Skip to content

fix(#1485): carry answerability across the packaging boundary — the routers stop NACKing heartbeats - #1502

Merged
rbuergi merged 4 commits into
mainfrom
fix/1485-answer-once-across-packaging
Aug 14, 2026
Merged

rbuergi merged 4 commits into
mainfrom
fix/1485-answer-once-across-packaging

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Closes #1485.

The defect

MeshBuilder is the only call site of IRoutingService.DeliverMessage, and it passes
delivery.Package(routes.Hub.JsonSerializerOptions). Package replaces the payload with
RawJson. Every answer-once guard in the routing layer was a CLR-type test on
delivery.Message — is DeliveryFailure / HasAttribute<CanBeIgnoredAttribute>() — so on the
routed path it inspected RawJson and could never match.

Consequence: the routers DID NACK [CanBeIgnored] heartbeats to a departed owner — re-posted
every heartbeat interval, which is the NotFound storm the guards' own comments describe — and
COULD answer a DeliveryFailure with a DeliveryFailure.

One thing in the issue was wrong

#1485 read the monolith RoutingServiceBase guards as sitting before packaging and therefore
still working, making the two routers "disagree in production". They do not. Both routers are
behind the same single packaging call, so the comment claiming they "both agree" was true only in
that both were uniformly dead. MonolithRouterAnswerOnceAfterPackagingTest is that claim made
executable — it fails on the pre-fix tree exactly like the Orleans one.

The sweep found seven sites, not two

Site Note
RoutingGrain.PostFailure in the issue
OrleansRoutingService.MayAnswer (2 call sites) in the issue
RoutingServiceBase.NackRouteFailure issue said this worked — it did not
RoutingServiceBase.PostNotFound issue said this worked — it did not
MonolithRoutingService.PostNotFound also missing the [CanBeIgnored] clause outright
MessageService.NackThroughParent [CanBeIgnored] half dead behind its RawJson clause
MessageService.ReportFailure the routing TAIL

That last one matters most: ReportRoutingFailure reports whatever the mesh's route handler hands
back, and that handler is DeliverMessage(delivery.Package(...)) — so fixing only the routers
would have moved the identical storm one level up, on the one path where a router returns Failed
synchronously (the Orleans shutdown short-circuit, i.e. every pod shutdown).

The fix

Follows the precedent already inside Package for IDiagnosticKeyed (#1200): stamp the fact
onto the envelope immediately before the type is erased, and have every guard read the envelope via
delivery.MayAnswer() (new AnswerPolicy, in MeshWeaver.Messaging.Contract).

The fact, not the type name — the one question the routing layer asks is "may I answer this", and
a general-purpose type oracle on the envelope would invite the payload-parsing decisions this
codebase deliberately avoids. Only the suppressed case is stamped, so ordinary traffic pays
nothing and an unstamped delivery (never packaged, or pre-serialised by an external client) degrades
to exactly the old CLR-type behaviour — never to "answer something you must not". The property is
presence-checked, never value-compared: a delivery property crosses the gRPC/SignalR wire as
JSON and returns as a JsonElement rather than the bool that was stamped, which is the trap
MessageStormBreaker.ResolvePayloadKey documents for the sibling stamp.

Fail-before, demonstrated

Each test was run on the pre-fix tree and is RED there — all three deliveries (heartbeat,
DeliveryFailure, ordinary control) were answered where exactly one should be:

  • OrleansRouterAnswerOnceAfterPackagingTest — real IHostApplicationLifetime, real hub at the
    sender's address; found 3
  • MonolithRouterAnswerOnceAfterPackagingTest — real monolith mesh, real IRoutingService;
    found 3
  • RoutingTailAnswerOnceAfterPackagingTest — the Orleans shutdown branch's exact return shape
    (packaged, then Failed); found 3 (verified by reverting just that guard and re-running)

Each also carries a positive control dispatched LAST through the same serial path, so "the
suppressed ones were processed" is proven rather than waited for. Packaging_ErasesTheGuardsInput
pins the mechanism itself.

Verification

  • dotnet build -c Release -warnaserror: MeshWeaver.Messaging.Hub.Test,
    MeshWeaver.Hosting.Orleans.Test, MeshWeaver.Hosting.Monolith.Test — 0 warnings, 0 errors
    (these transitively cover every touched src project and its dependents)
  • MeshWeaver.Messaging.Hub.Test — 242/242
  • MeshWeaver.Hosting.Orleans.Test routing suite — 19/19
  • MeshWeaver.Hosting.Monolith.Test routing/NACK subset — 32/32
  • MeshWeaver.Documentation.Test — 21/21 (doc edit + What's New node)

Not in this PR

#1486 is deliberately left open — see the analysis posted there. Short version: its natural fix
(route the NACK through the same path forward traffic takes, instead of publishing straight to the
Orleans stream) is only safe once this PR has landed, because without the answer-once guard it
would create a real NACK loop. That is very likely why the direct stream publish exists at all.

🤖 Generated with Claude Code

… routers stop NACKing heartbeats

Every mesh delivery reaches a router as `delivery.Package(hub.JsonSerializerOptions)` —
`MeshBuilder` is the single call site of `IRoutingService.DeliverMessage` — and `Package`
replaces the payload with `RawJson`. The routing layer's answer-once guards were written as
CLR-type tests on `delivery.Message` (`is DeliveryFailure` / `HasAttribute<CanBeIgnored>`),
so on the routed path they inspected `RawJson` and could never match. The routers therefore
NACKed `[CanBeIgnored]` heartbeats to a departed owner — re-posted every heartbeat interval,
which IS the NotFound storm the guards' own comments describe — and could answer a
DeliveryFailure with a DeliveryFailure.

Seven dead sites, not two. The issue read the monolith guards as sitting BEFORE packaging and
therefore still working; they do not. Both routers sit behind the same single packaging call,
so the comment claiming they "both agree" was true only in that both were uniformly dead:

  - RoutingGrain.PostFailure
  - OrleansRoutingService.MayAnswer (both call sites)
  - RoutingServiceBase.NackRouteFailure
  - RoutingServiceBase.PostNotFound
  - MonolithRoutingService.PostNotFound  (also missing the [CanBeIgnored] clause outright)
  - MessageService.NackThroughParent     ([CanBeIgnored] half dead behind its RawJson clause)
  - MessageService.ReportFailure         (the routing TAIL — fixing only the routers would
                                          have moved the same storm one level up)

The fix follows the precedent already inside `Package` for `IDiagnosticKeyed` (#1200): stamp
the FACT onto the envelope immediately before the type is erased, and have every guard read
the envelope via `delivery.MayAnswer()`. The fact, not the type name — the single question the
routing layer asks is "may I answer this", and a general-purpose type oracle on the envelope
would invite the payload-parsing decisions this codebase deliberately avoids. Only the
SUPPRESSED case is stamped, so ordinary traffic pays nothing and an unstamped delivery (one
that never went through `Package`, or arrived pre-serialised from an external client) degrades
to exactly the old CLR-type behaviour — never to "answer something you must not". The property
is presence-checked, never value-compared, because a delivery property crosses the wire as
JSON and returns as a JsonElement rather than the bool that was stamped.

Three tests, one per host plus the reporting tail, each demonstrated RED on the pre-fix tree
(all three suppressed+control deliveries were answered; expected one):

  - OrleansRouterAnswerOnceAfterPackagingTest   — real host lifetime, real hub at the sender
  - MonolithRouterAnswerOnceAfterPackagingTest  — real monolith mesh, real IRoutingService
  - RoutingTailAnswerOnceAfterPackagingTest     — the Orleans shutdown branch's return shape

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A participant proxy re-serializes the whole envelope (SignalRConnectionHub.DeliverMessage, the
gRPC registry) and the routers on the far side apply the same answer-once contract to what comes
back — so the stamp has to survive that hop, and it is the reason MayAnswer checks the property
key's PRESENCE and never its value: an object-valued delivery property is stamped as a bool and
arrives as a JsonElement. A value comparison would quietly stop matching after exactly one hop,
which is the round-trip MessageStormBreaker.ResolvePayloadKey documents for the sibling
DiagnosticKey stamp. Verified rather than assumed.

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

Fixes #1485 by making the routing layer’s “answer-once” decision survive the MessageDelivery.Package(...) boundary (where payloads become RawJson). The change stamps “answer suppressed” onto the delivery envelope at packaging time and updates all relevant guard sites to consult the envelope via delivery.MayAnswer(), preventing heartbeat/NACK amplification storms during shutdown and missing-target scenarios.

Changes:

  • Introduce AnswerPolicy and stamp AnswerSuppressed onto deliveries during MessageDelivery.Package(...).
  • Replace CLR-type-based guards with delivery.MayAnswer() across router + reporting tail paths.
  • Add Orleans/Monolith/tail regression tests and document the invariant (including a What’s New entry).

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated no comments.

Show a summary per file
File Description
test/MeshWeaver.Messaging.Hub.Test/RoutingTailAnswerOnceAfterPackagingTest.cs Adds a regression test pinning the “routing tail” behavior on packaged deliveries that fail synchronously.
test/MeshWeaver.Hosting.Orleans.Test/OrleansRouterAnswerOnceAfterPackagingTest.cs Adds Orleans router regression tests proving type erasure and enforcing answer-once behavior post-packaging.
test/MeshWeaver.Hosting.Monolith.Test/MonolithRouterAnswerOnceAfterPackagingTest.cs Adds monolith router regression test ensuring the same answer-once behavior behind the packaging boundary.
src/MeshWeaver.Messaging.Hub/MessageService.cs Updates failure reporting and parent-NACK behavior to use envelope-based MayAnswer() instead of payload CLR type.
src/MeshWeaver.Messaging.Hub/MessageDelivery.cs Stamps answer-suppression metadata onto the envelope immediately before payload type erasure to RawJson.
src/MeshWeaver.Messaging.Contract/AnswerPolicy.cs Centralizes the answer-once contract and provides MayAnswer(this IMessageDelivery) for all emit/guard sites.
src/MeshWeaver.Hosting/RoutingServiceBase.cs Updates monolith routing guards (NackRouteFailure, PostNotFound) to use delivery.MayAnswer().
src/MeshWeaver.Hosting.Orleans/RoutingGrain.cs Updates Orleans routing grain failure posting guard to use delivery.MayAnswer().
src/MeshWeaver.Hosting.Monolith/MonolithRoutingService.cs Fixes monolith NotFound sender-NACK decision to use envelope-based policy (and aligns with full contract).
src/MeshWeaver.Connection.Orleans/OrleansRoutingService.cs Removes the local MayAnswer helper and uses delivery.MayAnswer() consistently in shutdown/dispatch failure paths.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-14-routing-answer-once.md Adds a user-facing “Fix” entry describing the shutdown/node-deletion storm reduction.
src/MeshWeaver.Documentation/Data/Architecture/ActionBlockWedgePrevention.md Documents the invariant: exemption must be read from the envelope, not payload CLR type, with references to the new tests.

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

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

728 tests  +5   728 ✅ +5   11m 21s ⏱️ +38s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 9d39fed. ± Comparison against base commit 4989ada.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   4m 17s ⏱️ - 1m 4s
1 837 tests +2  1 734 ✅ +2  103 💤 ±0  0 ❌ ±0 
2 178 runs  +9  2 075 ✅ +9  103 💤 ±0  0 ❌ ±0 

Results for commit 9d39fed. ± Comparison against base commit 4989ada.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 949 tests  +13   1 939 ✅ +13   8m 11s ⏱️ +6s
   11 suites ± 0      10 💤 ± 0 
   11 files   ± 0       0 ❌ ± 0 

Results for commit 9d39fed. ± Comparison against base commit 4989ada.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 552 tests  +10   2 551 ✅ +10   8m 25s ⏱️ +56s
   12 suites + 1       1 💤 ± 0 
   12 files   + 1       0 ❌ ± 0 

Results for commit 9d39fed. ± Comparison against base commit 4989ada.

This pull request removes 154 and adds 164 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.DeleteLayoutAreaIntegrationTest ‑ DeleteNode_DeletesNode
MeshWeaver.Hosting.Monolith.Test.DeleteLayoutAreaIntegrationTest ‑ DeleteNode_FromNodeHub_Succeeds
MeshWeaver.Hosting.Monolith.Test.DeleteLayoutAreaIntegrationTest ‑ DeleteNode_PostRegisterCallback_DoesNotDeadlock
MeshWeaver.Hosting.Monolith.Test.DeleteLayoutAreaIntegrationTest ‑ DeleteNode_PostRegisterCallback_Recursive_DoesNotDeadlock
MeshWeaver.Hosting.Monolith.Test.DeleteLayoutAreaIntegrationTest ‑ DeleteNode_WithChildren_DeletesAll
MeshWeaver.Hosting.Monolith.Test.DeleteRedirectToExistingParentTest ‑ NearestNonSatelliteAncestor_skips_satellite_grouping
MeshWeaver.Hosting.Monolith.Test.DeleteRedirectToExistingParentTest ‑ Redirect_goes_home_for_top_level_node
MeshWeaver.Hosting.Monolith.Test.DeleteRedirectToExistingParentTest ‑ Redirect_skips_virtual_parent_and_targets_nearest_existing_ancestor
MeshWeaver.Hosting.Monolith.Test.DeleteRedirectToExistingParentTest ‑ Redirect_targets_real_immediate_parent_when_it_exists
MeshWeaver.Hosting.Monolith.Test.DisposalPendingSaveFlushTest ‑ WriteAckedInsideSampleWindow_SurvivesHubDisposal
…
MeshWeaver.Auth.Test.ApiTokenServiceTests ‑ CreateToken_DoesNotSweepAnotherUsersExpiredTokens
MeshWeaver.Auth.Test.ApiTokenServiceTests ‑ CreateToken_LeavesLiveTokensAlone
MeshWeaver.Auth.Test.ApiTokenServiceTests ‑ CreateToken_SweepsTheUsersAlreadyExpiredTokens
MeshWeaver.FutuRe.Test.FutuReAnalysisTest ‑ ActivityLogs_ShouldBeQueryableViaMeshQuery
MeshWeaver.FutuRe.Test.FutuReAnalysisTest ‑ AllNodeTypes_ShouldExist
MeshWeaver.FutuRe.Test.FutuReAnalysisTest ‑ AmericasIns_Overview_ShouldRender
MeshWeaver.FutuRe.Test.FutuReAnalysisTest ‑ AmountTypes_ShouldLoadFromMeshNodes
MeshWeaver.FutuRe.Test.FutuReAnalysisTest ‑ AnnualReport_EmbeddedCharts_ShouldRenderViaPathResolution
MeshWeaver.FutuRe.Test.FutuReAnalysisTest ‑ AnnualReport_Overview_ShouldContainLayoutAreaReferences
MeshWeaver.FutuRe.Test.FutuReAnalysisTest ‑ AnnualReport_Overview_ShouldRender
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 464 tests  +60   1 464 ✅ +60   6m 20s ⏱️ -24s
   11 suites ± 0       0 💤 ± 0 
   11 files   ± 0       0 ❌ ± 0 

Results for commit 9d39fed. ± Comparison against base commit 4989ada.

This pull request removes 97 and adds 157 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.DeleteNodeBehaviorTest ‑ Leaf_Delete_SucceedsAndRemovesNode
MeshWeaver.Hosting.Monolith.Test.DeleteNodeBehaviorTest ‑ Missing_Node_Fails_NotFound
MeshWeaver.Hosting.Monolith.Test.DeleteNodeBehaviorTest ‑ NoDeletePermission_OnRoot_Fails_Unauthorized_AndLogsPath
MeshWeaver.Hosting.Monolith.Test.DeleteNodeBehaviorTest ‑ NonRecursive_WithChildren_Fails_HasChildren_NothingDeleted
MeshWeaver.Hosting.Monolith.Test.DeleteNodeBehaviorTest ‑ Recursive_Delete_Log_ListsAllAffectedPathsAndSucceeded
MeshWeaver.Hosting.Monolith.Test.DeleteNodeBehaviorTest ‑ Recursive_Delete_RemovesEntireSubtree
MeshWeaver.Hosting.Monolith.Test.DeleteNodeBehaviorTest ‑ Validator_RejectsDescendant_BlocksWholeSubtree_AllPathsListed
MeshWeaver.Hosting.Monolith.Test.DeleteNodeBehaviorTest ‑ Validator_RejectsRoot_Fails_ValidationFailed_LogsNodePath
MeshWeaver.Hosting.Monolith.Test.DeleteNodeBehaviorTest ‑ Warnings_WithConfirm_Proceed_AndLogWarning
MeshWeaver.Hosting.Monolith.Test.DeleteNodeBehaviorTest ‑ Warnings_WithoutConfirm_Block_AndLogWarning
…
MeshWeaver.Data.Test.ReadDuringDisposalWindowTest ‑ ReadThatFaultsOnTeardown_IsNackedShuttingDown_NotAnsweredAsAbsent
MeshWeaver.Hosting.Monolith.Test.DeleteLayoutAreaIntegrationTest ‑ DeleteNode_DeletesNode
MeshWeaver.Hosting.Monolith.Test.DeleteLayoutAreaIntegrationTest ‑ DeleteNode_FromNodeHub_Succeeds
MeshWeaver.Hosting.Monolith.Test.DeleteLayoutAreaIntegrationTest ‑ DeleteNode_PostRegisterCallback_DoesNotDeadlock
MeshWeaver.Hosting.Monolith.Test.DeleteLayoutAreaIntegrationTest ‑ DeleteNode_PostRegisterCallback_Recursive_DoesNotDeadlock
MeshWeaver.Hosting.Monolith.Test.DeleteLayoutAreaIntegrationTest ‑ DeleteNode_WithChildren_DeletesAll
MeshWeaver.Hosting.Monolith.Test.DeleteRedirectToExistingParentTest ‑ NearestNonSatelliteAncestor_skips_satellite_grouping
MeshWeaver.Hosting.Monolith.Test.DeleteRedirectToExistingParentTest ‑ Redirect_goes_home_for_top_level_node
MeshWeaver.Hosting.Monolith.Test.DeleteRedirectToExistingParentTest ‑ Redirect_skips_virtual_parent_and_targets_nearest_existing_ancestor
MeshWeaver.Hosting.Monolith.Test.DeleteRedirectToExistingParentTest ‑ Redirect_targets_real_immediate_parent_when_it_exists
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 920 tests  ±0   1 728 ✅ ±0   5m 57s ⏱️ -16s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 9d39fed. ± Comparison against base commit 4989ada.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    66 files  + 1      66 suites  +1   44m 35s ⏱️ -3s
10 450 tests +90  10 144 ✅ +90  306 💤 ±0  0 ❌ ±0 
10 791 runs  +97  10 485 ✅ +97  306 💤 ±0  0 ❌ ±0 

Results for commit 9d39fed. ± Comparison against base commit 4989ada.

♻️ This comment has been updated with latest results.

…ionSource from the routing tests

Standing constraint: 100% reactive, no polling, and no FirstAsync/Take(1) shenanigans — a one-shot
take terminates the subscription and is "sample a moment" thinking spelled reactively.

The three tests now push every NACK the router posts onto a ReplaySubject, and assert by
SUBSCRIBING to a running fold of that stream through the repo's assertion idiom,
`.Should().Within(...).Match(...)`, which settles on the first emission satisfying the predicate
and fails with a real diagnostic on timeout. Gone: `DeliverMessage(...).FirstAsync().ToTask()`
(now the sanctioned cold-observable `.Subscribe(...)`, which IS the dispatch),
`TaskCompletionSource` + `WaitAsync`, and the mutable captured control-id.

The assertion is a positive signal, not a wait: the ordinary control message is dispatched LAST
down the same serial path, so the fold at the moment IT is answered already contains anything the
two suppressed deliveries produced. `ContainSingle` on that fold is therefore a real assertion
about a completed sequence — never "sleep, then look".

Fail-before re-established for the rewritten bodies (the earlier evidence covered the old ones):
disabling the stamp in Package alone turns all five assertions red — Orleans 2/3, monolith 1/1,
routing tail 2/2, each reporting `found 3` where one is expected.

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

rbuergi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up commit 0a1de82 — tests rewritten to the standing reactive constraint (no polling, no
FirstAsync/Take(1), no TaskCompletionSource).

The three tests now push every NACK the router posts onto a ReplaySubject and assert by
subscribing to a running fold of that stream through the repo's idiom
.Should().Within(...).Match(...). DeliverMessage(...).FirstAsync().ToTask() became the sanctioned
cold-observable .Subscribe(...) — which is the dispatch — and the TaskCompletionSource +
WaitAsync pair is gone.

The assertion stays a positive signal, not a wait: the ordinary control message is dispatched
LAST down the same serial path, so the fold at the moment it is answered already contains anything
the two suppressed deliveries produced. ContainSingle on that fold is an assertion about a
completed sequence, never "sleep, then look".

Fail-before re-established for the rewritten bodies (the earlier evidence covered the old ones):
disabling only the stamp in Package turns all five assertions red — Orleans 2/3, monolith 1/1,
routing tail 2/2, each reporting found 3 where one is expected.

Re-verified locally on the new commit: MeshWeaver.Messaging.Hub.Test 243/243,
MeshWeaver.Hosting.Orleans.Test 160/160 (the full suite, including the real-cluster routing
tests), MeshWeaver.Data.Test 373/373, plus -c Release -warnaserror clean on all three test
projects.

Production code is unchanged by this commit and carries no async at all: AnswerPolicy is pure
predicates and the guards are synchronous boolean checks.

… level its new volume can afford

Two defects in the one log line the answer-once fix newly exercises.

NAME: the payload at this point is RawJson by construction, so `delivery.Message.GetType().Name`
printed the literal string "RawJson" for every suppressed delivery. GetMessageType reads the $type
out of the JSON — the only way the line says what was actually suppressed.

LEVEL: this is a cost/value change, not a debugging tweak. The branch used to be reachable only by
TYPED control traffic (a packaged delivery could not match the CLR-type test), so it was rare and
Warning was the right price. Reading the contract off the envelope makes it reachable once per
SUPPRESSED delivery, and the moment that happens in bulk is a pod shutdown — prod 2026-08-10
measured 944 on one dying pod, i.e. ~1k Loki lines per shutdown, billed forever, emitted exactly
when the process has least capacity. The value is near zero: suppression here is the DESIGNED
outcome for a DeliveryFailure or a [CanBeIgnored] message, and MayAnswer() is false for nothing
else, so the line reports normal behaviour rather than an anomaly. The storm it replaces — a posted
NACK per message, each itself routed and logged — was the expensive thing; keeping a per-message
Warning would bank a fraction of the same bill for no diagnostic gain.

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

rbuergi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

⛔ Not merging yet — CI went red on an unrelated test and I am not satisfied it is unrelated

MeshWeaver.Hosting.Monolith.Test.PatchWriteRouteCollapseTest.SequentialWriter_DeletedText_IsNotResurrectedByADuplicateWrite
failed on shard 1 of commit 0a1de82. It is #1249's regression guard: a deleted word came back
because one patch got persisted by two routes.

Why I did not just re-run it. Re-running would have been green (the next commit's full CI was
green), and that would have hidden the question rather than answered it.

What I established

  • The immediately preceding commit 504a0db has a byte-identical src/ tree and passed, with
    all six shards executed (verified job-by-job, not just the suite conclusion). The only delta
    between green and red is three test files of mine, none of which touches the persistence path.
  • No code path: every guard I changed is on a failure/NACK path, and the one hot-path addition
    (Package) adds no property for non-suppressed payloads, so an ordinary PatchDataRequest
    delivery is byte-identical to before. The three [CanBeIgnored] types in the tree are
    HeartBeatEvent, ShutdownRequest, DisposeRequest — none is on this test's write path.
  • Locally 0 failures in 40 runs (20 default, 20 at DOTNET_PROCESSOR_COUNT=2).
  • The race is real and timed, not structural: PostCommitFlushRegistry's own docs say the
    sampler's mark is read at handler time and the conflict "needs the row to advance inside the queue
    window — milliseconds". So the duplicate route always exists; what varies is whether the flush
    raises its high-water mark before the sampler's 200 ms debounce fires. A regression guard over a
    timed window is flaky by construction under load.

The measurement that stopped me

flake-repro.yml in rate mode, 60 iterations per arm on the 2-vCPU runner:

arm result
main (run) 0 / 60
this branch (run) 3 / 60 (iterations 9, 31, 60)

That is suggestive but not conclusive — under the null hypothesis of an equal rate, all three
failures landing in one arm has probability ≈ 12.5%. It is nowhere near enough to dismiss, and
"deleted text comes back" is data loss, so I am not merging on a green that could be luck.

Re-running both arms at 110 iterations to separate the hypotheses
(main,
branch). If the branch rate is
genuinely higher I will find and fix the mechanism before this merges; the leading candidate is the
per-message reflection AnswerPolicy.IsAnswerSuppressed adds inside Package(), which is the only
thing in this PR that touches a hot path.

@rbuergi
rbuergi merged commit d5d7507 into main Aug 14, 2026
21 of 22 checks passed
@rbuergi

rbuergi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

✅ Resolved — the branch was exonerated by the larger sample, and merged on a genuine green

Re-ran both arms at 110 iterations. The verdict inverts the first sample:

ref 60 iters 110 iters pooled
main 0 / 60 7 / 110 (15, 17, 26, 53, 61, 89, 109) 7 / 170 ≈ 4%
this branch 3 / 60 1 / 110 (24) 4 / 170 ≈ 2%

So main flakes more than the branch, and the initial 0-vs-3 was precisely the small-sample luck
flake-repro.yml's own header warns about — the same trap in the other direction. Fisher exact on
the pooled counts gives p ≈ 0.5: no difference.

PatchWriteRouteCollapseTest is therefore a pre-existing ~4% flake on main, filed with the rate
data and a root cause as #1557: #1249's route-collapse is a timed window (the sampler's 200 ms
debounced write versus the post-commit flush raising its high-water mark), while its guard asserts
the deterministic claim "there is no duplicate write to hold at all". Each occurrence is also a real
resurrection of deleted content, so it is not only a test problem.

Merged on 9d39fed, whose MeshWeaver Build and Test was COMPLETED/SUCCESS with all six shards
executed
(checked job-by-job, not just the suite conclusion — a green suite can coexist with
skipped shards on a reused tree).

@rbuergi
rbuergi deleted the fix/1485-answer-once-across-packaging branch August 19, 2026 10:34
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