Skip to content

fix(#1486): the NACK takes the local route the forward message took - #1612

Merged
rbuergi merged 2 commits into
mainfrom
fix/1486-nack-takes-the-local-route
Aug 14, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/1486-nack-takes-the-local-route

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

RoutingGrain.PostFailure returned the DeliveryFailure by publishing to the sender's Orleans stream — unconditionally.

Every other same-process delivery to a co-hosted hub short-circuits on OrleansRoutingService's local route table and never touches a stream. So the failure leg was the weakest inbound path in the system: a hub whose stream subscription was never attached — or was attached and then lost, which SubscribeWhenStreamingReadyAsync can do while the local route stays live — is reachable for forward traffic and unreachable for NACKs.

And it fails silently by construction: a publish to a memory stream with no live subscriber succeeds. Nothing faults, the ContinueWith never sees IsFaulted, and the NACK is simply gone — so the requester waits forever for an answer the router believes it sent. That is the [STALE-CALLBACK] shape.

The fix is the routing decision, not a deadline

PostFailure now asks the routing service for the sender's local route first and delivers through it when this silo hosts the sender — the same step-1 short-circuit the forward leg takes. The answer travels the way the question did.

Only a sender on another silo still needs the stream, and that path's trace line is renamed FAILURE_DELIVER_OK_UNCONFIRMED, because "the publish did not fault" is all it can honestly claim.

🚨 The issue is explicit that a timeout here "would only convert a silent hang into a logged one; it would not deliver the NACK" — so no bound was added.

What this settles, and what it does not

Question 3 — settled. "SubscribeWhenStreamingReadyAsync giving up while the local route stays live is its own inconsistency: two views of reachability that can disagree, with nothing reconciling them." For a co-hosted hub they no longer disagree, because the NACK now consults the same view the forward path does.

Question 2 — deliberately open. For a genuinely remote sender, a publish with no subscriber still cannot distinguish delivered from discarded. That needs a stream-level answer this change does not have — it is now the remaining case rather than the majority one.

Relationship to #1563

#1563 is the caller's side of the same defect: a ContentRoute GetDataRequest to an unreachable hub waiting out its full 60 s instead of taking the prompt NotFound. Its sender is the portal's own hub — co-hosted with the router — so its NACK went down exactly this path.

I've commented there rather than claiming it closed here: one occurrence, no repro, so the honest statement is the mechanism, not a guarantee.

Verification

Three tests pin the routing decision, including the two ways a fix here could be wrong:

  • a co-hosted sender resolves and delivers (not merely "an entry exists");
  • a sender this silo does not host has no local route, so the stream stays its transport — the fix must not pretend otherwise or the NACK is dropped;
  • a deregistered hub stops resolving, or this would trade a silent stream drop for a silent local one.

MeshWeaver.Hosting.Orleans.Test 163/163. -c Release -warnaserror clean for MeshWeaver.Connection.Orleans, MeshWeaver.Hosting.Orleans and the test project.

Fixes #1486

🤖 Generated with Claude Code

https://claude.ai/code/session_01KmBFaHTqhy7h742wkAaJKj

RoutingGrain.PostFailure returned the DeliveryFailure by publishing to the
sender's Orleans stream, unconditionally. Every OTHER same-process delivery to a
co-hosted hub short-circuits on OrleansRoutingService's local route table and
never touches a stream — so the failure leg was the WEAKEST inbound path in the
system: a hub whose stream subscription was never attached, or was attached and
then lost (SubscribeWhenStreamingReadyAsync can give up while the local route
stays live), is reachable for forward traffic and unreachable for NACKs.

And it fails silently by construction: a publish to a memory stream with NO live
subscriber SUCCEEDS. Nothing faults, the ContinueWith never sees IsFaulted, and
the NACK is simply gone — so the requester waits forever for an answer the router
believes it sent. That is the [STALE-CALLBACK] shape.

PostFailure now asks the routing service for the sender's LOCAL route first and
delivers through it when this silo hosts the sender — the same step-1
short-circuit the forward leg takes, so the answer travels the way the question
did. Only a sender on another silo still needs the stream, and that path's trace
line is renamed FAILURE_DELIVER_OK_UNCONFIRMED because "the publish did not
fault" is all it can honestly claim.

🚨 Not closed by adding a bound. The issue is explicit that a timeout here would
convert a silent hang into a logged one without ever delivering the NACK, so the
fix is the routing decision, not a deadline.

This also settles the issue's third question — SubscribeWhenStreamingReadyAsync
giving up while the local route stays live is "two views of reachability that can
disagree, with nothing reconciling them". For a co-hosted hub they no longer
disagree, because the NACK now consults the same view the forward path does.

Its SECOND question is deliberately left open: for a genuinely remote sender, a
publish with no subscriber still cannot distinguish delivered from discarded.
That needs a stream-level answer this change does not have, and it is now the
only remaining case rather than the majority one.

#1563 is the caller's side of the same defect — a ContentRoute GetDataRequest to
an unreachable hub waiting out its full 60 s instead of taking the prompt
NotFound. Its sender is the portal's own hub, i.e. co-hosted with the router, so
its NACK went down exactly this path. Commented there rather than claimed closed
here: one occurrence, no repro, so the honest statement is the mechanism, not a
guarantee.

Verified: 3 new tests pin the routing decision (a co-hosted sender resolves and
DELIVERS; a sender this silo does not host has no local route, so the stream
stays its transport; a deregistered hub stops resolving, or the fix would trade a
silent stream drop for a silent local one). MeshWeaver.Hosting.Orleans.Test
163/163. -c Release -warnaserror clean for MeshWeaver.Connection.Orleans,
MeshWeaver.Hosting.Orleans and the test project.

Fixes #1486

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmBFaHTqhy7h742wkAaJKj

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

This PR fixes a routing asymmetry in the Orleans router where RoutingGrain.PostFailure (NACK/DeliveryFailure) always published to an Orleans stream, even when the sender hub is co-hosted and forward traffic uses the local-route short-circuit. The change makes failure responses follow the same local route as the original forward message when possible, avoiding silent NACK loss when stream subscriptions are missing.

Changes:

  • Route failure deliveries via OrleansRoutingService local route table first (co-hosted sender case), falling back to stream publish only when no local route exists.
  • Expose OrleansRoutingService.TryGetLocalRoute(...) so the failure leg can reuse the forward-leg’s local-route decision.
  • Add focused tests pinning the local-route vs stream decision, plus a WhatsNew entry describing the user-visible improvement.

Reviewed changes

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

File Description
src/MeshWeaver.Hosting.Orleans/RoutingGrain.cs Uses a local-route lookup to deliver DeliveryFailure to co-hosted senders, avoiding stream-only NACK delivery.
src/MeshWeaver.Connection.Orleans/OrleansRoutingService.cs Adds TryGetLocalRoute accessor to expose the existing local-route table to the failure leg.
test/MeshWeaver.Hosting.Orleans.Test/RoutingFailureTakesTheLocalRouteTest.cs New tests intended to verify local-route resolution and non-resolution behavior for senders.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-14-faster-failure-answers.md Documents the faster/more reliable failure-answer behavior as a user-facing fix.

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

Comment thread test/MeshWeaver.Hosting.Orleans.Test/RoutingFailureTakesTheLocalRouteTest.cs Outdated
@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

731 tests  +3   731 ✅ +3   11m 33s ⏱️ +11s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit c6bfb81. ± Comparison against base commit 8bfe80c.

♻️ 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 939 tests  +3   1 929 ✅ +3   7m 27s ⏱️ + 1m 6s
   11 suites ±0      10 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit c6bfb81. ± Comparison against base commit 8bfe80c.

♻️ 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 925 tests  ±0   1 733 ✅ ±0   5m 57s ⏱️ -17s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit c6bfb81. ± Comparison against base commit 8bfe80c.

♻️ 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   5m 45s ⏱️ -20s
1 843 tests +7  1 740 ✅ +7  103 💤 ±0  0 ❌ ±0 
2 193 runs  +8  2 090 ✅ +8  103 💤 ±0  0 ❌ ±0 

Results for commit c6bfb81. ± Comparison against base commit 8bfe80c.

♻️ 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  ±0   1 464 ✅ ±0   6m 21s ⏱️ -27s
   11 suites ±0       0 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit c6bfb81. ± Comparison against base commit 8bfe80c.

♻️ 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 565 tests  ±0   2 564 ✅ ±0   8m 28s ⏱️ -16s
   12 suites ±0       1 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit c6bfb81. ± Comparison against base commit 8bfe80c.

♻️ 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  ± 0      66 suites  ±0   45m 33s ⏱️ -3s
10 467 tests +13  10 161 ✅ +13  306 💤 ±0  0 ❌ ±0 
10 817 runs  +14  10 511 ✅ +14  306 💤 ±0  0 ❌ ±0 

Results for commit c6bfb81. ± Comparison against base commit 8bfe80c.

♻️ This comment has been updated with latest results.

Copilot is right: the counter was bumped in the delegate body, so it read 1 for a
route whose returned observable was never subscribed — the assertion "resolving the
route must actually deliver" would have passed without the NACK ever being handed
over. Deferring the increment makes it mean what it says, and matches how PostFailure
actually calls it: Invoke(...).Subscribe(...).

Added the negative half in the same test: after Invoke and before Subscribe the count
must still be 0, which is what proves the counter now tracks the subscription rather
than the lookup.

3/3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmBFaHTqhy7h742wkAaJKj
@rbuergi
rbuergi merged commit 1aafcec into main Aug 14, 2026
22 checks passed
@rbuergi
rbuergi deleted the fix/1486-nack-takes-the-local-route 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