-lerスタックの各ライブラリにはそれぞれのテストスイートがある。sqlerはORMをテストし、qlerはジョブキューをテストし、loglerはログ検索をテストし、proclerはプロセス管理をテストし、daglerはDAGオーケストレーターをテストする。全て単体では通過する。
prooflerが問うのは、もっと難しい問いだ:本当に一緒に動くのか?
prooflerが何をテストするか
このスイートは41セクション・430以上のチェックを、実際にインストールされたライブラリに対して実行する。モックなし、スタブなし、シミュレートされたインターフェースなし。全テストが少なくとも2ライブラリの公開APIを呼び出し、大半は3〜4ライブラリを使う。
テスト対象のレイヤー:
| ライブラリ | 役割 |
|---|---|
| sqler | 非同期・コネクションプール・楽観的ロック付きSQLite ORM |
| qler | ワーカー・cron・レート制限・依存関係付きバックグラウンドジョブキュー |
| logler | Rustバックエンド・相関ID・DBブリッジ付きログ調査ツール |
| procler | ヘルスチェック・クラッシュ検知・リカバリー付きプロセス管理 |
| dagler | ファンアウト/リデュース・リトライ・キャンセル付きDAGパイプラインオーケストレーター |
全て5ライブラリともSQLiteバックエンド。Postgresなし、Redisなし、外部依存なし。
哲学
ルールは一つ:バグは発生源で修正する。
prooflerがバグを発見したとき、try/exceptで包んだり、テストをスキップしたり、フォールバックを追加したりしない。バグを持つライブラリで修正する。テストは正しい動作をアサートし続け、ライブラリが修正をリリースする。
8つのマイルストーンを経て、このアプローチはスタック全体で16のバグを発見・修正した:
| ライブラリ | 発見バグ数 | 例 |
|---|---|---|
| sqler | 3 | コネクションプール枯渇、count集計、プロモートカラム処理 |
| qler | 4 | 依存関係解決でのコネクションリーク、古いattempt ID、バッチdedup |
| logler | 5 | 誤ったDBマッピング、__init__.pyがRust拡張をシャドウイング、検索OOM、SQLエンジンO(N²) |
| dagler | 3 | 動的ファンアウトジョブのキャンセル漏れ、スキーマタイミング、キャンセル状態の永続化 |
| procler | 1 | 読み取り専用クエリパスを使ったDDL操作 |
全ての修正にはリグレッションテストがある。バグが戻れば失敗する。修正は理論上のものではなく、各ライブラリのリポジトリに実際にリリースされており、prooflerの実行のたびに検証される。
境界はどう見えるか
興味深いバグは個々のライブラリの内部にあるのではない。境界にある。
logler + qler: スキーマ前提
loglerのdb_to_jsonl()はSQLiteデータベースのテーブルを自動検出してJSONLに変換する。全テーブルに_idカラムがあることを前提としていた(sqlerの慣習)。qlerのqler_job_depsテーブルにはそれがない。純粋なリレーショナル結合テーブルだからだ。自動検出がクラッシュした。
修正: 自動検出時に_idのないテーブルをスキップ。loglerの1行の変更で、loglerのテスト単体では絶対に出てこないバグだ。loglerのテストはqlerのスキーマを使わないから。
procler + sqler: 読み取り専用強制
sqlerのexecute_sql()メソッドはクエリが読み取り専用であることを検証する(SELECT、EXPLAIN、PRAGMA、WITH)。proclerはデータベース初期化時のDDL操作(CREATE TABLE、ALTER TABLE)にexecute_sql()を使っていた。proclerのテストでは異なるデータベースセットアップパスを使っていたため動作していたが、proclerがsqlerの実際のAPIを通じて初期化した瞬間に壊れた。
修正: proclerはDDLにadapter.execute()を使うようになった。読み取り専用チェックを迂回する下位レベルAPIで、sqlerがまさにこの目的で公開しているもの。
dagler + qler: 動的ジョブのキャンセル
daglerのcancel()は送信時点で作成されたジョブを反復する。しかしファンアウトDAGはディスパッチャーを通じて動的にジョブを作成する。それらは元の送信リストにない。実行中のファンアウトをキャンセルすると、20のmapジョブがデータベースに保留中のまま残った。
修正にはdaglerが既知のジョブをキャンセルした後に相関IDでqlerのジョブテーブルを照会し、動的に作成されたものを拾い上げることが必要だった。実際のキャンセル圧力下でファンアウトを実行した時だけ表面化するクロスライブラリの相互作用。
注文パイプライン: カオス下の600ジョブ
セクション13は完全な注文処理パイプラインを実行する: 100注文、それぞれが6タスク(バリデーション、不正チェック、課金、確認、在庫、倉庫)を依存チェーン付きで生成する。現実的な確率でカオスを注入: バリデーション失敗2.5%、不正拒否4.5%、課金拒否11.2%。
2つのワーカーがパイプラインを処理する。途中でWorker Aがシャットダウンし、qlerのリース回収が期限切れリースを検知してWorker Bが放棄されたジョブを引き継ぐ。全注文は完了するか、追跡可能な理由で失敗する。
loglerは完全なライフサイクルをトレースする: 注文ごとの相関IDがログファイルとqlerデータベースの両方を貫通する。課金失敗がダウンストリームタスクのキャンセルに連鎖し、loglerのInvestigatorはどのステップが、なぜ失敗し、どのダウンストリームジョブが影響を受けたかを正確に診断できる。
このセクションだけで27のチェック。データロスや追跡不能な失敗への許容はゼロ。
procler: 実際のOSサブプロセス管理
セクション14〜19はproclerが実際のOSサブプロセスとしてqlerワーカーを管理することをテストする。モックプロセスではなく、PID・シグナル・終了コードを持つ本物のasyncio.create_subprocess_exec呼び出し。
ヘルスチェックはSTARTING → HEALTHY → DEADと遷移する。クラッシュ検知は実行中のワーカーにSIGKILLを送り、proclerが予期しない終了を検知することを検証し、代替ワーカーが放棄されたジョブをリカバリーすることを確認する。CIDトレーシングは相関IDがproclerの管理レイヤーからqlerのジョブ実行、loglerの調査出力へと貫通することを検証する。
セクション19のフルスタックラウンドトリップ: proclerがワーカーを起動 → qlerが20ジョブを処理 → loglerが全てをトレース → prooflerがproclerのspawnイベントからqlerのジョブ完了、loglerの検索結果までの相関チェーンを検証する。
dagler: スケールでのDAGオーケストレーション
セクション25〜41は基本的な線形パイプラインからダイアモンドDAG、ファンアウト/リデュース、並行実行、運用エッジケースまでdaglerを追い込む。
スケールテストが面白くなる場面。ファンアウトスループットは明確な曲線を描く:
| ファンアウトサイズ | 経過時間 | items/s |
|---|---|---|
| 100 items | ~1s | ~90 |
| 500 items | ~4s | ~120 |
| 1,000 items | ~8s | ~120 |
| 5,000 items | ~70s | 72 |
スループットは500〜1Kでピーク。5Kではmap 5,000ジョブが同時に結果を書き込むWAL書き込み圧力で低下する。これは文書化された特性であってバグではない。テストはスループットに関係なく正確なリデュース結果が正しいことを検証する。
運用エッジケース(S36〜S41)はflightキャンセル、mapレベル失敗でのリトライ、冪等な送信dedup、wait()タイムアウトリカバリー、並行ファンアウト競合をテストする。本番でパイプラインを破壊するシナリオがそれだ。prooflerはその失敗モードをリリース前に捕捉する。
ストレステスト: スループット数値
stress.pyはカオス注入・ワーカーチャーン・ゾンビリカバリー・強制終了を伴う、注文あたり8タスク依存パイプラインを実行する。このボックスのスループット数値:
| スケール | 設定 | スループット | p50 | p95 | p99 | Peak RSS | チェック |
|---|---|---|---|---|---|---|---|
| 200注文 (1,600ジョブ) | 1w × c=2 | 860 jobs/min | 50ms | 105ms | 133ms | 62 MB | 50/50 |
| 1,000注文 (8,000ジョブ) | 1w × c=4 | 2,145 jobs/min | 60ms | 139ms | 186ms | 152 MB | 50/50 |
| 10,000注文 (80,000ジョブ) | 2w × c=1 | 1,245 jobs/min | 73ms | 144ms | 204ms | 1,370 MB | 50/50 |
全実行は再現性のためにseed=42を使用。カオス率: バリデーション失敗2.5%、不正失敗4.5%、課金失敗11.2%。ワーカーチャーンには正常シャットダウン+再起動と、10Kスケールで1回の強制SIGKILLを含む。
スループットは200から1,000注文で増加する。バッチクレームパイプラインは大規模スケールで常にフルに保たれ、逆依存インデックスはテーブルサイズに関係なくO(1)で解決を維持する。10Kではスループットが1,245 jobs/minに落ちる。WALファイルが80Kのアクティブ行で肥大化し、SQLiteの単一ライター制約が効いてくる。処理RSSは55以上のワーカーサイクルを通じて1,370 MBで安定し、メモリ増加はゼロ。2.6 GBのピークは全てloglerの分析フェーズで60万以上のログエントリを読み込んだもの。
10Kで注入されたゾンビ213、リカバリー213。全設定でデータロスゼロ。全失敗注文はloglerで追跡可能。
prooflerとは何でないか
ライブラリではない。pip install prooflerはできない。5つの-lerライブラリのローカルチェックアウトをdeps/ディレクトリにシンボリックリンクする必要があるテストハーネスだ。リポジトリを既にクローン済みならセットアップ手順は30秒で終わる。
CIシステムでもない。スイートはハードウェアによって6〜10分かかり、一部のセクション(5Kファンアウト、10Kファンアウト)はパフォーマンスクリフをテストするために意図的に遅い。開発時検証用であり、自動ゲートではない。
prooflerとは何か: 独立して開発された5つのライブラリが、SQLiteファイルとPythonのimportシステム以外何も共有せず、動くスタックとして構成できることの証明。それが発見するバグは、ユニットテストが構造的に捕捉できないものだ。
ソース
github.com/gabu-quest/proofler — 41セクション、430以上のチェック、ワークアラウンドゼロ。