Repository navigation
fix(#3477): correlate a re-enqueued write with the NACK that caused it - #3532
Conversation
After LATE_NACK_REENQUEUE the trail went dark. The late registry mints a FRESH requestId per attempt, so the re-enqueued write registered under an id unrelated to the one that produced it — and "the re-enqueue never left the cache hub", "it never activated the owner" and "it activated and never answered" were indistinguishable in the log. A write that was NACKed, re-enqueued and then neither applied nor answered is the write-loss class LateNackReenqueueTest exists to refuse, and it could not be attributed. UpdateRemote now threads ONE correlation id, minted at the caller's entry and passed unchanged into both re-enqueue arms, on BEGIN, LATE_ACK, LATE_NACK_REENQUEUE (+ its failure), OWNER_NACK_REENQUEUE and VERDICT_TIMEOUT — which is the line an operator actually reaches for, and which now says to grep the corr for the whole chain. A new LATE_VERDICT_REGISTERED names the per-attempt requestId beside it. Purely additive diagnostics: nothing is awaited, bounded or retried, and no behaviour changes. Levels are deliberate — registration at Debug (one per late-registered write, hot path), the Warning that pays for it is VERDICT_TIMEOUT, which fires only once a write has already failed to settle. This matters more since #3505: reclassifying owner teardown as OwnerDisposing routes MORE traffic into this arm, and preferentially into the late half.
There was a problem hiding this comment.
🟡 Changes recommended
The new test contains a direct await on an IObservable (violating the repo’s ObservableAwait.Await convention) and has misleading/internally inconsistent documentation/naming that should be corrected before merge.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds end-to-end log correlation for re-enqueued cross-hub mesh node writes by threading a stable corr id through UpdateRemote attempts, and introduces a regression test to ensure the late NACK re-enqueue log line carries that correlation id so operators can follow the full attempt chain.
Changes:
- Extend
MeshNodeStreamExtensions.UpdateRemotewith an optionalcorrelationId, mint once per write chain, and includecorr=in key diagnostic logs (BEGIN,LATE_*,OWNER_*,VERDICT_TIMEOUT). - Add a new
LATE_VERDICT_REGISTEREDdebug log that records bothcorrand the per-attemptrequestId. - Add a new test that drives the late-OwnerDisposing NACK path and asserts
LATE_NACK_REENQUEUEincludes a non-emptycorr.
File summaries
| File | Description |
|---|---|
| test/MeshWeaver.Graph.Test/LateNackReenqueueCorrelationTest.cs | New test to exercise the late NACK re-enqueue path and validate the presence of corr= in the diagnostic log trail. |
| src/MeshWeaver.Mesh.Contract/MeshNodeStreamExtensions.cs | Thread a stable correlation id through UpdateRemote reattempts and add/augment diagnostic logging to make re-enqueue chains followable. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| .Configure<LoggerFilterOptions>(o => | ||
| { | ||
| o.MinLevel = LogLevel.Debug; | ||
| o.Rules.Add(new LoggerFilterRule( | ||
| providerName: null, | ||
| categoryName: "MeshWeaver.Mesh.MeshNodeStreamHandle", | ||
| logLevel: LogLevel.Debug, | ||
| filter: null)); | ||
| })); |
| var reenqueue = await captured.FirstMatching( | ||
| @"LATE_NACK_REENQUEUE .*corr=(?<corr>[^\s]+)", TestTimeouts.Convergence); |
| /// <para>The fix threads ONE correlation id, minted at the caller's entry and passed unchanged into | ||
| /// every re-attempt. This test pins that inheritance: the re-attempt's own <c>BEGIN</c> must carry | ||
| /// the SAME <c>corr=</c> as the <c>LATE_NACK_REENQUEUE</c> that caused it.</para> |
| })); | ||
|
|
||
| [Fact(Timeout = 90_000)] | ||
| public async Task AReenqueuedAttemptInheritsTheCorrelationIdOfTheNackThatCausedIt() |
|
⛔ Merge-queue steward: left dequeued — a job other than a test shard failed — a build or gate failure is never a flake.
Not a catalogued flake. Fix the failure, or — with run URLs, an issue and an assertion-message pattern — add it to |
|
⛔ Merge-queue steward: left dequeued — a job other than a test shard failed — a build or gate failure is never a flake.
Not a catalogued flake. Fix the failure, or — with run URLs, an issue and an assertion-message pattern — add it to |
|
Held as draft — my own test in this PR is flaky, and it is flaky by construction rather than by chance. The merge-queue steward ejected this twice (runs 34088390261, 34089526911) and correctly declined to re-queue it. Not re-queueing; fixing it. The failure is not what the label says
and the test was then killed at 90 000 ms with no inner wait reporting anything. Root cause: the park and the dispose are the same hub, so they wait on each otherThe test parks the owner's merge executor, then calls SpinWait.SpinUntil(() => Volatile.Read(ref releaseGate) == 1 || owner.IsShuttingDown,
TimeSpan.FromSeconds(60));and The arithmetic is what makes this conclusive rather than a guess:
For the outer bound to fire while the inner 45 s wait never reports, the test must burn more than 45 s before it ever reaches its assertion. The only thing there that can is the dispose. And because the spin cap (60 s) exceeds the whole remaining budget, that cap cannot rescue the test — it guarantees the kill. The 663 ms local pass and the 90 s CI kill are the two sides of one race, not two different behaviours. Why the sibling looked like a safe pattern to copy
What changesThe production fix in |
|
Correction to my comment above — I named the wrong mechanism, twice. Root cause found; both earlier claims are falsified by reading the code they were about. What was wrong
The actual causeThe control settles it: on the same queue-build (34089526911), in the same shard, on the same host, the sibling
That is why the failure is silent: there is no assertion to fail and no timeout to report, because the thread that would carry the test forward is the thread being waited on. It also explains the pass/hang split exactly, because
The absent teardown lines are the fingerprint — the method never returned. There is a small irony worth recording: the XML comment on that very method congratulated itself for returning an The fix
I also removed a claim from the class doc that measurement had falsified: it asserted this test was "deterministic where the sibling is not." The opposite is what happened. Asserting on a log line instead of on storage buys determinism only if observing the line cannot perturb what produced it — here, observing it captured the producer's thread. Re-verified locally across repeated runs before re-arming; the production change in |
LateNackReenqueueCorrelationTest wedged for its full 90s Fact timeout on queue-build 34089526911 and was killed with no TEST END and no [DISPOSE] line — the method never returned. The control names the cause: in the SAME shard, on the same host, the sibling LateNackReenqueueTest passed. The two have identical park/dispose choreography; the only structural difference is that this test awaited a live Subject<string> fed from a logger. CapturingLoggerProvider.Add ran OnNext on whatever thread logged — for LATE_NACK_REENQUEUE, the owner hub's action-block thread — and awaiting an observable resumes its continuation inline on the signalling thread. So the assertions, the finally that releases the parked merge turn, the method return and the framework teardown all ran on the hub's own thread while that hub was mid-disposal. Silent by construction: no assertion to fail and no timeout to report, because the thread that would carry the test forward was the one being waited on. It presented as a flake because FirstMatching had two paths. The replay of already-buffered lines resumed on the test's thread (663ms, green); the live Subject resumed on the hub's (90s, killed). FirstMatching now polls the buffer from Observable.Interval, so the continuation resumes on the scheduler's thread. The Subject is deleted rather than left unused — keeping it invites the await back. 🚨 The XML comment on that method already cited the exact hazard, to justify returning IObservable rather than Task: "a Task completed inside an Rx pipeline resumes its awaiter inline on the signalling thread". That is correct and applies verbatim to awaiting the observable. Avoiding .ToTask() does not avoid what .ToTask() is banned for. Also removed a class-doc claim that measurement falsified — that this test was "deterministic where the sibling is not". The opposite happened. Verification is honest about its limits: 5/5 green locally at 593-644ms (previously 663ms/36s/90s), but locally this test always took the REPLAY path and the old code passed here too, so the local runs are a no-regression check, not proof. The proof is structural: there is no longer any path on which a hub thread signals the awaited observable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conserves the #3532 root cause where the next person will meet it, rather than leaving it in a PR comment. New section in WritingTests.md, placed beside the existing hot-vs-replayed-signals guidance because it is the same family of mistake seen from the scheduling side. Carries the three things that were expensive to recover: the fingerprint (absent TEST END / [DISPOSE] lines mean the method never returned), why it presents as a flake (replay and live paths resume on different threads, so local runs and the PR build pass while the queue fails), and the control that names it in one step (the sibling passed in the same shard). Also states the part that is easy to get wrong twice: avoiding .ToTask() does not avoid this. The hazard is the inline resume, not the Task. DocumentationLinkIntegrityTest passes (48ms); Documentation.Test builds clean at Release -warnaserror. Built rather than --no-build, since the page is an embedded asset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #3477.
Pairs-with: none— adds no public type or member and removes none; format arguments, one new log line, one test.Parser-delta: none — does not touch
scripts/check-type-forwards.py.The defect: a fresh
requestIdper attempt, so the chain broke exactly where it matteredThe re-enqueue calls
UpdateRemote(update, attempt + 1, …), which runs that line again and registers under a different id. So afterthere was no id to follow, and the three outcomes were indistinguishable in the log: the re-enqueue never left the cache hub; it left and never activated the owner; it activated and never answered. #3477's own words — "a write that was NACKed, re-enqueued and then neither applied nor answered is the write-loss class this test exists to refuse" — and it could not be attributed.
The fix: one correlation id, minted at entry, inherited by every re-attempt
UpdateRemotegainscorrelationId;corr = correlationId ?? Guid.NewGuid().AsString()mints once at the caller's entry, and both re-enqueue arms passcorrelationId: corr.corr=now appears onBEGIN,LATE_ACK,LATE_NACK_REENQUEUE,LATE_NACK_REENQUEUE failed,OWNER_NACK_REENQUEUEandVERDICT_TIMEOUT— the last being the line an operator actually reaches for, which now carries the instruction outright:grep corr=<id>yields every attempt of this write, including re-enqueues that registered under a different requestId. A newLATE_VERDICT_REGISTEREDnames the per-attemptrequestIdbeside the stablecorr.Purely additive. Nothing new is awaited, bounded or retried; no behaviour changes.
🚨 Why it matters more since tonight
#3505 reclassifies owner-side teardown faults as
OwnerDisposing, which feeds this arm — and not incidentally. OnceHostedHubsCollectioncloses a hosted hub's scope, the hub is pastDisposeHostedHubs,PostImplGenericrefuses its own post, and the verdict routes throughILatePatchVerdictSinkby construction: the late half, which is the half #3477 measured going dark. #3505's cost argument — "a false retryable costs at most two idempotent re-diffs" — holds only if the re-enqueue answers.Log levels, stated rather than assumed
src/levels are committed contract, not a verbosity knob.LATE_VERDICT_REGISTEREDandBEGINare Debug — one per write and one per late-registered write, on the hot path. The Warning that pays for them isVERDICT_TIMEOUT, which fires only once a write has already failed to settle, i.e. exactly when the chain is worth its cost.🚨 What the test covers — and what it does NOT
LateNackReenqueueCorrelationTestdrives the real sequence on the sibling's proven rig: park the owner's merge executor, write cross-hub, fence on the armed late watch, fence on the handler having run, dispose the owner so its NACK is necessarily late. It then asserts theLATE_NACK_REENQUEUEline carries a correlation id — which before this change it did not. Passes in ~190 ms, deterministic: a disposed owner's post is refused, so the verdict routes through the late sink immediately rather than after the 2 s response bound.It deliberately does not assert on the write landing — its sibling
LateNackReenqueueTestdoes, and is 1-in-330 flaky on exactly that wait, which is #3477's own open subject.And it does not pin INHERITANCE. That needs the re-attempt's own
BEGIN, which isLogDebugand is dropped by the harness's filter before any provider sees it. Two attempts to raise that filter for one category did not reach the factory the hub resolves. The honest options were to promote a hot-path line to Information so a test could see it — forbidden — or to say so. So the inheritance is guaranteed by the two call sites passingcorrelationId: corr, which the compiler checks and no test does. That gap is stated in the test's own doc comment and on the issue, rather than left for someone to find behind a green suite.Verification
MeshWeaver.Mesh.ContractRelease-warnaserror→ 0 Warning(s), 0 Error(s)MeshWeaver.Graph.TestRelease-warnaserror→ 0 Warning(s), 0 Error(s)LateNackReenqueueCorrelationTest→ 1 passed, real.trx, and the captured line verified in the run output:LATE_NACK_REENQUEUE … code=OwnerDisposing corr=s00FLGaix0yWYK2z8alWWQ