ResiCache:observer のネスト実行はライフサイクル、fragment、scope token を区別しなければならない
技術ノート
公開 / DavidHLPL
結論優先:キャッシュチェーン、同期ロック、single-flight を持つ Java システムでは、完全な execute、ロック内の executeChainFragment、observer の per-call scope token は三つの異なる概念だ。around hook の状態は今回の呼び出しが返した token とペアにして復元しなければならない。fragment はノードを進めるだけで外側のライフサイクルを開き直さない。ThreadLocal は現在の実行スナップショットを分離するのにだけ適し、observer の状態機械にしてはならない。
設計レビューの案内:lock 内の続行で全チェーンを再実行してはいけない理由
ResiCache の同期 lock handler は実行権を得て後続 node を続ける。外側の寿命が続く中で完全 execute を使うと、計時・context 復元をもう一組開き、後処理も重複し得る。
これは固定 commit の設計と再現可能な反例であり、本番事故の主張ではない。
- 帰属:lock 後の続行は元の呼び出しで、新規要求ではない。
- 選択:fragment は node 能力を再利用し around/post-process を省く。状態は呼出 token。
- 検証:固定 source と次のモデルでイベント差を確認。実 Java/Redis/並行統合 test の代用ではない。
直接確認できる実装証拠
全リンクを 75ed279a71b17f227c3170d738eb93e50d876c8a に固定する。
| 問い | ソース |
|---|---|
| lock 内でどの入口を呼ぶか | SyncLockHandler.java |
| fragment は lifecycle/後処理を省くか | ChainEngine.java |
| start/end の状態の受け渡し | ChainObserver.java |
| 実 project test はどこか | ChainEngineTest.java |
最小実行可能な反例
lifecycle-check.mjs として保存し node lifecycle-check.mjs を実行する。Node 標準 library のみで言語非依存の寿命モデルを表す。
import assert from "node:assert/strict";
const events = [];
const started = [];
const ended = [];
async function execute(body) {
const token = {};
started.push(token);
events.push("start");
try {
const result = await body();
events.push("post");
return result;
} finally {
ended.push(token);
events.push("end");
}
}
async function fragment() {
events.push("node");
return "value";
}
assert.equal(await execute(fragment), "value");
assert.deepEqual(events, ["start", "node", "post", "end"]);
assert.equal(ended[0], started[0]);
// lock 内の完全 execute は lifecycle と後処理を重複させる。
events.length = 0;
await execute(() => execute(fragment));
assert.deepEqual(events, ["start", "start", "node", "post", "end", "post", "end"]);
// 元例外を伝播し終了処理する。成功後処理は行わない。
events.length = 0;
const failure = new Error("handler failed");
await assert.rejects(execute(() => { throw failure; }), (error) => error === failure);
assert.deepEqual(events, ["start", "end"]);
assert.equal(ended.at(-1), started.at(-1));
// 重なる非同期呼出も各自の token で終了する。
const offset = started.length;
await Promise.all([execute(fragment), execute(fragment)]);
assert.notEqual(started[offset], started[offset + 1]);
assert.notEqual(ended[offset], ended[offset + 1]);
assert.ok(ended.slice(offset).every((token) => started.slice(offset).includes(token)));
console.log("PASS: fragment boundary, duplicate lifecycle counterexample, cleanup, token pairing");
期待結果:一行の PASS。誤例では start/post/end が二回、fragment は各一回。例外経路でも end を行い元例外を伝播する。モデルの契約であり、非同期の重なりも JVM multi-thread 検証ではない。実際には下の行列の thread 隔離、lock 失効、同 key 再入を確認し、性能・本番安定性は約束しない。
適用範囲
このページは公開 ResiCache main の固定コミット 75ed279a71b17f227c3170d738eb93e50d876c8a に基づき、次に当てはまる:
- キャッシュ handler chain に MDC、Timer、tracing observer を追加する;
- 分散ロックや、同じ key の並行ロードを一つに集約する single-flight の中で同じ handler chain に再入する;
- ネスト実行による重複計時、MDC リーク、future の自己待ちを調査する;
- 並行再利用に安全な observer API を設計する。
1. まず三つの実行境界を描く
公開 Engine の完全な実行順序は次のように単純化できる:
execute(ctx)
-> snapshot handlers
-> observer.onChainStart(ctx)
-> beforeNode(handler)
-> handler.handle(ctx)
-> afterNode(handler, result)
-> decision: continue / skip / terminate
-> post-process
-> observer.onChainEnd(ctx, scopeTokens, result)
ロック内の fragment は次だ:
executeChainFragment(context, fromHandler)
-> 現在の snapshot から from を見つける
-> 後続ノードだけ進める
-> ノードレベルの before/handle/after を実行する
-> around start/end を再実行しない
-> post-process を実行しない
この二つのエントリポイントは交換できない。fragment は外側の execute の内部続行であり、独立した完全なチェーン呼び出しではない。
2. around hook はトークンでペアにする。共有フィールドは使わない
observer インターフェースは onChainStart に scopeToken を返させ、同じトークンを onChainEnd に渡す:
Object token = observer.onChainStart(context);
try {
// chain execution
} finally {
observer.onChainEnd(context, token, result);
}
実際の実装では、token は今回の呼び出しで復元すべき状態を保持すべきだ。例えば:
- MDC の元の request id;
- Timer の start nanos;
- tracing span や parent context;
- ネスト深度と復元順序。
これらの値を共有 observer フィールドに書き込んではならない。CacheContext の型なし文字列属性に詰め込んでもいけない。observer は通常複数のスレッドに再利用される。フィールド状態はリクエスト A の後始末とリクエスト B の開始を繋いでしまう。
3. fragment が around hook を再発火できない理由
同期ロック handler がロック内で後続ノードを続行するとき、外側の execute が引き続き責任を持つのは:
- 現在の snapshot のライフサイクル;
- around hook の start/end のペア;
- 最終的な post-process;
- 最終結果と例外境界。
fragment が再び完全な execute を呼んだ場合の一般的な結果は:
- MDC stamp が二重に書き込まれ、内側の終了時にもとの previous value に復元されない;
- Timer と tracing が偽のネスト呼び出しを生む;
- post-process が二回実行され、キャッシュ書き戻しや状態遷移が重複する;
- observer は外側のリクエストが二回起きたと判断するが、実際はロック内の一度の続行にすぎない。
したがって fragment は「どの handler の後から続行するか」という最小エントリポイントだけを公開し、around/post-process を担わないことを明示すべきだ。
4. ThreadLocal には実行スナップショットだけを入れる
Engine は ThreadLocal<List<CacheHandler>> で現在スレッドの handler snapshot を保持し、fragment が外側の execute と一致する不変リストを見られるようにする。この使い方は並行呼び出し間の構造的隔離を解決する:
List<CacheHandler> snapshot = List.copyOf(handlers);
CURRENT_SNAPSHOT.set(snapshot);
try {
// execute and possibly executeChainFragment
} finally {
CURRENT_SNAPSHOT.remove();
}
observer の MDC previous value、Timer start、scope token を同じ ThreadLocal スロットに入れてはならない。スナップショットは実行構造であり、token は per-call 状態であり、ライフサイクルが異なる。
将来、完全な execute の任意深度ネストをサポートするなら、明示的な execution context かスタック式 ThreadLocal を使い、各層で前の層を復元する必要がある。単一の上書き可能スロットは、現在公開されている fragment 境界だけを安全にサポートできる。
5. single-flight の leader/follower は分けてモデル化する
同 key の single-flight は「どのスレッドがロードを実行するか」を答える:
- 最初のスレッドが
Leaderになる; - 同スレッド・同 key のネスト呼び出しは
Reentrantfast path を通る; - 他のスレッドは
Followerになり leader の future を待つ; - leader は finally で状態を掃除し future を完了させる。
observer は「今回のチェーン実行がどのライフサイクルイベントを経験したか」を答える。両者は互いに代替できない:
- single-flight が observer token の開始と終了を決めてはならない;
- observer が leader/follower の共有ロック状態を保持してはならない;
- reentrant fast path は自分が作った future を待ってはならない。デッドロックする。
最小テストマトリクス
| シナリオ | 検証すべきこと |
|---|---|
| 正常な完全チェーン | onChainStart -> beforeNode -> afterNode -> onChainEnd、start/end token が同一 |
| ロック内 fragment | 後続ノードの hook だけ。二回目の around hook なし。二回目の post-process なし |
| handler が例外を投げる | 例外は伝播し続ける。finally が onChainEnd を発火。失敗した handler は afterNode を発火しない |
| 二スレッド並行 | token、MDC previous state、snapshot が互いに汚染しない |
| 同 key ネスト | reentrant パスは自身の future を待たず、実行権を二重取得しない |
これらが最小の回帰契約だ。本番システムではキャンセル、タイムアウト、ロック失効、結果可視性のテストも補うべきだ。
よくある誤り
- 開始時間や MDC previous value を共有フィールドに保存する:並行呼び出しが互いに上書きする。
- fragment が完全な execute を呼ぶ:around hook と post-process が二重になる。
- ThreadLocal を万能コンテキストとみなす:ネスト層が上書きされ、例外パスでの復元が困難になる。
- single-flight と observer を一つの層に統合する:調整状態と観測状態が相互に汚染する。
- observer が既定で主失敗結果を受け取る:公開固定実装の
onChainEndには成功結果のプレースホルダが渡される。失敗結果が必要なら、先に明確な契約とテストを補う必要がある。
証拠の境界
このページは公開固定コミット内の挙動だけを約束し、未公開のローカル変更を証拠としない。ソース事実と固定コミットは ResiCache 公開固定コミット を参照。機密情報を除去した raw スナップショットはリポジトリの src/content/raw/zh-cn/resicache-observer-nested-execution-contract.md にある。
出典と関連文書
研究記録
- 元の文書: src/content/note/ja/resicache-observer-nested-execution.md
- 出典リビジョン: f49953fb0d4bd52a39bf81167b0c4b7dd7d78a64
- 証拠の状態: 有効