/var/log/jsoizo

メモ帳 技術とか趣味とか

Itelable<T>のfoldとrunningFoldの違い

よくつかっているIterableの自作extensionを紹介します - Speaker Deck

で以下のようなコードを紹介した。

    fun <A, F, S> Iterable<T>.partitionMap(
        predicate: (A) -> Boolean,
        transformFirst: (A) -> F,
        transformSecond: (A) -> S
    ): Pair<List<F>, List<S>> = this.fold(Pair(emptyList(), emptyList())) { acc, it ->
        val (fList, sList) = acc
        if (predicate(it)) Pair(fList, sList.plus(transformSecond(it)))
        else Pair(fList.plus(transformFirst(it)), sList)
    }

に対して「runingFoldではなくfoldなのはなぜ?」という質問を頂いて、その場で答えられず「runningFoldを初めて聞いたのでわからないです。素人なので僕の引き出しではこれが限界でした」てきなやや礼節に欠く回答?態度?をしてしまったのでちゃんと調べておく。

foldとrunningFoldそれぞれの説明と実装はそれぞれ以下の通り。

まず fold
配列を走査して戻り値の型 R に変換していく。実装も単にforでぶん回してるだけなのでわかりやすい。

public inline fun <T, R> Iterable<T>.fold(initial: R, operation: (acc: R, T) -> R): R {
    var accumulator = initial
    for (element in this) accumulator = operation(accumulator, element)
    return accumulator
}

つぎに runningFold
引数はfoldと同様であるが、戻り値の型が List<R> になっている。foldとの違いは走査途中の operation の適用結果を溜めてリストに含めるという点か。 runningFold().last == fold() といえる。

public inline fun <T, R> Iterable<T>.runningFold(initial: R, operation: (acc: R, T) -> R): List<R> {
    val estimatedSize = collectionSizeOrDefault(9)
    if (estimatedSize == 0) return listOf(initial)
    val result = ArrayList<R>(estimatedSize + 1).apply { add(initial) }
    var accumulator = initial
    for (element in this) {
        accumulator = operation(accumulator, element)
        result.add(accumulator)
    }
    return result
}

runningFoldのシグネチャを見る限りとしては、やりたかった partitionMap は最終的に Pair<List<F>, List<S>> を返したいので、実装としてfold系統を使うならfoldが妥当そうである。

...
...
...

という話だけだとつまらないのとrunningFoldの使い所がわからなかったので、とくいげにrunningFoldのKEEP を読んでみる。言語仕様の背景を見るにはKEEPだと今日学んだので。
アルゴリズム的な目的で累積和を求めるのに使えるぞ的な説明が多め。アルゴリズム素人なので累積和って言葉自体が初めましてなのだが、配列の任意の区間の差とかを何度も取り出したい場合に一度累積和を出しておくと便利的なことらしい。

累積和 | アルゴリズムビジュアル大事典

ここまで調べてさすがに疲れたので寝る。
元気があったら追記する。

Arrow.ktにpartitionMapを追加するPRを出してコミュニティの暖かさに包まれた話

社内でラップバトル的な感じで情報共有をする会があるのだが、その場で "ScalaのpartitionMapがKotlinのIterableにはなくて不便だからextension書いた" 的な話をした。
反応も多少あったので調子に乗ってArrow-ktにissue切ってPRだしたら無事に取り込まれた。Arrowの次のバージョンが1.1.6や1.1.7になるか2.0.0になるか微妙なところだが、何れにせよ次期リリースには含まれるはずである。

github.com

感想としては、extension作って便利だったら積極的にOSSに対してコントリビュートして取り込んでもらうというのは今後も有効なムーブだなと感じたのが一つ。あとはKotlinというかArrow.ktのコミュニティの温かみを感じたのがもう一つ。このblogでは後者について記録しておく。

実装途中、knitが生成するテストコードのまわりで困ったことがあったので、以下の通り質問をSlackに投げたところ、有識者やArrowのメンテナにレスをもらい、そもそもの実装に対する改善策も提示してもらった。さらに私のブランチに対してこうすれば良いぞ的な例をPRの形で出してくれて、手厚い対応をしてもらっている。

https://slack-chats.kotlinlang.org/t/10117561/hello-i-am-trying-to-implementing-for-this-issue-i-requested#a15fb724-bf0d-4777-9acb-baea51036bc8

結果、私としてはその意見をもとに修正したコミットを作りArrow.ktに対してPR出すだけのかんたんなお仕事となった。

こういう技術者コミュニティで質問するのとかは個人的には結構勇気がいる活動なのだけど、今回このSlackで相談をするのは初めてだったにもかかわらず心理的ハードルがまったくなく行うことができた。というのも、Kotlin Lang Slackのarrow部屋に関しては常日頃からFPの初学者風のコメントから上級者っぽそうなコメントまでメンテナや有識者が軒並み細かく反応してくれているのを見ていたので、今回も反応くれるだろうというイメージが持てて安心していたからである。

また出したPRに対しても、first contributeに対する感謝のコメントが付いたり、関数名について "ChatGPTはseparateMapが良いって言ってるけど俺はseparateEitherが妥当だとおもうわ" 的なコメントでやんわり直すことを提案してくれた。他のPRでメンテナ同士の会話を見るともっとストレートなのでおそらく初コントリビュートだから優しくしてくれたのだと勝手に思っている。そういうところやslackでのコミュニケーションのとり方から、メンテナのコミュニティ維持・発展に対するスタンスが伝わり感動している。

いくつかのOSSにちょっとしたPRを何度か出したことがある程度なのでサンプルが少ないが、ここまでコントリビュート体験が良かったのは初めてかもしれない。レビューコメントの付け方は自分の仕事上でも活かせそうだし、とても学びのあるOSS活動となった。

Iterable<T>.firstNotNullOfOrNullはぱっとみよくわからないけどとても便利

scalaでいうcollectFirst的なやつが欲しいなーと思っていたところ、
firstNotNullOfOrNullっていうやーつがメソッド名だけ読んで理解するのが困難だがcollectFirst的に使えて便利だったのでメモ。

シグネチャは以下通り

public inline fun <T, R : Any> Iterable<T>.firstNotNullOfOrNull(transform: (T) -> R?): R?

配列の各要素に対して、引数に与えた変換処理transformを実行し、

  • transformの結果が最初にnullでないものが見つかればそれを返す
  • 配列の中に一つも条件を満たすものがなければnullを返す

というもの。

例えば適当な乱数とインデックスを持った配列から最初に9の倍数が出てきた場所を探したい場合、このように書ける。

val list = (1..100).map{ Pair(it, Random.nextInt(0, 100)) }
val result = list.firstNotNullOfOrNull{ (i, rand) -> 
    if (rand % 9 == 0) i else null
}

こうすることもできるが配列を2回ループすることになり効率が悪い。

val result = list.mapNotNull{ (i, rand) -> 
    if (rand % 9 == 0) i else null
}.firstOrNull()

なおscalaだとこんなかんじ

val rand = new scala.util.Random
val list = (1 to 100).map((_, rand.nextInt(100)))
list.collectFirst{ case (i, rand) if rand % 9 == 0 => i }

ちなみに Iterable<T>.firstNotNullOf というのもあるが、これは Iterable<T>.firstNotNullOfOrNull でNullが返却される際に例外を投げる版。 例外から解放されて安全にnullを扱いたくてこういうメソッドを使っているわけなのでこれが必要な状況はあまり想像できない。

jOOQのRecordから値を取得するときのnullableをarrowのoptionで綺麗に取り扱う

jOOQの Record.get(field) は以下のようなシグネチャでnullableである。

 <T> T get(Field<T> field) throws IllegalArgumentException;

nullableな処理が一つなら良いが、複数フィールドを取得したい場合などは以下のようなコードを書くことがある。
TODOS テーブルの各フィールドにはNOT NULL制約がついているものとする。

val query = create.select().from(TODOS)

val todos = query.fetch().map { record ->
    ToDo(
        id = record.get(TODOS.ID)!!,
        title = record.get(TODOS.TITLE)!!,
        body = record.get(TODOS.BODY)!!,
        created_at = record.get(TODOS.CREATED_AT)!!,
        modified_at. = record.get(TODOS.CREATED_AT)!!
    )
}

この例は、各フィールドに対してNOT NULL制約がついているので !! で強制的に値を読み取ったとしてもNullPointerExceptionが発生することはないだろう。だが、たとえば left outer join をした右側のテーブルに対してこれを実行した場合はNullPointerExceptionが発生してしまう。ゆえに極力 !! を使うことは避けて通りたい。
また、jOOQ code generatorなどで生成したTableRecord型のクラスについても、フィールドにNOT NULL制約がついていたとしても生成されるTableRecordのフィールドはすべてnullableとなってしまう。*1

これに対しarrow coreのoptionを利用すると若干の記述量の増加だけでこれを多少改善することができる。
option型自体の説明は↓から

jsoizo.hatenablog.com

val todos: List<ToDo> = query.fetch().mapNotNull { record ->
    runBlocking {
        option {
            ToDo(
                id = record.get(TODOS.ID).toOption().bind(),
                title = record.get(TODOS.TITLE).toOption().bind(),
                body = record.get(TODOS.BODY).toOption().bind(),
                created_at = record.get(TODOS.CREATED_AT).toOption().bind(),
                modified_at. = record.get(TODOS.CREATED_AT).toOption().bind()
            )
        }.orNull()
    }
}

arrowにて nullableをOptionに変換する <T> T?.toOption(): Option<T> と、Optionの合成を行う関数が定義されている。それを用いると、ToDo型のインスタンス作成時にフィールドに1つでもnullがある場合にはToDoオブジェクトは生成せずにNoneとすることができる。
その後 .orNull() によってOptionをnullableに戻してたうえで、さらにfetch()した結果に対してmapNotNullしているので、最終的にはnullのないListが返却されることになる。

毎度 toOption() するのがだるかったら以下のような拡張関数を定義しておくと良いかもしれない。

fun <T> Record.getOrNone(field: Field<T>): Option<T> = this.get(field).toOption()

TableRecordを使っている場合はどうしたらいいかわからん。
code generatorの拡張みたいなことをすればいいのかな?できるかもわからないけど。。。

ダラダラ書いてしまったが要するにJavaのOptionalやScalaのOptionライクな挙動にすればスッキリすることもあるね〜というだけの話。ただKotlinに慣れていないだけでいい感じにできるのかもしれない。。。

*1:jOOQ 3.18のcode generatorにはこのあたりを改善するオプションが追加される? Add options to generate non-null attributes on Records, Pojos, and interfaces in KotlinGenerator · Issue #10212 · jOOQ/jOOQ · GitHub

Arrow Coreのデータ型探訪

これは Kotlin Advent Calendar 2022 の19日目が空いていたので書いたものです。

Kotlin中心の仕事になるので少しずつお勉強中なのだが、Scalaでコードを書いているときに使っている便利なデータ型が意外とKotlinにはないなとかNull-Safetyの方針がScalaとは違うのだなと感じることがある。

これは、その感想をうけて関数型プログラミングライブラリであるArrowを使えばややScalaぽいコードが書けそうなので試してみるものである。ArrowにはCore, Fx, Optics等ライブラリがいくつかあるがそのうちArrow Coreの一部データ型について触っていく。

追記: 2022.12.21 Tuple4~22について追加した

続きを読む

AWS JDBC Driver for MySQLをslick経由で使いたい

前回、AWS JDBC Driver for MySQLの検証を行ったが、実際にはそのままJDBCドライバを使うということはなく、scalaなアプリケーションで使いたいときにはslickから呼び出すことが多いと思われる。

また、AWS JDBC Driver for MySQLはコネクションプールとしてHikariCPを利用しているときに以下2点が必要である。

  • SQLExceptionOverride を継承してAWS JDBC Driver for MySQLが出す一部SQLExceptionの例外処理の実装
  • 実装した例外処理をHikariCPの設定に追加( setExceptionOverrideClassName )

ここで、slick利用時 Database.forConfig でDatabaseインスタンスを作成することになるが、slickの設定ファイルの記載にHikariCPの setExceptionOverrideClassName に相当する項目が存在していない。

なので、 HikariDataSource をnewして Database.forDataSource によりDatabaseインスタンスを作成する必要がある。

class HikariCPSQLException extends SQLExceptionOverride {
  import SQLExceptionOverride.Override
  private val ignoreSQLStates = Seq("08S02", "08007")
  override def adjudicate(sqlException: SQLException): SQLExceptionOverride.Override = {
    val sqlState = sqlException.getSQLState
    if (ignoreSQLStates.exists(sqlState.equalsIgnoreCase)) Override.DO_NOT_EVICT
    else Override.CONTINUE_EVICT
  }
}

val connectionPool: HikariDataSource = {
  val _config = new HikariConfig()
  _config.setJdbcUrl(jdbcUrl)
  _config.setUsername(user)
  _config.setPassword(password)
  _config.setExceptionOverrideClassName("com.jsoizo.HikariCPSQLException")
  new HikariDataSource(_config)
}
val database = Database.forDataSource(connectionPool)

これはさすがに面倒なので、 Database.forConfig でconfファイルから exceptionOverrideClassName を設定できるように修正しPull Requestを出して取り込んでもらった。次バージョン 3.4.2 でリリースされると思われる。

github.com

普段何気なく使っているOSSの一部に貢献できてよかった。

AWS JDBC Driver for MySQLのfailover機能に関する動作検証

これは スタンバイアドベントカレンダーの9日目です。

はじめに

Amazon Aurora for MySQLクラスタを組んでいて、可用性を高めるために書き込み可能インスタンスを複数並べてActive/Standby構成にすることがある。
このような運用をしているとき、Activeなインスタンスに問題があるとフェイルオーバーしてStandbyしているサーバが昇格するが、DBを利用しているアプリケーション側も接続先を自動的に昇格したサーバに対して切り替えたい。
ここではJVM言語 + JDBCなアプリケーションにおけるそのような要件に対して応えるために、2022年3月よりAWS公式でAurora for MySQL用のJDBCドライバを提供してくれるようになったので扱ってみることにする。

TL;DR

  • AWS JDBC Driver for MySQLAWSが提供しているAuroraのfailover検知機能を持ったMySQLJDBCドライバ
  • Auroraがfailoverしてwirterインスタンスが切り替わったときにJDBCで勝手に接続先インスタンスを切り替えてくれるのでアプリケーションは書き込み先を意識する必要がなくなる
  • Connection Poolを利用する場合はConnection Pool側でfailoverエラー時の再接続を回避する必要あり
  • 意図せずwriterインスタンスに昇格してしまう可能性もあるのでリードレプリカのみ利用したい場合は利用しないが吉

AWS JDBC Driver for MySQLとは

AWSが提供している、Aurora for MySQLのfailoverを検知し再接続をする機能を持ったMySQLJDBCドライバ。
本件ではfailoverの機能だけを検証するがその他にもAurora for MySQLを利用するにあたって便利なIAM接続とかSSMで管理しているクレデンシャルの読み込みなどの機能を有している。

https://github.com/awslabs/aws-mysql-jdbc#amazon-web-services-aws-jdbc-driver-for-mysql

awslabs.github.io

どのようにfailoverが動いているのか?

MySQL Connector/Jのプラグインとして実装されており、以下の図の通りに動作する。 JDBCドライバ内にLogical(論理) ConnectionとPhysical(物理) Connectionを保持し、ユーザ側にはLogical Connectionを見せている。Failover Processがクラスタのfailoverを検知し、検知したら新しい接続先へのConnectionを張りPhysical Connectionを置き換える。Logical Connectionから参照しているPhysical Connectionだけが変わっているのでユーザ側からはConnectionを使い続けることができるということである。

img 画像引用元: github.com/awslabs/aws-mysql-jdbc#enhanced-failure-monitoring

細かいことは FailoverConnectionPlugin.javaAuroraTopologyService.java の実装を追いかけていくとわかる。

その他の選択肢

AWS JDBC Driver for MySQL以外にもAuroraのfailoverを検知した切り替えをしたり、プロキシサーバーを動かすなどでfailover対応をするソリューション自体は存在しているので明記しておく。すでに他の方にて検証など充分に行われているのでことさらにここで深堀りはしない。

MariaDB Connector/J

MariaDB向けのJDBCドライバであるMariaDB Connector/Jのバージョン2系においてはAuroraのfailoverを検知する機能が実装されている。
これはAuroraのインスタンスに接続しグローバル変数 innodb_read_only の変化によりAuroraのfailoverを検知し、接続を再確立しようとするもの。

dev.classmethod.jp

なお、MariaDB Connector/Jの最新のメジャーバージョンである3系からはAuroraのfailover検知モードは削除されている。

mariadb.com

理由は以下に引用する通りで、当面は2系をサポートし続けるようなので使うことはできるが、AWS公式に手段を提供されている状況において新規にfailover対応をしたいときの選択肢としては微妙ではないかと思われる。

driver 3.0 is a complete rewrite of the connector. Specific support for aurora has not been implemented in 3.0, since it relies on pipelining. Aurora is not compatible with pipelining. Issues for Aurora were piling up without the community proposing any PR for them and without access for us to test those modifications. (2.x version has a 5 years support).

引用元: About MariaDB Connector/J - MariaDB Knowledge Base

Amazon RDS Proxy

RDS ProxyはAuroraを含むRDSに対するプロキシを提供するサービスで、主にserverlessなアプリケーションむけのコネクションのプールやfailover時の書き込み先インスタンスの切り替えなどの機能がある。

aws.amazon.com

DBクラスタでfailoverが発生したとしても、Proxyがそれを検知しDBクラスタ - Proxy間の接続は切り替えつつも、Proxy - アプリケーション間の接続はそのままになるために高速に接続を切り替えられるというもの。中身は不明だがMariaDB Connector/JのAurora failover検知よりも早いこと、JVM系の言語に限らず利用することができるのが良い点。

AWS JDBC Driver for MySQLがfailoverを検知して接続先を切り替える動作検証

ここからは、実際にAWS JDBC Driver for MySQLを利用してfailover時に接続先が切り替わることを検証してみる。

検証を行うDBクラスタ構成

1つのクラスタに以下DBインスタンスを作成する。

役割 Tier host
1号機 writer 0 ip-10-7-2-150
2号機 reader 0 ip-10-7-2-175
3号機 reader 5 ip-10-7-2-43

エンドポイントはデフォルトで用意されているclusterエンドポイントとreaderエンドポイント以外にも作成する。

接続先 備考
clusterエンドポイント 1号機 自動作成
read-only clusterエンドポイント 2,3号機 自動作成
書き込み用エンドポイント 1,2号機 カスタムエンドポイント
読み込み用エンドポイント 3号機 カスタムエンドポイント

書き込み用はTierの高いインスタンス2台によりActire/Standby的な構成にしておき、読み取り専用エンドポイントはTierが低いものだけとしてwriteが発生しうるインスタンスへ接続が発生しないようにする。と想定した設計である。

実施する検証内容

以下のような条件下でfailover時にどのように挙動するかを、ドキュメントに記載されたエンドポイント種別ごとの初回接続先とfailover時の挙動を照らし合わせながら確認する。
実装はJVM系の言語なら何でも良いがScalaで行う。

  1. clusterエンドポイントに接続した状態でfailoverをしたとき
  2. カスタムエンドポイントに接続した状態でfailoverをしたとき
  3. connection poolを利用している状態でfailoverしたとき
  4. read-only クラスタエンドポイントに接続した状態でfailoverをしたとき

発行するクエリは固定でhostnameをサーバから取得するだけのものとする。

SELECT @@hostname;

検証-1. クラスタエンドポイントに接続した状態でfailoverをしたとき

クラスタエンドポイントに接続している場合のfailover時の挙動は

Initial connection: primary DB instance Failover behavior: connect to the new primary DB instance

なので、1号機(writer)に繋いでいる状態でfailoverすると自動的に2号機(reader)に再接続するはずである

検証用コードは以下通り。
与えられたホストに対してAWS JDBC Driver for MySQLで接続を確立し、その接続を利用して無限に接続先を確認するクエリを投げ続ける。リトライ周りは softwaremill/retry を利用している。

  val host     = ""
  val user     = ""
  val password = ""
  val database = "test"

  val jdbcUrl = s"jdbc:mysql:aws://${host}:3306/${database}"

  def main(args: Array[String]): Unit = {
    val connection: Connection = DriverManager.getConnection(jdbcUrl, user, password)
    val sql                    = "SELECT @@hostname"
    while (true) {
      val resultF = withRetry(() => execQuery(connection, sql))(20)
      val result  = Await.result(resultF, 1.minute)
      if (result.next()) {
        println(s"connected_host_name: ${result.getString("@@hostname")}")
      }
    }
    connection.close()
  }

  private def execQuery(connection: Connection, sql: String): Future[ResultSet] = {
    val f = Future {
      val statement = connection.createStatement()
      statement.executeQuery(sql)
    }
    f.failed.foreach {
      case e: SQLException => log(s"[${e.getSQLState}] ${e.getMessage}")
      case e               => log(e.getMessage)
    }
    f
  }

  private def withRetry(f: () => Future[ResultSet])(retryCount: Int): Future[ResultSet] = {
    implicit val success: Success[ResultSet] = Success.always
    retry.Pause(retryCount, 1.second).apply(f)
  }

結果 : 期待通り 1号機に接続中failoverし2号機に接続先のホストが切り替わった

JDBC固有のSQLExceptionを発生させたのちに接続先が切り替わっている。

2022-11-27T23:50:54.067688 - connected_host_name: ip-10-7-2-150
2022-11-27T23:50:54.077197 - connected_host_name: ip-10-7-2-150
2022-11-27T23:51:00.119274 - [08S02] The active SQL connection has changed due to a connection failure. Please re-configure session state if required.
2022-11-27T23:51:01.153104 - connected_host_name: ip-10-7-2-175
2022-11-27T23:51:01.160771 - connected_host_name: ip-10-7-2-175

検証-2. カスタムエンドポイントに接続した状態でfailoverをしたとき

ここでは 検証-1と同じ実装で接続先だけを変えている。以下のカスタムエンドポイントでそれぞれ実験する

  • A: 書き込み用(1,2号機)
  • B: 読み込み用(3号機)

カスタムエンドポイントに接続している場合は、failover時にPrimaryつまり高Tierインスタンスへ接続を切り替えるが、カスタムエンドポイントのメンバーに含まれていないのだとしてもPrimaryへ接続する可能性があると記載されているので、上記Bのパターンでも1,2号機に切り替わるかもと解釈している。

Initial connection: any DB instance in the custom DB cluster Failover behavior: connect to the primary DB instance (note that this might be outside of the custom DB cluster)

結果A: 期待通り 1号機に接続中failoverし2号機に切り替わった。検証1と同様の結果

2022-11-28T00:09:37.526918 - connected_host_name: ip-10-7-2-150
2022-11-28T00:09:37.533356 - connected_host_name: ip-10-7-2-150
2022-11-28T00:09:42.929866 - [08S02] The active SQL connection has changed due to a connection failure. Please re-configure session state if required.
2022-11-28T00:09:43.960524 - connected_host_name: ip-10-7-2-175
2022-11-28T00:09:43.967610 - connected_host_name: ip-10-7-2-175

結果B: 3号機に接続中failoverし、そのまま3号機に接続し続けた

ドキュメントに記載された挙動(Primaryへ接続を切り替える)とは異なる結果となった。理由は不明であるが、必ず同じ結果が得られるのであればむしろ良好である。ただし勝手ににPrimaryへ接続が切り替わられると厄介であるので、このようなレプリカだけを対象としたエンドポイントを作る場合は本JDBCを利用せずMySQL Connector/Jを素直に使うのが良いと思われる。

検証-3. connection poolを利用している状態でfailoverしたとき

Connection Poolを利用しているときは注意が必要である。
公式のドキュメントにも記載の通り、Connection Poolライブラリの実装次第ではJDBCドライバが投げたSQL Exceptionをキャッチし、特定のエラーの場合はコネクションを再確立しようとする。だが、このJDBCドライバを使ってAuroraに対して接続するときにはfailover時のwriterインスタンスへの接続の切り替えをJDBC側で行うために、failoverの例外時はConnection Poolライブラリ側ではコネクションをそのまま維持し続けてほしい。よってJDBCドライバがConnection Poolライブラリにfailoverのエラーを返した場合の処理を追加で実装する必要がある。

Scalaで実装する場合SlickにせよQuillにせよデフォルトのConnection PoolライブラリはHikari CPであるため、公式のドキュメントに記載されたSQLExceptionOverrideの実装を行いHikari CPの ExceptionOverrideClassName 設定に実装したクラス名を与えてあげれば良い。

https://github.com/awslabs/aws-mysql-jdbc#connection-pooling

import com.zaxxer.hikari.SQLExceptionOverride
import java.sql.SQLException

class HikariCPSQLException extends SQLExceptionOverride {
  import SQLExceptionOverride.Override
  private val ignoreSQLStates = Seq("08S02", "08007")
  override def adjudicate(sqlException: SQLException): SQLExceptionOverride.Override = {
    val sqlState = sqlException.getSQLState
    if (ignoreSQLStates.exists(sqlState.equalsIgnoreCase)) Override.DO_NOT_EVICT
    else Override.CONTINUE_EVICT
  }
}

上記に加え、Connection Poolの作成とconnectionをPoolから取得するように修正する。

  def main(args: Array[String]): Unit = {
    val connectionPool: HikariDataSource = {
      val _config = new HikariConfig()
      _config.setJdbcUrl(jdbcUrl)
      _config.setUsername(user)
      _config.setPassword(password)
      _config.setExceptionOverrideClassName("com.jsoizo.HikariCPSQLException")
      new HikariDataSource(_config)
    }
    val sql = "SELECT @@hostname"
    while (true) {
      val connection: Connection = connectionPool.getConnection
      val resultF                = withRetry(() => execQuery(connection, sql))(20)
      val result                 = Await.result(resultF, 1.minute)
      if (result.next()) {
        log(s"connected_host_name: ${result.getString("@@hostname")}")
      }
      connection.close()
    }
  }

結果: 期待通り 1号機に接続中failoverし2号機に接続先のホストが切り替わった

検証1と同じ結果を得ることができた

2022-11-28T10:05:26.017121 - connected_host_name: ip-10-7-2-150
2022-11-28T10:05:26.024403 - connected_host_name: ip-10-7-2-150
2022-11-28T10:05:30.306909 - [08S02] The active SQL connection has changed due to a connection failure. Please re-configure session state if required.
2022-11-28T10:05:31.336693 - connected_host_name: ip-10-7-2-175
2022-11-28T10:05:31.344043 - connected_host_name: ip-10-7-2-175

検証-4. read-only クラスタエンドポイントに接続した状態でfailoverをしたとき

read-only clusterエンドポイントに繋いだ場合、failover時にはTier加味しつつレプリカに接続しようとするので、readerが2,3号機であるときにfailoverして2号機がwriterに昇格すると1号機か3号機のいずれかに接続すると思われる。ただし昇格後のPrimaryつまり2号機に接続する可能性もある模様。

Initial connection: any Aurora Replica Failover behavior: prioritize connecting to any active Aurora Replica but might connect to the primary DB instance if it provides a faster connection

結果: 期待通り 2号機に接続中failoverし3号機に切り替わった。

2022-11-28T10:24:34.566652 - connected_host_name: ip-10-7-2-175
2022-11-28T10:24:34.572563 - connected_host_name: ip-10-7-2-175
2022-11-28T10:24:34.746535 - [08S02] The active SQL connection has changed due to a connection failure. Please re-configure session state if required.
2022-11-28T10:24:35.780123 - connected_host_name: ip-10-7-1-43
2022-11-28T10:24:35.789052 - connected_host_name: ip-10-7-1-43

まとめ

AWS JDBC Driver for MySQLのfailover時の挙動について、概ね期待通りの結果を得ることができた。
現状のfailover検知によるwriterインスタンスの切り替えについてはRDS Proxyを使うことが最初の選択肢だと思われるが、平常時のプロキシのオーバーヘッドが気になる場合や、JVM系の言語を使っているならばこのJDBCを使うことで安易にfailover検知を行うことができるので良さそうだ。

githubを見る限りドキュメントも丁寧にまとまっており、プロダクション環境で使うにも困ることは無いと思われる。積極的に使っていくことをおすすめしたい。

リンク

Amazon Web Services (AWS) JDBC Driver for MySQL

GitHub - awslabs/aws-mysql-jdbc: The Amazon Web Services (AWS) JDBC Driver for MySQL is a driver that enables applications to take full advantage of the features of clustered MySQL databases.

GitHub - brettwooldridge/HikariCP: 光 HikariCP・A solid, high-performance, JDBC connection pool at last.