kagamihogeの日記

kagamihogeの日記です。

昔のmariadb-java-clientはfetch across commitが出来ない

ぐぐってみると、mariadb-java-clientは2014年頃の1.1.7ではfetch-sizeの挙動が怪しくデータ量など運が悪いとOOMになるケースも多かったようだが、少なくとも3.5.7ではその挙動は無くなっている。

検証内容

docker run --name some-mariadb -e MARIADB_ROOT_PASSWORD=pass -p 3306:3306 mariadb:latest

簡単にメモリを溢れさせるために適当な長さの文字列カラムを持つテーブルを作成。適当な件数をあらかじめ追加しておく。

create table sample (
 id SERIAL NOT NULL PRIMARY KEY,
 name varchar(10000)
);

以下の定義だとorg.mariadb.jdbc:mariadb-java-client3.5.7となる。

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.0.1'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation 'org.apache.commons:commons-lang3'

    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' //3.5.7
}

tasks.named('test') {
    useJUnitPlatform()
}

簡単にOOM起こさせるために最大メモリを極端に小さくする。

-Xmx10m

1.まずは以下のように全件読み込もうとしてOOMの発生を確認する。

    JdbcClient c = JdbcClient.create(ds);
    List<DataLoaddd> list = c.sql("select id, name from sample").query(DataLoaddd.class).list();
    System.out.println(list.size());

2.次に、fetch-sizeを指定してstreamではOOMが発生しない事を確認する。

    JdbcClient c = JdbcClient.create(ds);
    c.sql("select id, name from sample")
        .withFetchSize(1)
        .query(DataRecored.class)
        .stream()
        .forEach(d -> {
          System.out.println(d);
        });

3.加えて、読み込んだ行をupdateするトランザクション処理を追加してもOOMが発生しない事を確認する。

    JdbcClient c = JdbcClient.create(ds);
    c.sql("select id, name from sample")
        .withFetchSize(1)
        .query(DataRecored.class)
        .stream()
        .forEach(d -> {
          System.out.println(d);

          TransactionDefinition t = new DefaultTransactionDefinition();
          TransactionStatus transaction = m.getTransaction(t);
          int update = c.sql(
              "update sample set name = '" + RandomStringUtils.insecure()
                  .nextAlphanumeric(10000) + "' where id = " + d.id()).update();
          m.commit(transaction);

        });

ここでmariadb-java-clientを超古い1.1.7に変更すると、1・2・3いずれもOOMになる。

    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:1.1.7'

というわけで、2014年頃とかの1.1.7はまともにfetch-sizeが機能していなかったが現代の3.5.7とかはちゃんと機能していそうである。少なくとも、良くあるfetch across commitは書けそうではある。

余談

はじめてMariaDBとそのJDBCを使う事になったが「mariadb bulk fetch」とかでぐぐると上手くいかないとか何とかのブログが何件かヒットして不安になった。ただ、いずれも2015年前後のかなり古いもの。それで、とりあえず単純な動作だけでも確認しとくか、となった。こんくらいなら問題無さそうなのでとりあえず安心している。

spring-bootのrecordへのデシリアライズとバリデーション

record導入後はspring-bootのhttp request/responseのマッピング先はこれになる……かどうかは分からない。とはいえ一通りの挙動確認をする。また、recordが普及すればいわゆるvalue objectへ直接マッピングも増えると思われるのでそれを想定した確認をする。

環境

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.0.5'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2'

    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

デシリアライズ

厳密にはjacksonの使い方ではあろうが、spring-bootを特に何も考えなければこれを使うであろう。

以降では省略するが、下記のようなcontrollerメソッド引数でreqeustのjsonを親となるrecordへデシリアライズ、を考える。

  @PostMapping("/a")
  public String a(@RequestBody SampleRequest request) {

単一引数

{
  "id": "asdf"
}
public record SampleRequest(SampleId id) {}
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonCreator.Mode;

public record SampleId(String value) {
  @JsonCreator(mode = Mode.DELEGATING)
  public SampleId {}
}

親recordのコンストラクタ引数名をキー名とし、@JsonCreator(mode = Mode.DELEGATING)のコンストラクタ委譲によりvalue objectのコンストラクタへ値を渡す。@JsonCreatorとかDELEGATINGとかややこしいがjacksonでは仕方ないかな……と思う。ややこしい事の詳細は下記を参照。

kagamihoge.hatenablog.com

単一引数(List)

{
  "list": ["string", "sadf"]
}
public record SampleRequestList(SampleList list) {}
public record SampleList(List<String> value) {
  @JsonCreator(mode = Mode.DELEGATING)
  public SampleList {}
}

単一引数(List要素がvalue object)

配列要素をvalue objectにマッピングする。

{
  "list": ["a", "b"]
}
public record SampleRequestListValue(SampleListObject list) {}
public record SampleListObject(List<SampleId> value) {
  @JsonCreator(mode = Mode.DELEGATING)
  public SampleListObject { }
}
SampleRequestListValue[list=SampleListObject[value=[SampleId[value=a], SampleId[value=b]]]]

出来るのか……という奇妙な感動をする。

複数引数

{
  "start": 10,
  "end": 20
}
import com.fasterxml.jackson.annotation.JsonUnwrapped;

public record SampleRequestRange(
    @JsonUnwrapped
    SampleRange range) {}
public record SampleRange(int start, int end) {}

Java側の子オブジェクトのプロパティを親のものとして扱う。

qiita.com

なお、下記のように単に子オブジェクトとして扱うなら@JsonUnwrappedは不要。

{
  "range": {
    "start":10,
    "end": 20
  }
}

ミックス

上記までのを一緒に使用する。

{
  "id": "asdf",
  "list": ["string", "sadf"],
  "start": 0,
  "end": 0
}
public record SampleRequestMix(
    SampleId id,
    SampleList list,
    @JsonUnwrapped
    SampleRange range) {}

バリデーション

セキュア・バイ・デザイン方式

recordのコンストラクタに条件をべた書きする。

public record SampleRequest(SampleId id) {

  public SampleRequest {
    if (id == null) {
      throw new MyDomainViolationException();
    }
  }
}
public record SampleId(String value) {

  @JsonCreator(mode = Mode.DELEGATING)
  public SampleId {
    if (value == null || value.isEmpty()) {
      throw new MyDomainViolationException();
    }

    if (2 < value.length()) {
      throw new MyDomainViolationException();
    }
  }
}

大仰な名前だが、セキュア・バイ・デザイン: 安全なソフトウェア設計 が紹介している方式だからここではその名前をお借りした。詳細は書籍参照で、ざっくり言えば、ドメインルールの順守がセキュアにも繋がる、という主張。書籍は説明のため特定のフレームワーク・ライブラリへの依存を避けてるだけとは思うが、Javaならコンストラクタにベタ書きと実行時例外の組み合わせは、見た目はモッサリするが十分現実的かな、と思う。

なお、jacksonの変換ルールには注意が必要。たとえば、{"id": null}とか{}だとvalue-objectのコンストラクタまで処理が来ない(=DELEGATINGが発生しない)ため、意図通りの挙動にならない。なので親record側でnullチェックが必要になる。ここはドメインではなくcontrollerの世界だから@NotNullとミックスしても良いか、という割切もアリ……かもしれない。

アノテーション

@RestController
public class SampleController {

  @PostMapping("/a")
  public String a(@RequestBody @Validated SampleRequest request) {
public record SampleRequest(
    @Valid
    SampleId id) {}
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;

public record SampleId(
    @NotEmpty
    @Size(max = 2)
    String value) {

  @JsonCreator(mode = Mode.DELEGATING)
  public SampleId {
  }
}

まず、従来通りcontrollerメソッドにorg.springframework.validation.annotation.Validated(またはjakarta.validation.Valid)を付与する。また、これも従来通りネストしたオブジェクトを検証対象にする場合はそれに@Validを付与する。最後に、recordのコンストラクタ引数にアノテーションを付与する。

@JsonUnwrapped も同様。

public record SampleRequestRange(
    @JsonUnwrapped
    @Valid
    SampleRange range) {}
public record SampleRange(@Max(5) int start, int end) {}

感想とか

デシリアライズだけならrecordでシンプルに収められそうな感触がある。

入力値検証は……正直よくわからない。spring bootだと伝統的なbean validationが実績もあり情報も豊富だが、ややこしい事をしようとするとすごいややこしい事になるのが個人的にはあまり好きではない。セキュア・バイ・デザイン方式は考え方はシンプルでややこしい検証ルールも素直に実装してしまえば良いが、コードがモッサリしてアノテーションの簡潔さを捨ててしまうのも勿体なく感じる。昨今はドバッと大量生成もなんとなく許される風潮もあるが、そのトレンドも何時まで続くのかはよくわからない。

他言語や他ライブラリだと全く別アプローチをしておりなるほどなぁと関心するが、とはいえ既存コードとあまりにも隔絶した方針は取れないしなぁ、などと悩む。ずーっと悩んでるで一生悩み続ける気がしないでもない。

参考URL

DirtiesContextでSpringBootTestのcontextリフレッシュタイミングを制御

@SpringBootTestでのテストケース記述は場合によりbeanの状態更新が避けられずcontextのリフレッシュが必要になる事もある。その解決にはorg.springframework.test.annotation.DirtiesContextを使用する。このアノテーションをクラスやメソッドに付与するとcontextをdirtyにするタイミングを指定できる。なお、俺は勘違いた点として後述するが、dirtyにするのとcontextリフレッシュのタイミングの違いが重要な場合もある。

ソースコードと調査内容など

@SpringBootTestを付与するクラスを2つ用意し、それぞれに@DirtiesContextを付与したり指定変更したりで挙動を確認する。

本エントリの前提条件としてテストクラスの実行順序はSampleTest1 -> SampleTest2で固定とする。特に順序指定アノテーションなどは指定しないが説明の簡易化のため省略する。

なし - なし

まず、両方のクラスとも@DirtiesContext無しで実行する。

@SpringBootTest
public class SampleTest1 {
  @BeforeAll
  static void beforeAll(@Autowired SampleApp c) {
    System.out.println("1 before all " + c);
  }

@SpringBootTest
public class SampleTest2 {
Started SampleTest1 
1 before all org.example.app.SampleApp@4975dda1
2 before all org.example.app.SampleApp@4975dda1

この場合contextは再利用可能と判断されてリフレッシュは行われない。

AFTER_CLASS - なし

先行テストクラスにAFTER_CLASSを付与する。

@SpringBootTest
@DirtiesContext
public class SampleTest1 {

@SpringBootTest
public class SampleTest2 {
Started SampleTest1 
1 before all org.example.app.SampleApp@25f0c5e7
Started SampleTest2
2 before all org.example.app.SampleApp@6a552721

先行テストクラス実行後にdirtyとマークされ、後続テストクラスの少なくとも@BeforeAll前にはcontexのリフレッシュが実行される。

なし - BEFORE_CLASS

@SpringBootTest
public class SampleTest1 {

@SpringBootTest
@DirtiesContext(classMode = ClassMode.BEFORE_CLASS)
public class SampleTest2 {
Started SampleTest1 
1 before all org.example.app.SampleApp@120df990
Started SampleTest2
2 before all org.example.app.SampleApp@5e020dd1

後続テストクラスのcontextリフレッシュ判定タイミング前にはdirtyとマークされ、少なくとも@BeforeAll前にはcontexのリフレッシュが実行される。結果として先行テストクラスのAFTER_CLASSと同等な挙動になる。ただし、テストクラスの実行順序によってはdirtyのマークタイミングが異なる点には注意が必要。

俺も勘違いしていたが、BEFORE_CLASS / AFTER_CLASSはリフレッシュタイミングの指定ではなく、あくまでもdirtyとマークするタイミングである。大半のケースで両者は一致するがテストクラスの実行順序によっては微妙に異なる場合もありうる。突然テストケースが失敗し始める原因になりうるので注意が必要だろう。

なし - AFTER_CLASS

@SpringBootTest
public class SampleTest1 {

@SpringBootTest
@DirtiesContext
public class SampleTest2 {
Started SampleTest1 
1 before all org.example.app.SampleApp@69d103f0
2 before all org.example.app.SampleApp@69d103f0

もしリフレッシュタイミング指定ならば、SampleTest2の後にリフレッシュが実行されているハズである。実際には、dirtyとマークしたがそこですべてのテストクラスが完了してリフレッシュ判定が訪れないので何も起こらない。

BEFORE_CLASS - なし

@SpringBootTest
@DirtiesContext(classMode = ClassMode.BEFORE_CLASS)
public class SampleTest1 {

@SpringBootTest
public class SampleTest2 {
Started SampleTest1 
1 before all org.example.app.SampleApp@b506ed0
2 before all org.example.app.SampleApp@b506ed0

もしリフレッシュタイミング指定ならば、SampleTest1の開始前に2回リフレッシュが実行されているハズである。実際には、dirtyマークしてもしなくても一度もリフレッシュ未実施なのでリフレッシュを単に1回実行する*1

BEFORE_CLASSのテストクラスがdirtyなケースを考える。テストクラスの実行順序は、他テストクラス -> BEFORE_CLASSがdirty、と、BEFORE_CLASSがdirty -> 他テストクラス、が考えられる。このとき、他テストクラスの立場からすると、dirtyとそうでない場合の2通りがありえてしまう。これはテストの不安定化要因となる。

すべてのテストクラスにBEFORE_CLASS付与で解決は出来るだろうが、contextのリフレッシュはそれなりに重い処理なので、今度はスローテストのリスクが高まる。

従って、あるテストクラスがdirtyになるならAFTER_CLASSでマーク、が定番の使い方になると思われる。実際にこれがデフォルトなわけだし。

*1:これは挙動からの推測に過ぎない。ドキュメントやソースの裏付けをしてないので、正確な記述では無い可能性がある