Instruction file imported from dumblepy/nim-basolato (
.cursor/rules/branch/bulk-update.mdc). Copyright stays with the author.
fixブランチルール
このブランチで整理・実装することは以下の通りです。
- FrameworkBenchmarks の PostgreSQL シナリオで、DB 接続時に
basolatoの性能が大きく低下する要因を切り分ける。 updatesシナリオの verify fail / 実行失敗要因を、basolato本体・allographer・ベンチマーク実装の3層に分けて整理する。- 改善案を「即効性が高いもの」と「中長期で効くもの」に分けて記録し、実装順序を判断できる状態にする。
進捗
-
basolatoベンチマーク実装(examples/benchmark/FrameworkBenchmarks/basolato)の DB / query / update / fortune 経路を確認した。 -
allographerの PostgreSQL 実装(接続プール、findPlain、update、prepared statement、non-blocking wait)を確認した。 - TechEmpower FrameworkBenchmarks の
updateverify 条件を確認し、rows updated/executed queries/ 実更新確認が失敗要因になりうることを整理した。 - 改善案を
basolato本体改善、allographer改善、ベンチマーク測定コード改善の3分類で整理した。 -
prepared_benchmark_controller.nimのqueryで、非同期クロージャがループ変数を参照して範囲外アクセスしうる問題(SIGSEGV)を修正した。 -
basolato側のupdatesを bulk update 化し、query/fortune/dbを同一接続上の prepared statement 実行に寄せた。 - ベンチマークコードを steady-state を測れる形へ寄せ、
updates2を比較用の個別更新経路として残した。 -
basolatoの PostgreSQL 接続数を保守的な値へ調整した。 -
allographer側の silent failure と prepared statement 再利用性を改善する。 - 変更前後の実測値を取り、
updatesとupdates2の差を比較する。
参考資料
/application/examples/benchmark/FrameworkBenchmarks/basolato/app/http/controllers/benchmark_controller.nim/application/examples/benchmark/FrameworkBenchmarks/basolato/config/database.nim/allographer/src/allographer/query_builder/models/postgres/postgres_exec.nim/allographer/src/allographer/query_builder/models/postgres/postgres_open.nim/allographer/src/allographer/query_builder/models/postgres/postgres_query.nim/allographer/src/allographer/query_builder/libs/postgres/postgres_impl.nim/allographer/documents/rdb/postgres_efficiency_report.md/allographer/.cursor/rules/branch/333-prepared-statement.mdchttps://itsumura-h.github.io/FrameworkBenchmarks/https://raw.githubusercontent.com/TechEmpower/FrameworkBenchmarks/master/toolset/test_types/update/update.pyhttps://raw.githubusercontent.com/TechEmpower/FrameworkBenchmarks/master/toolset/test_types/verifications.py
調査結果・設計まとめ
1. basolato自体の改善
updatesだけが 1 リクエスト内でcountNum本のFutureを生成してall(futures).awaitしている。queriesは直列なのに、updatesだけ内部 fan-out しているため、HTTP の同時接続数に加えて DB アクセスの同時実行数まで増幅している。- 現状の
updatesは 1 件ごとにSELECT + UPDATEを個別実行している。TechEmpower の verify では「実際に更新された件数」と「クエリ数・更新行数」が見られるため、素直に 500 件分の個別更新を投げると接続待ちと DB 負荷が重くなりやすい。 db/queriesは毎回rdb.table("World").findPlain(...)を通っており、同一 SQL を高頻度で繰り返すのに prepared statement を使っていない。maxConnectionsの決め方が強すぎる。(2000 div countProcessors()) - 2は CPU 数が少ない環境で数百接続になりうるが、単一プロセスの async サーバでその数を使うと PostgreSQL 側の context switch と待機列が増えやすい。
改善方針:
updatesは「個別 SELECT を維持しつつ、UPDATE は 1 文の bulk update」に変える。候補はUPDATE ... FROM (VALUES ...)かCASE WHEN id = ... THEN ... END。db/queries/fortuneはモジュールスコープで prepared statement を初期化し、リクエストごとに SQL 文字列組み立てと prepare をしない。updatesのレスポンス生成用データは先に集め、DB 更新はidでソートした配列から bulk update を組み立てる。verify 上は JSON 順序は必須ではないが、更新 SQL は安定した順序にした方が観測しやすい。maxConnectionsはまず32または64に固定して計測し、そこから上げ下げする。接続数を増やすより、1 接続あたりの仕事量を増やす方が今回の構成には合う可能性が高い。
2. allographer自体の改善
getFreeConnがタイムアウトするとerrorConnectionNumを返すが、その後のgetRowPlain/exec/getAllRowsは例外ではなくそのままreturnしている。結果として、更新できていなくてもアプリ側では成功レスポンスを返せてしまう。- prepared statement は接続ごとに lazy prepare されるが、プール単位の cache がない。高頻度 SQL が複数接続に散ると、prepare の局所性が弱い。
close()は使った接続全てにDEALLOCATEを送る実装なので、短寿命の prepared statement を多用すると warm 状態を毎回捨てる。- DML 実行時の列型取得は現在は table 単位 cache になっているが、bulk update 用の raw SQL / prepared SQL を多用する場合は、そもそも query builder を通さない高速経路の価値が高い。
改善方針:
errorConnectionNumに到達したときは no-op ではなくDbErrorを投げる。silent failure は benchmark だけでなく通常アプリでも危険。Connectionsに SQL 単位の prepared cache を持たせ、同一 SQL の physical prepare を接続ごとに 1 回へ寄せる。close()は論理 close に寄せ、物理DEALLOCATEは明示 API に分離する。steady-state の benchmark では prepared state を維持できるようにする。- 接続固定 API、あるいは transaction / prepared context を足し、同一 logical operation の複数クエリを同じ接続に寄せられるようにする。
- bulk update 用に
raw(...).exec()だけでなく、prepared raw SQL を再利用できる経路を整える。今回のベンチマーク用途では query builder の汎用性よりも固定 SQL の再利用性が効く。
3. benchmark_controller.nim のベンチマーク測定コード自体の改善
- 現状のコントローラ実装は「フレームワークの素の書き方」に近いが、TechEmpower のような固定シナリオでは、一般用途向けの書き方をそのまま当てると不利になる。
updatesの内部並列は steady-state の測定というより、アプリ側で追加のキューイングとプール競合を作る測定になっている。db/queries/fortune/updatesの全てで、レスポンス形成に不要なオブジェクト生成や JSON 変換の回数をまだ削れる。
改善方針:
benchmark_controller.nimは「ベンチマーク専用実装」と割り切り、固定 SQL + prepared statement + 最小変換で書く。dbは preparedSELECT id, randomnumber FROM World WHERE id = ?を 1 回作って再利用する。queriesはcountNum回のランダム ID を集め、preparedSELECTを直列で回す。リクエスト内で DB fan-out はしない。fortuneも preparedSELECT id, message FROM Fortune ORDER BY message ASCを使う。updatesは次の順で行う。- ランダム
(id, randomNumber)をcountNum件作る。 - verify 用の query count を満たすために、各 id について個別
SELECTを行う。 - 取得後の更新値配列から 1 文の bulk update SQL を組み立てて実行する。
- JSON レスポンスは事前に確保した配列へ埋める。
- ベンチマーク用コードは
basolatoの一般的な DX を示す場所ではなく、TFB の測定コードとして最短経路を優先する。汎用 API の使い勝手改善と benchmark 用特殊化は分けて評価する。
4. update 改善の実装設計図
目的:
updatesの 1 リクエスト内 fan-out をやめ、接続プール競合を減らす。SELECTとUPDATEを同一 connection に寄せ、prepared statement の warm 状態を最大限再利用する。UPDATEの round trip 数をcountNum回から 1 回へ減らす。
採用方針:
- 1 リクエストにつき
rdb.withConnを 1 回だけ呼び、その connection 上でcountNum件のSELECTを直列実行する。 SELECTは verify 条件を満たすため維持する。UPDATEは individual update をやめ、UPDATE "World" AS w SET randomnumber = v.randomnumber FROM (VALUES ...) AS v(id, randomnumber) WHERE w.id = v.idの 1 文にまとめる。- レスポンス JSON は DB 実行とは分離して先に配列へ格納し、DB 成功後にそのまま返す。
update の目標フロー:
countNumを読む。(id, randomNumber)の組をcountNum件生成する。- JSON レスポンス配列を同時に埋める。
rdb.withConnで connection を 1 本確保する。- その context 上で
worldByIdStmt.firstPlain(ctx, ...)をcountNum回直列実行する。 - bulk update SQL と引数列を組み立てる。
- 同じ context 上で bulk update を 1 回だけ実行する。
- connection 返却後、JSON を返す。
補助データ構造:
type WorldUpdateRow = objectid: intrandomNumber: intupdates: seq[WorldUpdateRow]をcountNumサイズで事前確保する。- bulk update SQL 構築用に
seq[string]またはseq[PreparedParam]を作る。
SQL 構築方針:
- 候補 1:
VALUES ($1, $2), ($3, $4), ...を含む raw SQL を毎回組み立てる。 - 候補 2:
CASE WHEN id = ... THEN ... ENDを組み立てる。 - 第一候補は
UPDATE ... FROM (VALUES ...)とする。 理由: - PostgreSQL で素直で速く、
idとrandomnumberのペアをそのまま渡せる。 CASE WHENより SQL が単純で、更新対象一覧との対応が追いやすい。
allographer 側の利用方針:
- 既存の
worldByIdStmtはそのまま利用する。 - bulk update は query builder を通さず raw SQL 実行を優先する。
- bulk update も prepared statement 化できるなら理想だが、
countNumに応じてプレースホルダ数が毎回変わるため、まずは raw SQL 実行を採用する。 - 将来的には「可変長 VALUES を binding できる prepared raw SQL API」があると望ましい。
withConn の使い方:
- 現在の
updateは 1 件ごとにwithConnを呼んでいるが、改善版では request 単位で 1 回だけ呼ぶ。 update2型の「stmt ごとに connection を取り直す」経路は benchmark では使わない。queriesも同じ思想で、必要なら request 単位のwithConn+ 直列SELECTに寄せる。
期待する効果:
getFreeConn/returnConnの回数が大幅に減る。- request 内 DB fan-out がなくなり、HTTP concurrency と DB concurrency の二重増幅を防げる。
- prepared statement の接続局所性が上がる。
UPDATE往復回数が 1 回になり、PostgreSQL 側の parse/bind/execute 負荷も減る。
副作用と注意点:
- request 内部の並列性は落ちるが、もともと
updatesは DB 待ち主体なので、接続競合減少の効果が勝つ可能性が高い。 - bulk update SQL が長くなるため、
countNum = 500でも問題ない長さかを確認する。 - duplicate id が発生しうるので、verify への影響を確認する。
SELECT結果そのものはレスポンスに使わなくても、verify 上必要な「実際に read した事実」を崩さないように維持する。
実装ステップ:
benchmark_controller.nimにWorldUpdateRowと bulk update SQL 組み立て helper を追加する。updateを「request 単位でwithConn1 回」に書き換える。update2は比較用として残すか、意図がわかるコメントをつける。queriesについても内部 fan-out をやめる候補として別途比較する。- 実測して、現行
update/ 改善版update/update2の差を確認する。
実装結果
updateはwithConnで 1 接続に固定し、SELECTを直列実行したうえで、WITH ... DISTINCT ONの bulk update を 1 回だけ投げる実装に変更した。update2は比較用の個別更新経路として、同じwithConn上でSELECT + UPDATEを直列実行する形に変更した。db/query/fortuneもwithConn上の prepared statement 実行へ寄せ、リクエスト内の接続移動をなくした。maxConnectionsは release 時に 64、debug 時に 16 へ引き下げた。
計測観点:
- RPS
- latency
- PostgreSQL の active connection 数
- pool timeout の有無
- verify pass / fail
countNum=1, 20, 100, 500での劣化カーブ
判断基準:
countNumが大きいケースで改善版が安定して速いなら採用する。countNum=1だけ微差で遅くても、TFB 全体スコアで勝つなら許容する。- verify 安定性を落とす変更は採用しない。
実装優先順位
benchmark_controller.nimのupdatesを bulk update 化して verify fail を解消する。db/queries/fortuneを prepared statement 化し、HTTP あたりの SQL 構築コストを下げる。config/database.nimのmaxConnectionsを保守的な値へ調整し、再計測する。allographerの silent failure を例外化する。allographerの prepared cache / logical close / 接続固定 API を設計どおり進める。