Skip to content

Report the HANDLER side when a callback outlives the quiescing budget - #1011

Merged
rbuergi merged 14 commits into
mainfrom
diag/981-handler-side
Aug 9, 2026
Merged

rbuergi merged 14 commits into
mainfrom
diag/981-handler-side

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Refs #981.

This is instrumentation, not a fix. It does not change the quiescing budget, add a retry, or post a response from anywhere. #981 stays open.

The gap it closes

Every capture of "callbacks still pending past the teardown quiescing budget" has been caller-only: the hub names the request type, its target and its age, and nothing about what happened to the delivery at the receiving end. Two occurrences — 2 738 ms and 6 153 ms against a 2 s budget, both a CreateNodeRequest addressed to the mesh hub itself, every queue empty — therefore left the mechanism unnamed. Further members have since landed in other projects, one on a docs-only PR with zero C#.

RequestFateLedger records, per awaited request id, the stages the delivery actually reached, and folds the trail into every place a pending callback is already reported.

What a capture now says

[QUIESCE-TIMEOUT] client/ccfc…: 1 callback(s) still pending after 0.3s.
  Pending: WqgJwZojREesGMav5jZf2Q=MisroutedReplyRequest@host/1(303ms)
  handler-side fate (what happened to the delivery):
    WqgJwZojREesGMav5jZf2Q=MisroutedReplyRequest:
      POSTED target=host/1@client/ccfc…(+0ms) → RECEIVED runLevel=Started@client/ccfc…(+0ms)
      → ROUTED onTarget=False state=Forwarded@mesh/1(+1ms) → RECEIVED runLevel=Started@host/1(+1ms)
      → ROUTED onTarget=True state=Submitted@host/1(+1ms) → HANDLER_ENTER@host/1(+1ms)
      → RESPONSE_POSTED type=SwallowedResponse target=mesh/1@host/1(+1ms)
      → HANDLER_EXIT state=Processed@host/1(+2ms)
      → RESPONSE_ARRIVED_NO_SUBJECT type=SwallowedResponse@mesh/1(+2ms)
      ⇒ a reply WAS posted for this correlation and the callback is STILL pending — the reply was
        lost between the responder and the requester, so chase the response delivery, not the handler.

Each trail ends in a verdict naming which of the mutually exclusive shapes it is: never delivered / delivered-but-parked / no handler matched / handler ran and never replied / chain completed without producing a reply / reply posted and lost. The stages stay above it as the evidence.

Answering the binary question

The specific ambiguity to remove was: does the chain that owes the reply complete slowly, or terminate without responding? Three parts close it:

  1. HANDLER_COMPLETED_WITHOUT_DELIVERY — a rule chain that completes without emitting is otherwise silent in every diagnostic and reads exactly like one still running.
  2. hub.NoteRequestStage(requestId, stage) — the seam a HANDLER uses to report on work it detached from the rule chain. The canonical mesh handlers return request.Processed() immediately and owe their reply from a composed observable, so the pipeline's own HANDLER_EXIT proves nothing about whether a reply is coming. HandleCreateNodeRequest — the handler named in every capture — now records CREATE_CHAIN_SUBSCRIBED / _EMITTED / _ERROR / _COMPLETED_EMPTY plus the post-creation handler arms.
  3. A verdict per trail, so the report states the shape instead of leaving it to be inferred.

While instrumenting (2) I found that the create handler's outer Subscribe has no onCompleted arm, and both save paths filter with .Where(n => n is not null). An empty completion there posts nothing and the caller waits forever — the #981 signature exactly. The new arm records it and deliberately does not post: which response is correct depends on why the chain went empty, and that is unknown until a capture names the branch. Recording is instrumentation; posting would be a guessed fix. Flagging it here as a code-reading observation, not a proven cause.

Cost and bounds

  • Entries exist only for ids with a live Observe callback, and are dropped the moment one resolves, is cancelled, or its subscription is disposed — so the ledger is bounded by the in-flight set, needs no eviction policy, and needs no Clear() for test isolation.
  • One ledger per hub tree (the root creates it, hosted hubs inherit their parent's instance) — an instance owned by the mesh, no static state. The tree scope is what lets the RECEIVING hub's stages land on the trail the REQUESTING hub reads.
  • Hot path: one ConcurrentDictionary lookup that short-circuits on an empty ledger. Stage strings are formatted only after a lookup HITS, so an un-awaited delivery allocates nothing.
  • A long trail keeps its head and its tail, suppressing the middle — the terminal stages decide the verdict, so they can never be the part that gets dropped. Rendering is capped by the existing PendingCallbackLogCap.

Reaching CI

The trail rides the channel that already works: QuiescingTimeoutDetail → GetQuiescingTimeoutSummary() → MonolithMeshTestBase's DISPOSE_QUIESCE_LEAK phase trace → /tmp/meshweaver-test-trace.log, which dotnet-test.yml copies with if: always(). All four reported #981 members derive from MonolithMeshTestBase.

Two checks against known traps:

The trail is also rendered on two other paths: [STALE-CALLBACK] (fires while the mesh is still live — relevant because #981's callbacks go unanswered ~3.1 s before Dispose() is invoked; dial MESHWEAVER_STALE_CALLBACK_MS down on a repro run) and GetDisposalDiagnostics (the DISPOSE_TIMEOUT case, where no QuiescingTimeoutDetail exists yet).

Also in this PR

HandleCreateNodeRequest's doc-comment claimed its async work was wrapped in Observable.FromAsync. It is not, and that pattern is forbidden outside IoPool — a stale doc advertising a banned pattern is one copy away from becoming true. Corrected, with the reason.

Verification

No What's New entry: internal test/diagnostic infrastructure, nothing user-facing.

🤖 Generated with Claude Code

rbuergi and others added 6 commits August 9, 2026 14:21
Refs #981.

Every capture of the pending-callback teardown failure has been caller-only:
the hub names the request type, its target and its age, and nothing about what
happened to the delivery at the receiving end. Two occurrences (2738ms and
6153ms against a 2s budget, both a CreateNodeRequest addressed to the mesh hub
itself, every queue empty) therefore left the mechanism unnamed.

RequestFateLedger records, per AWAITED request id, the stages the delivery
actually reached — posted, received, routed, deferred, dropped, handler
entered/exited with a state, and whether a response was ever posted for that
correlation. One ledger per hub tree (the root creates it, hosted hubs inherit
their parent's), so the hub that registered the callback and the hub that
received the delivery write to the same trail. It holds only ids with a live
Observe callback and drops each the moment it resolves, so it is bounded by the
in-flight set; the hot path is one dictionary lookup that short-circuits on an
empty ledger and formats nothing when it misses.

The trail is folded into QuiescingTimeoutDetail, which MonolithMeshTestBase
already writes to /tmp/meshweaver-test-trace.log (the one diagnostic CI keeps).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs #981.

Three places report a pending callback and all three were caller-only:

- [QUIESCE-TIMEOUT] (teardown budget) — already enriched in the previous commit.
- [STALE-CALLBACK] — the scanner that fires while the mesh is still SERVING.
  This is the one that matters for #981's first hard constraint: the callback
  predates disposal by ~3.1s on a fully live mesh, so teardown only DETECTS it.
  Dial MESHWEAVER_STALE_CALLBACK_MS down on a repro run and this line names the
  handler side before disposal is involved at all.
- GetDisposalDiagnostics — what the test base writes on a DISPOSE_TIMEOUT, i.e.
  the case where the hub never reached its quiescing timeout and so has no
  QuiescingTimeoutDetail to read.

Also documents how to read the trail (and what it cannot see) in
DebuggingMessageFlow.md, next to the manual Trace-log reconstruction it replaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…chain

Refs #981.

The open question is binary — does the chain that owes the reply complete
SLOWLY, or terminate WITHOUT responding? A trail that only says "no reply yet"
reproduces exactly that ambiguity, so three things close it:

- HANDLER_COMPLETED_WITHOUT_DELIVERY — a rule chain that completes without
  emitting is otherwise silent in every diagnostic and reads identically to one
  still running.
- hub.NoteRequestStage(requestId, stage) — the seam a HANDLER uses to report on
  work it detached from the rule chain. The canonical mesh handlers return
  Processed() immediately and owe their reply from a composed observable, so the
  pipeline's own HANDLER_EXIT proves nothing about whether a reply is coming.
  HandleCreateNodeRequest — the handler named in every #981 capture — now records
  CREATE_CHAIN_SUBSCRIBED / _EMITTED / _ERROR / _COMPLETED_EMPTY and the
  post-creation handler arms. Its outer Subscribe had NO onCompleted arm, and two
  save paths filter with .Where(n => n is not null), so an empty completion posts
  nothing and the caller waits forever. The new arm RECORDS that and deliberately
  does NOT post — which response is correct depends on WHY the chain went empty,
  and that is unknown until a capture names the branch. Instrumentation, not a
  guessed fix.
- A verdict line per trail, so the report states which failure shape it is
  instead of leaving the reader to infer it. Two verdicts are pinned by tests,
  including the discriminator that matters most: a reply posted but lost on the
  way home must not read like a handler that never replied (one says chase the
  response delivery, the other says chase the handler).

Also corrects HandleCreateNodeRequest's doc-comment, which claimed the async work
was wrapped in Observable.FromAsync. It is not, and that pattern is now forbidden
outside IoPool — a stale doc advertising a banned pattern is one copy away from
becoming true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs #981.

A head-only stage cap was actively harmful: HANDLER_EXIT, RESPONSE_POSTED,
*_COMPLETED_EMPTY and faults are always the LAST stages, and a cross-hub request
already spends ~12 stages on routing hops before a handler is entered at all. The
cap would have silently truncated exactly the evidence the verdict reads. Keep
the first 10 and a sliding last 8; suppress the middle and say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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.

Pull request overview

Adds handler-side instrumentation to pending-callback / quiescing-timeout diagnostics so #981 captures can distinguish “never delivered”, “no handler matched”, “handler ran but didn’t reply”, and “reply was posted but lost”.

Changes:

  • Introduces RequestFateLedger, a hub-tree-scoped in-flight ledger that records request/response delivery stages and emits a single “verdict” per pending callback.
  • Threads fate-stage recording through MessageService/MessageHub post, routing, deferral, handler-enter/exit, and response-arrival paths; exposes hub.NoteRequestStage(...) for handlers with detached work.
  • Adds end-to-end tests pinning the enriched quiescing-timeout summary (including cross-hub and misrouted-reply discriminators) and documents how to read the new trail.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/MeshWeaver.Messaging.Hub.Test/QuiescingTimeoutNamesHandlerSideTest.cs New tests that pin the enriched quiescing-timeout output and key fate/verdict distinctions.
src/MeshWeaver.Messaging.Hub/RequestFateLedger.cs New in-flight ledger that records handler-side request fate stages and renders a capped trail + verdict.
src/MeshWeaver.Messaging.Hub/MessageService.cs Records fate stages across intake, routing, deferral/drop, handler execution, and correlated response posting.
src/MeshWeaver.Messaging.Hub/MessageHubExtensions.cs Adds NoteRequestStage extension for handlers to record detached-chain terminal stages.
src/MeshWeaver.Messaging.Hub/MessageHub.cs Hooks fate rendering into [STALE-CALLBACK], quiesce-timeout, and disposal diagnostics; tracks/untracks with response-subject lifecycle.
src/MeshWeaver.Mesh.Contract/MeshExtensions.cs Instruments HandleCreateNodeRequest with NoteRequestStage stages (subscribe/emitted/error/completed-empty) and updates the related doc comment.
src/MeshWeaver.Documentation/Data/Architecture/DebuggingMessageFlow.md Documents the new handler-side fate trail and how to interpret the verdicts/stage omissions.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

rbuergi and others added 2 commits August 9, 2026 14:53
Refs #981.

A local trace holds 30 DISPOSE_QUIESCE_LEAK records and 26 of them say
"0 pending callback(s) after 2.00s: <none>" — the poll reported not-drained,
and by the time the verdict was rendered every callback had already resolved
inside the timeout hand-off. Nothing leaked; the detector raced its own budget.
But the report is indistinguishable at a glance from #981's, and "<none>" reads
as a diagnostic that failed to collect its evidence, so an investigator hunts a
leak that was never there. Three classes produce it locally
(TodoDataChangeWorkflowTest, TodoGraphIntegrationTest, TodoViewsTest).

Say what actually happened instead. Behaviour is unchanged — the hub still flags
QuiescingTimedOut and the class still fails; only the sentence changes, so a
grep over the trace can separate the two shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

685 tests  ±0   685 ✅ +1   10m 49s ⏱️ - 3m 48s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌  - 1 

Results for commit 090aebc. ± Comparison against base commit 043c0d3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   5m 43s ⏱️ -17s
1 929 tests ±0  1 821 ✅ ±0  108 💤 ±0  0 ❌ ±0 
2 061 runs  ±0  1 953 ✅ ±0  108 💤 ±0  0 ❌ ±0 

Results for commit 090aebc. ± Comparison against base commit 043c0d3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 725 tests  ±0   1 533 ✅ ±0   6m 11s ⏱️ +36s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 090aebc. ± Comparison against base commit 043c0d3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 903 tests  ±0   1 901 ✅ ±0   6m 16s ⏱️ -13s
   11 suites ±0       2 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 090aebc. ± Comparison against base commit 043c0d3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 272 tests  ±0   1 267 ✅ +1   7m 7s ⏱️ -31s
   11 suites ±0       5 💤 ±0 
   11 files   ±0       0 ❌  - 1 

Results for commit 090aebc. ± Comparison against base commit 043c0d3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

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

Results for commit 090aebc. ± Comparison against base commit 043c0d3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   65 files  ±0     65 suites  ±0   42m 15s ⏱️ - 4m 15s
8 708 tests +4  8 401 ✅ +6  307 💤 ±0  0 ❌  - 2 
8 840 runs  +4  8 533 ✅ +6  307 💤 ±0  0 ❌  - 2 

Results for commit 090aebc. ± Comparison against base commit 043c0d3.

♻️ This comment has been updated with latest results.

rbuergi and others added 6 commits August 9, 2026 15:55
Refs #981. Addresses the review thread on RequestFateLedger.

The docs said Render() produced one line; it emitted a newline before the
verdict. I made the CODE match the DOC rather than the reverse, because
single-line is a requirement here, not a formatting preference: these records
are read out of /tmp/meshweaver-test-trace.log with grep — a shared,
append-only file that is the one artifact surviving a killed CI run. A newline
before the verdict splits one record in two, so grep <messageId> returns the
stages and HIDES the conclusion, handing the reader evidence without the answer.
That is exactly the failure this instrumentation exists to remove. Long lines
lose to records that grep out whole. Pinned by a test.

Also closes a gap the first CI capture exposed in my own diagnostic. A trail
with zero stages rendered as "posted but no RECEIVED stage was recorded" — which
ASSERTS a post that was never observed, and could equally have meant the
callback was registered after the delivery had already traversed the pipeline
(the Observe(delivery) overload registers post-hoc). Track now seeds the trail
with an AWAITING stage, so a trail can never be empty and "AWAITING and nothing
else" is a positive statement: nothing was ever posted under this correlation.
Added the matching verdicts, including one for a post-routing state=Failed —
which is what the first real capture actually showed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs #981.

The first local repro of DynamicGraphIntegrationTest produced a trail carrying
ONLY the registration stage, which the ledger read as 'nothing was ever posted
under this correlation'. That reading was not safe: the same trail results when
the callback is registered AFTER its delivery was posted (the Observe(delivery)
overload registers post-hoc, so earlier stages were missed), and it also results
when Post THROWS between GetOrAddResponseSubject and the dispatch — which leaks
the pending entry with no delivery ever reaching the pipeline.

Three stages now separate those cases:
- POST_THREW  — the Post between registration and dispatch faulted. Records the
  exception and RETHROWS; no behaviour change, and the leaked entry is left
  exactly as it was so the defect stays visible rather than being papered over.
- REGISTERED_AFTER_POST — marks the Observe(delivery) overload, whose trail
  cannot describe anything before registration.
- The bare-registration verdict now says the gap is in the CALLER between Observe
  and Post, and names what to look for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi

rbuergi commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Attribution verdict for DynamicGraphIntegrationTest.TypeNodes_ExistInPersistence

It is #981 itself, reproducing — and this run is the first time the failure named its own handler side. It is an intermittent race, and I cannot rule my diff out as a contributor to its timing. Details, because "unrelated" would not be honest here.

What CI actually captured

DynamicGraphIntegrationTest left Observe subscriptions pending past the Quiescing budget.
Hub mesh/o_DLsQfpdUyDxl3M7wRGbg: 1 pending callback(s) after 2.00s:
  YyzUCix6UECGq-4VXqi4FQ=CreateNodeRequest@mesh/o_DLsQfpdUyDxl3M7wRGbg(2008ms)
  handler-side fate: <never reached any hub in this tree …>

CreateNodeRequest addressed to the mesh hub itself, unanswered past a 2.00 s budget, every queue empty — the exact #981 signature, in a fifth member alongside FutuRe / Cession / SourceDocumentDataLoading / PandasExplorer.

The same run also captured the child hub, and that trail is the first one that names a mechanism:

AFb9eWHZ80Ga41cFv4aG1Q=CreateOrUpdateNodeRequest:
  POSTED target=mesh/o_DLs…@ACME/Project(+0ms) → RECEIVED runLevel=Started@ACME/Project(+0ms)
  → ENQUEUED@ACME/Project(+0ms) → ROUTED onTarget=False state=Failed@ACME/Project(+0ms)

ROUTED onTarget=False state=Failed is the terminal stage. In MessageService.NotifyAsync, a Failed state is reported via ReportFailure only on the pre-routing, on-target path; after hierarchicalRouting.RouteMessageAsync returns, the not-on-target branch falls through to return Observable.Return(delivery) and the Failed state is dropped. No DeliveryFailure goes back, so the requester's Observe waits forever. Evidence, not code-reading — I have added a verdict that names this shape explicitly.

Is it pre-existing, or did my diff surface it?

Evidence Result
main CI run 31314508984 (7c432729, none of my code) Same failure: FutuReAnalysisTest … 1 pending callback(s) after 2.00s: CreateNodeRequest@mesh/<self>(4564ms) — and no handler-side block, because main lacks the instrumentation
main's other red run today (31314807609) the What's New frontmatter test, hotfixed by #1018 — unrelated
clean origin/main worktree, this test class, locally 30/30 passed (one sample)
this branch, same class, locally 1/30 failed — same test as CI, same trail
this branch, same class, after adding the POST_THREW / AWAITING stages 30/30 passed

So: the defect class is demonstrably live on main with an identical signature. But my branch reproduced this member once locally and main did not, and the branch then passed on the next run — i.e. an intermittent race, sampled once per configuration. That is not enough to claim my diff is innocent, and per the no-re-running rule I have not gone fishing for statistics.

The honest position: the ledger cannot create a pending callback — it only observes — but it does add per-delivery work on the message hot path (one dictionary lookup per stage site, plus a timestamp, an interpolation and an ImmutableList.Add for awaited ids). A teardown race decided at the 2 s boundary can plausibly be perturbed by that. I am not claiming otherwise.

What the instrumentation gained from this failure

The mesh hub's own trail came back with zero stages, rendered as posted but no RECEIVED stage was recorded — which asserts a post nothing had observed. That was a real defect in my own diagnostic, and this capture is what exposed it. Fixed:

  • Track() now seeds the trail with an AWAITING … stage, so a trail can never be empty. Locally the same failure now reads:
    AWAITING CreateNodeRequest→mesh/upwDICZ… ⇒ the hub registered a callback and NO delivery carrying this id ever entered the pipeline … the gap is in the CALLER between Observe and Post, not in any handler.
  • POST_THREW — records (and rethrows) a throw between GetOrAddResponseSubject and the dispatch, which leaks the pending entry with no delivery ever reaching the pipeline.
  • REGISTERED_AFTER_POST — marks the Observe(delivery) overload, whose trail cannot describe anything before registration. Without it, "registered post-hoc" and "never posted" read identically.

Three mutually exclusive causes now have three distinct stages where there was previously one silence.

Recommendation

Do not merge on my say-so. Two things are worth a maintainer's call:

  1. Whether the post-routing Failed-state drop in NotifyAsync is the Callbacks still pending past the teardown quiescing budget (FutuRe EuropeRe, Cession, PandasExplorer) — a CreateNodeRequest the mesh hub never answers #981 root cause — the trail says it is for the child-hub half. That is a behaviour change (posting DeliveryFailure for not-on-target routing failures can cascade), so it belongs in its own PR with its own tests, not in an instrumentation change.
  2. Whether the timing sensitivity above is acceptable. If it is not, the ledger can be gated behind an env var — at the cost of the PR Three read-after-write flakes, three unrelated defects (#978) #997 failure mode (a diagnostic that is configured off exactly where you need it).

@rbuergi
rbuergi merged commit e3dd0b9 into main Aug 9, 2026
16 checks passed
rbuergi added a commit that referenced this pull request Aug 9, 2026
… ledger

PR #1011 (RequestFateLedger) landed while this was in flight. The
conflict is in NotifyAsync's routing-loop arm, where both changes touch
the same return. Both are kept: the ledger records ROUTING_LOOP, then the
failure is reported instead of dropped.

The new reporting seam also records its own stages —
ROUTING_FAILED_REPORTED (with the verdict) and
ROUTING_FAILED_ALREADY_NACKED — so a capture says outright whether the
router answered the sender, rather than ending at
'ROUTED onTarget=False state=Failed' with nothing after it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants