Skip to content

fix(#3477): correlate a re-enqueued write with the NACK that caused it - #3532

Merged
meshweaver-cloud[bot] merged 3 commits into
mainfrom
fix/3477-correlate-the-late-nack-reenqueue
Sep 7, 2026
Merged

meshweaver-cloud[bot] merged 3 commits into
mainfrom
fix/3477-correlate-the-late-nack-reenqueue

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

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 requestId per attempt, so the chain broke exactly where it mattered

var requestId = Guid.NewGuid().AsString();      // ~1941, per ATTEMPT
lateRegistry?.Register(requestId, _path!, resp => { … });

The re-enqueue calls UpdateRemote(update, attempt + 1, …), which runs that line again and registers under a different id. So after

[UpdateRemote] LATE_NACK_REENQUEUE … attempt=1 code=OwnerDisposing

there 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

UpdateRemote gains correlationId; corr = correlationId ?? Guid.NewGuid().AsString() mints once at the caller's entry, and both re-enqueue arms pass correlationId: corr.

corr= now appears on BEGIN, LATE_ACK, LATE_NACK_REENQUEUE, LATE_NACK_REENQUEUE failed, OWNER_NACK_REENQUEUE and VERDICT_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 new LATE_VERDICT_REGISTERED names the per-attempt requestId beside the stable corr.

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. Once HostedHubsCollection closes a hosted hub's scope, the hub is past DisposeHostedHubs, PostImplGeneric refuses its own post, and the verdict routes through ILatePatchVerdictSink by 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_REGISTERED and BEGIN are Debug — one per write and one per late-registered write, on the hot path. The Warning that pays for them is VERDICT_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

LateNackReenqueueCorrelationTest drives 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 the LATE_NACK_REENQUEUE line 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 LateNackReenqueueTest does, 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 is LogDebug and 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 passing correlationId: 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.Contract Release -warnaserror → 0 Warning(s), 0 Error(s)
  • MeshWeaver.Graph.Test Release -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

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.
Copilot AI lite review requested due to automatic review settings September 7, 2026 05:43
@meshweaver-cloud
meshweaver-cloud Bot enabled auto-merge September 7, 2026 05:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.UpdateRemote with an optional correlationId, mint once per write chain, and include corr= in key diagnostic logs (BEGIN, LATE_*, OWNER_*, VERDICT_TIMEOUT).
  • Add a new LATE_VERDICT_REGISTERED debug log that records both corr and the per-attempt requestId.
  • Add a new test that drives the late-OwnerDisposing NACK path and asserts LATE_NACK_REENQUEUE includes a non-empty corr.
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.

Comment on lines +68 to +76
.Configure<LoggerFilterOptions>(o =>
{
o.MinLevel = LogLevel.Debug;
o.Rules.Add(new LoggerFilterRule(
providerName: null,
categoryName: "MeshWeaver.Mesh.MeshNodeStreamHandle",
logLevel: LogLevel.Debug,
filter: null));
}));
Comment on lines +148 to +149
var reenqueue = await captured.FirstMatching(
@"LATE_NACK_REENQUEUE .*corr=(?<corr>[^\s]+)", TestTimeouts.Convergence);
Comment on lines +31 to +33
/// <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()
@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

492 tests  ±0   492 ✅ ±0   40s ⏱️ -4s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit cd0c0fe. ± Comparison against base commit 0707ac1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

251 tests  ±0   251 ✅ ±0   1m 40s ⏱️ -20s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit cd0c0fe. ± Comparison against base commit 0707ac1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 034 tests  ±0   1 034 ✅ ±0   1m 11s ⏱️ -4s
    2 suites ±0       0 💤 ±0 
    2 files   ±0       0 ❌ ±0 

Results for commit cd0c0fe. ± Comparison against base commit 0707ac1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 604 tests  +4   1 410 ✅ +4   1m 58s ⏱️ -7s
    4 suites ±0     194 💤 ±0 
    4 files   ±0       0 ❌ ±0 

Results for commit cd0c0fe. ± Comparison against base commit 0707ac1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    5 files  ±0      5 suites  ±0   2m 26s ⏱️ +5s
1 473 tests ±0  1 473 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 474 runs  ±0  1 474 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit cd0c0fe. ± Comparison against base commit 0707ac1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 113 tests  +34   1 113 ✅ +34   3m 33s ⏱️ +6s
    3 suites ± 0       0 💤 ± 0 
    3 files   ± 0       0 ❌ ± 0 

Results for commit cd0c0fe. ± Comparison against base commit 0707ac1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   16 files  ± 0     16 suites  ±0   11m 31s ⏱️ -24s
5 967 tests +38  5 773 ✅ +38  194 💤 ±0  0 ❌ ±0 
5 968 runs  +38  5 774 ✅ +38  194 💤 ±0  0 ❌ ±0 

Results for commit cd0c0fe. ± Comparison against base commit 0707ac1.

♻️ This comment has been updated with latest results.

@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 7, 2026
@meshweaver-cloud

Copy link
Copy Markdown
Contributor

⛔ Merge-queue steward: left dequeued — a job other than a test shard failed — a build or gate failure is never a flake.
Group build: https://github.com/Systemorph/MeshWeaver/actions/runs/34088390261

  • failed job: Test Results (shard 4)
  • failed job: Test Results

Not a catalogued flake. Fix the failure, or — with run URLs, an issue and an assertion-message pattern — add it to .github/known-flakes.json (see Doc/Architecture/MergeQueue). Re-queue with gh pr merge <n> --auto once the head is green; label queue-rejected marks this PR as needing a person.

@meshweaver-cloud meshweaver-cloud Bot added the queue-rejected The merge queue rejected this PR on an uncatalogued failure; a person owns it now label Sep 7, 2026
@rbuergi
rbuergi added this pull request to the merge queue Sep 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 7, 2026
@meshweaver-cloud

Copy link
Copy Markdown
Contributor

⛔ Merge-queue steward: left dequeued — a job other than a test shard failed — a build or gate failure is never a flake.
Group build: https://github.com/Systemorph/MeshWeaver/actions/runs/34089526911

  • failed job: Test Results (shard 4)
  • failed job: Test Results

Not a catalogued flake. Fix the failure, or — with run URLs, an issue and an assertion-message pattern — add it to .github/known-flakes.json (see Doc/Architecture/MergeQueue). Re-queue with gh pr merge <n> --auto once the head is green; label queue-rejected marks this PR as needing a person.

@rbuergi
rbuergi marked this pull request as draft September 7, 2026 06:29
@rbuergi

rbuergi commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

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

LateNackReenqueueCorrelationTest did not fail an assertion. The line it asserts on was written, 2.4s in:

[06:12:24.227] LATE_NACK_REENQUEUE hub=cache/w4Pa… target=TestData/corr-node
               attempt=1 code=OwnerDisposing corr=yW4yTuav70WJZrf5mU5lDg

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 other

The test parks the owner's merge executor, then calls nodeHub.Dispose() on that same hub. Dispose() drains the executor; the executor is sitting in

SpinWait.SpinUntil(() => Volatile.Read(ref releaseGate) == 1 || owner.IsShuttingDown,
                   TimeSpan.FromSeconds(60));

and releaseGate is only written in the finally, which cannot run until after the assertion. So the only early exit is owner.IsShuttingDown flipping before dispose blocks — a race.

The arithmetic is what makes this conclusive rather than a guess:

bound value on CI where from
outer test kill 90 s TestTimeouts.TestMilliseconds = Convergence * OuterMargin, OuterMargin = 2.0
my assertion wait 45 s TestTimeouts.Convergence
the park's spin cap 60 s literal in the test

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

LateNackReenqueueTest has the identical park/dispose/finally structure, and it is green — so the shape read as established. It differs in what it asserts: ground truth in durable storage, with its own 45 s poll after the dispose. Mine asserts on a log line, which buys no extra tolerance and adds nothing to the budget. The sibling therefore carries the same latent race and merely wins it more often; that is worth a separate look and I am not touching it in this PR.

What changes

The production fix in MeshNodeStreamExtensions.cs is unaffected — no assertion about it failed, and the corr= it emits is visible in the failure log above. Only the test's choreography is wrong. I am reworking it so the assertion does not depend on winning a disposal race, then re-arming.

@rbuergi

rbuergi commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

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

  1. "Dispose() blocks draining the parked executor for up to 60 s." It does not. MessageHub.Dispose() sets disposalStarted = true as the first statement inside a short lock — so IsShuttingDown flips immediately — and its own summary says "Returns immediately; observe DisposalCompleted for completion." It never blocks on the drain, so the park's || owner.IsShuttingDown exit cannot be starved by it. The circular wait I described does not exist.
  2. "The 90 000 ms is TestTimeouts.TestMilliseconds." It is this test's own [Fact(Timeout = 90_000)] literal. The arithmetic I built on Convergence * OuterMargin was numerically the same and causally irrelevant.

The actual cause

The control settles it: on the same queue-build (34089526911), in the same shard, on the same host, the sibling LateNackReenqueueTest passed while this test wedged. The structural difference is not the park — the two are identical there — it is that this is the only one of the two that awaits a live Subject<string> fed from a logger.

CapturingLoggerProvider.Add called emitted.OnNext(line), and Add runs on whatever thread logged — for LATE_NACK_REENQUEUE, the owner hub's action-block thread. Awaiting an observable resumes its continuation inline on the signalling thread. So everything after the await — the assertions, the finally that releases the parked merge turn, the method return, and the framework's teardown — ran on the hub's own action thread while that hub was mid-disposal.

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 FirstMatching had two paths:

path taken continuation resumes on observed
the replay of the already-buffered lines the test's own thread 663 ms, green
the live Subject the hub's action-block thread 90 s, killed, no TEST END, no [DISPOSE]

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 IObservable rather than a Task, citing "a Task completed inside an Rx pipeline resumes its awaiter inline on the signalling thread". That reasoning is correct and applies verbatim to awaiting the observable. Avoiding .ToTask() does not avoid the hazard .ToTask() is banned for.

The fix

FirstMatching now polls the buffer from Observable.Interval instead of awaiting a subject, so the continuation resumes on the scheduler's thread and never on the hub's. The Subject<string> is deleted outright rather than left unused — keeping it would invite the await back.

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 MeshNodeStreamExtensions.cs is untouched throughout.

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>
@rbuergi
rbuergi marked this pull request as ready for review September 7, 2026 06:38
@rbuergi rbuergi removed the queue-rejected The merge queue rejected this PR on an uncatalogued failure; a person owns it now label Sep 7, 2026
@meshweaver-cloud
meshweaver-cloud Bot enabled auto-merge September 7, 2026 06:38
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>
@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 395a749 Sep 7, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants