ResiCache: Observer Nested Execution Must Distinguish Lifecycle, Fragments, and Scope Tokens
ENGINEERING NOTES
Published / DavidHLPL
Conclusion first: in a Java system with a cache chain, a synchronization lock, and single-flight, the full execute, the lock-held executeChainFragment, and the observer’s per-call scope token are three different concepts. The around hook’s state must be paired and restored with the token returned by the current call; a fragment only advances nodes and does not reopen the outer lifecycle; ThreadLocal is only suitable for isolating the current execution snapshot and should not become the observer state machine.
Review guide: why a locked continuation must not restart the chain
In the ResiCache project, a sync-lock handler must continue later nodes after acquiring execution rights. Reusing full execute would open a second timing/context-restoration lifecycle while the outer one is still active and could duplicate post-processing.
This is a review of pinned design choices and reproducible counterexamples, not a claim of a production incident. Focus on:
- Ownership: locked continuation belongs to the original invocation.
- Tradeoff: fragments reuse node execution while skipping around hooks/post-processing; tokens carry per-call state.
- Validation: inspect pinned source and observe event differences in the model below. It does not replace real Java, Redis, or concurrent integration tests.
Directly verifiable implementation evidence
All links pin 75ed279a71b17f227c3170d738eb93e50d876c8a:
| Question | Source |
|---|---|
| Which entry point runs inside the lock? | SyncLockHandler.java |
| Does a fragment skip full lifecycle and post-processing? | ChainEngine.java |
| How do start/end carry per-call state? | ChainObserver.java |
| Where are the real project’s tests? | ChainEngineTest.java |
Minimal runnable counterexample
Save as lifecycle-check.mjs and run node lifecycle-check.mjs. It uses only Node’s standard library to express the language-independent lifecycle model.
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]);
// Full execute inside the lock duplicates lifecycle and post-processing.
events.length = 0;
await execute(() => execute(fragment));
assert.deepEqual(events, ["start", "start", "node", "post", "end", "post", "end"]);
// Propagate the original failure and clean up without success post-processing.
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));
// Overlapping async calls must each end with their own 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");
Expected: one PASS line. The counterexample has two start/post/end sequences; the proper fragment has one of each. Failure retains end and propagates the original exception. This proves the model contract, not JVM multithreading. Thread isolation, lock loss, and same-key reentry still need the test matrix below. No throughput or production-stability claim is added.
Scope
This page is based on the public ResiCache main fixed commit 75ed279a71b17f227c3170d738eb93e50d876c8a, and applies to:
- Adding an MDC, Timer, or tracing observer to the cache handler chain;
- Re-entering the same handler chain inside a distributed lock or single-flight load;
- Troubleshooting duplicate timing, MDC leakage, and future self-wait caused by nested execution;
- Designing an observer API that is safe for concurrent reuse.
1. Draw the three execution boundaries first
The public Engine’s full execution order can be simplified as:
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)
The lock-held fragment is:
executeChainFragment(context, fromHandler)
-> find from in the current snapshot
-> advance only subsequent nodes
-> run node-level before/handle/after
-> do not re-run around start/end
-> do not run post-process
These two entry points are not interchangeable. A fragment is an internal continuation of the outer execute, not an independent full chain call.
2. Around hooks pair by token, not by shared fields
The observer interface makes onChainStart return a scopeToken, then passes that same token to onChainEnd:
Object token = observer.onChainStart(context);
try {
// chain execution
} finally {
observer.onChainEnd(context, token, result);
}
In a real implementation, the token should hold the state this call needs to restore, for example:
- the MDC’s original request id;
- the Timer’s start nanos;
- a tracing span or parent context;
- nested depth and restoration order.
Do not write these values into shared observer fields, and do not stuff them into the untyped string attributes of CacheContext. An observer is usually reused by multiple threads; field state would string together request A’s teardown with request B’s start.
3. Why a fragment must not re-trigger the around hook
While the synchronized-lock handler continues subsequent nodes inside the lock, the outer execute remains responsible for:
- the lifetime of the current snapshot;
- the start/end pairing of the around hook;
- the final post-process;
- the final result and exception boundary.
If a fragment calls the full execute again, common consequences are:
- the MDC stamp is written again, and the inner end restores the wrong previous value;
- Timer and tracing produce a spurious nested call;
- post-process runs twice, and cache write-back or state transitions repeat;
- the observer thinks two external requests happened, when in fact there was only one lock-held continuation.
Therefore, a fragment should expose only the minimal entry point of “continue after which handler”, and explicitly take no responsibility for around/post-process.
4. ThreadLocal only holds an execution snapshot
The Engine uses ThreadLocal<List<CacheHandler>> to hold the current thread’s handler snapshot so that a fragment sees the same immutable list as the outer execute. This usage solves structural isolation between concurrent calls:
List<CacheHandler> snapshot = List.copyOf(handlers);
CURRENT_SNAPSHOT.set(snapshot);
try {
// execute and possibly executeChainFragment
} finally {
CURRENT_SNAPSHOT.remove();
}
Do not also put the observer’s MDC previous value, Timer start, or scope token into the same ThreadLocal slot. The snapshot is execution structure; the token is per-call state; their lifetimes differ.
If arbitrary-depth nesting of the full execute is supported in the future, you need an explicit execution context or a stack-based ThreadLocal that restores the previous layer for each layer; a single overwriteable slot can only safely support the currently public fragment boundary.
5. Model leader/follower of single-flight separately
Single-flight for the same key answers “which thread executes the load”:
- the first thread becomes
Leader; - nested calls for the same key on the same thread take the
Reentrantfast path; - other threads become
Followerand wait on the leader’s future; - the leader cleans up state in a finally block and completes the future.
The observer answers “which lifecycle events this chain execution went through”. The two cannot replace each other:
- single-flight must not decide the start and end of the observer token;
- the observer must not hold the leader/follower shared lock state;
- the reentrant fast path must not wait on the future it created itself, or it will deadlock.
Minimal test matrix
| Scenario | Should verify |
|---|---|
| Normal full chain | onChainStart -> beforeNode -> afterNode -> onChainEnd, with identical start/end tokens |
| Lock-held fragment | only subsequent-node hooks; no second around hook; no second post-process |
| Handler throws | the exception keeps propagating; finally triggers onChainEnd; the failed handler does not trigger afterNode |
| Two-thread concurrency | tokens, MDC previous state, and snapshots do not pollute each other |
| Same-key nesting | the reentrant path does not wait on its own future and does not re-acquire execution rights |
These are the minimal regression contract; production systems should also add cancellation, timeout, lock-invalidation, and result-visibility tests.
Common mistakes
- Storing start time or the MDC previous value in a shared field: concurrent calls overwrite each other.
- A fragment calling the full execute: duplicate around hooks and post-process.
- Treating ThreadLocal as a universal context: nested layers overwrite, and the exception path is hard to restore.
- Fusing single-flight and observer into one layer: coordination state and observation state pollute each other.
- The observer by default receiving the primary failure result: the public fixed implementation passes a success-result placeholder to
onChainEnd; if you need failure results, you must first add an explicit contract and tests.
Evidence boundary
This page only promises behavior in the public fixed commit and does not treat unpublished local modifications as evidence. Source facts and the fixed commit: see the public ResiCache fixed commit; the redacted raw snapshot is at src/content/raw/zh-cn/resicache-observer-nested-execution-contract.md in the repository.
Sources and related documents
Research notes
- Original document: src/content/note/en/resicache-observer-nested-execution.md
- Source revision: f49953fb0d4bd52a39bf81167b0c4b7dd7d78a64
- Evidence status: active