Skip to content

fix(#2633): retry a transient stream-attach instead of latching a hub into "cross-process routing DISABLED" - #2645

Merged
rbuergi merged 4 commits into
mainfrom
fix/orleans-stream-reliability
Aug 30, 2026
Merged

rbuergi merged 4 commits into
mainfrom
fix/orleans-stream-reliability

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Closes nothing by itself — linking, not closing: #2633.

The defect

OrleansRoutingService.SubscribeWhenStreamingReadyAsync attaches a hub's Orleans memory-stream subscription once the #1129 readiness gate opens. Everything past that gate is a cluster call: SubscribeAsync resolves the stream's PubSubRendezvousGrain through Orleans' grain directory, which is precisely the component that is unstable while cluster membership changes.

The attach was a single attempt inside a catch-all, so one such rejection latched the hub into

crit: Orleans 'Memory' stream subscription could not be attached for portal/… —
      cross-process routing for this hub is DISABLED (in-process routing remains active)

…for the rest of that hub's life. Nothing re-attempted; the loss persisted until the hub re-registered (a circuit reconnect, or a pod restart). Six per-user losses across three ReplicaSet generations on memex-cloud, every one of them an OrleansMessageRejectionException whose inner text is Orleans' own "Unable to connect to S… , will retry after 582.6889ms".

The same exception class is already classified transient and retried on the delivery leg (RoutingGrain.DeliverToGrainWithRetry → IsTransientFailure). The subscribe leg had no equivalent. That inconsistency is the defect.

The fix

The attach runs through a new AttachWithBoundedRetryAsync:

  • Bounded — 5 re-attempts, backoff 250 ms doubling to a 4 s cap (≈7.75 s total). Comfortably longer than the sub-second reconnect Orleans promises; far short of the 120 s readiness gate that precedes it and the 60 s request budgets above it.
  • Loud — every retry logs a Warning naming the attempt number and the wait. The evidence that a hub was momentarily unattached is not lost, it is just no longer a Critical for a condition that was over in half a second.
  • Still terminal — anything IsTransientFailure does not recognise gives up on the first attempt, and an exhausted budget still ends in the same LogCritical + null + latched disable. A permanent failure still fails.
  • Each retry re-invokes the attach from scratch (fresh GetStream, fresh SubscribeAsync), so Orleans re-resolves the rendezvous grain rather than re-using a reference minted mid-handoff.

No change to the readiness gate itself, to the outbound subscriptionReady gate, or to the delivery handlers.

Tests — StreamAttachTransientRetryTest

Deterministic, no cluster: reaching the stream provider is the observable, and the backoff is collapsed to zero through an instance seam (AttachBackoff, never static state).

test asserts
TransientAttachFailure_IsRetried_UpToTheBudget 6 attempts (1 + SubscribeAttachRetries)
NonTransientAttachFailure_StillGivesUpOnTheFirstAttempt exactly 1 attempt — a permanent failure must still fail
TheFakeIsTheProductionException the driver is OrleansMessageRejectionException and is what IsTransientFailure matches — without this the retry tests could pass against nothing
AttachBackoff_IsBoundedAndCapped the budget is small, growing, capped, and finishes well inside the enclosing budgets

Failing on unfixed code

Run with the retry budget forced to the pre-fix single attempt (maxRetries: 0):

[FAIL] StreamAttachTransientRetryTest.TransientAttachFailure_IsRetried_UpToTheBudget
  Expected value to be 6 because a grain-directory rejection is TRANSIENT … but found 1.
Failed!  - Failed: 1, Passed: 3, Total: 4

With the fix in place:

Passed!  - Failed: 0, Passed: 13, Total: 13

(13 = this file plus OrleansStreamingReadinessGateTest and OrleansDirectoryInstabilityClassificationTest, run together to confirm the #1129 gate's "attaches exactly once" assertion still holds — it does, because its probe throws a non-transient NotSupportedException.)

🤖 Generated with Claude Code

… into "routing DISABLED"

`OrleansRoutingService.SubscribeWhenStreamingReadyAsync` attaches a hub's Orleans
memory-stream subscription once the #1129 readiness gate opens. Everything past that
gate is a CLUSTER call — `SubscribeAsync` resolves the stream's `PubSubRendezvousGrain`
through Orleans' grain directory, which is exactly what is unstable while cluster
membership changes.

The attach was a SINGLE attempt inside a catch-all, so one such rejection latched the
hub into "cross-process routing for this hub is DISABLED" for the rest of its life:
nothing re-attempted, and the loss persisted until the hub re-registered (a circuit
reconnect or a pod restart). Six per-user losses across three ReplicaSet generations on
memex-cloud, every one of them an `OrleansMessageRejectionException` that the DELIVERY
leg already classifies transient and retries (`RoutingGrain.DeliverToGrainWithRetry`).
The inconsistency between the two legs was the whole defect.

The attach now runs through `AttachWithBoundedRetryAsync`: bounded (5 re-attempts,
250 ms doubling to a 4 s cap — ≈7.75 s total, against the ~0.5–0.9 s reconnect Orleans'
own ConnectionManager promises in the message it fails with), loud (a Warning naming the
attempt on every retry), and still terminal — anything `IsTransientFailure` does not
recognise gives up on the first attempt, and an exhausted budget still ends in the same
LogCritical + null.

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

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

This PR addresses issue #2633 by making the Orleans memory-stream subscription attach path resilient to transient grain-directory/connection instability during cluster membership changes, so a single transient rejection no longer permanently disables cross-process routing for a hub.

Changes:

  • Add a bounded, backoff-based retry around the stream subscription attach in OrleansRoutingService.SubscribeWhenStreamingReadyAsync.
  • Expose/extend transient-failure classification and add attach retry policy knobs (retry count + backoff + instance seam).
  • Add a deterministic test suite validating transient vs non-transient attach behavior and the bounded/capped backoff policy.

Reviewed changes

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

File Description
src/MeshWeaver.Connection.Orleans/OrleansRoutingService.cs Adds bounded retry/backoff for stream subscription attach and makes transient failure classification testable.
test/MeshWeaver.Hosting.Orleans.Test/StreamAttachTransientRetryTest.cs Adds unit tests to pin retry budget, non-transient behavior, production exception shape, and backoff bounds.
Suppressed comments (2)

src/MeshWeaver.Connection.Orleans/OrleansRoutingService.cs:1034

  • await Task.Delay(...) adds another async continuation in src/ and can run the retry loop on an arbitrary scheduler/thread, with the same deadlock/identity-loss risks noted elsewhere in this file. If a delay is needed, model it reactively (Observable.Timer) and keep the whole attach sequence inside an IObservable pipeline (or inside IIoPool if it must remain Task-based).
                wait = delay(attempt);
                onTransientRetry(ex, attempt + 1, wait);
            }
            await Task.Delay(wait, ct).ConfigureAwait(false);
        }

src/MeshWeaver.Connection.Orleans/OrleansRoutingService.cs:1077

  • The new bounded-retry attach is awaited directly here, increasing async/await usage in a hub-reachable service. To match the repository’s reactive conventions, consider implementing the attach-with-retry as an IObservable<StreamSubscriptionHandle<...>> (using the existing IIoPool for the Task-based Orleans call) and subscribing/bridging at the boundary, rather than adding another await in the subscription path.
            var handle = await AttachWithBoundedRetryAsync(
                AttachSubscriptionAsync,

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

Comment on lines +1020 to +1023
try
{
return await attach().ConfigureAwait(false);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adopted — d91493a. You are right about the idiom, and there was a strictly better version twenty lines below in this same file.

Two corrections to the reasoning, so the record is accurate about why:

  • The parked-scheduler / lost-AccessContext concern does not apply here. This attach runs detached — RegisterStream starts it and stores the Task; it is never on a hub action block or a grain scheduler (the method's own comment says so, and it is what SubscribeWithRetryAsync NREs because Memory stream provider never initializes #1129 restructured it to be). It also carries no AccessContext: it is subscription setup, not a delivery.
  • This change did not introduce async into src/. SubscribeWhenStreamingReadyAsync was already async Task<StreamSubscriptionHandle<…>?> on main — it awaits the readiness gate and awaits SubscribeAsync — and it has to stay Task-returning, because RegisterStream's teardown awaits that Task to unsubscribe whatever it produced. My edit added an async loop, not the async.

But the loop was still the wrong shape for this codebase, and the right one was already here: AttachPodHub, forty lines down, does Observable.Defer(...) + RetryWhen + Observable.Timer for the pod-hub claim, and RoutingGrain.DeliverToGrainObservable does the same for the delivery leg. The retry is now that shape:

Observable.Defer(() => attach().ToObservable())
    .RetryWhen(errors => errors.Select((ex, i) => (ex, i))
        .SelectMany(t => t.i >= maxRetries || !isTransient(t.ex)
            ? Observable.Throw<long>(t.ex)
            : Observable.Timer(delay(t.i), scheduler)));

That removes the async helper and the Task.Delay entirely, keeps the attach cold (so every re-subscribe re-resolves the grain — the point of the retry), and leaves exactly one bridge, .ToTask(ct), which is also where cancellation lands. IIoPool is not the boundary for this one: the leaf here is Orleans' own SubscribeAsync, invoked from an already-detached task, and routing this through a pool would add a hop without removing a Task.

Semantics are unchanged — same budget, same backoff, same Warning per retry, same terminal give-up — and I re-ran the mutation (maxRetries: 0) to confirm the test still fails with 1 attempt against an expected 6.

rbuergi and others added 2 commits August 29, 2026 08:53
…b in the same file

Review finding on #2645: the bounded retry was written as an async for-loop with
Task.Delay, which adds async/await surface in src/ where this codebase's idiom is
IObservable composition.

The finding is right about idiom even though the parked-scheduler concern does not apply
here (this attach runs detached, never on a hub action block or grain scheduler, and the
enclosing SubscribeWhenStreamingReadyAsync was already async before this change — the
Task is what RegisterStream's teardown awaits to unsubscribe the handle).

There is a strictly better version and it is twenty lines above in the same file:
AttachPodHub's Defer + RetryWhen + Observable.Timer, which is also
RoutingGrain.DeliverToGrainObservable's shape. Adopting it removes the async helper and
the Task.Delay loop, keeps the attach COLD so every re-subscribe re-resolves the grain,
and leaves exactly one bridge — ToTask(ct) — where cancellation lands.

Semantics are unchanged: same budget, same backoff, same loud Warning per retry, same
terminal give-up. Re-verified by the same mutation (maxRetries: 0 → the transient test
fails with 1 attempt against an expected 6).

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

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

494 tests  +4   490 ✅ +4   4m 58s ⏱️ +17s
  6 suites ±0     4 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit d180e17. ± Comparison against base commit ecef6db.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

907 tests  ±0   907 ✅ ±0   5m 31s ⏱️ -1s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit d180e17. ± Comparison against base commit ecef6db.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

957 tests  ±0   957 ✅ ±0   5m 21s ⏱️ +12s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit d180e17. ± Comparison against base commit ecef6db.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 585 tests  ±0   1 584 ✅ ±0   6m 36s ⏱️ -28s
    8 suites ±0       1 💤 ±0 
    8 files   ±0       0 ❌ ±0 

Results for commit d180e17. ± Comparison against base commit ecef6db.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

   10 files  ±0     10 suites  ±0   5m 42s ⏱️ -13s
3 773 tests ±0  3 581 ✅ ±0  192 💤 ±0  0 ❌ ±0 
4 405 runs  ±0  4 213 ✅ ±0  192 💤 ±0  0 ❌ ±0 

Results for commit d180e17. ± Comparison against base commit ecef6db.

@github-actions

github-actions Bot commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 020 tests  ±0   1 020 ✅ ±0   6m 17s ⏱️ -8s
    6 suites ±0       0 💤 ±0 
    6 files   ±0       0 ❌ ±0 

Results for commit d180e17. ± Comparison against base commit ecef6db.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   42 files  ±0     42 suites  ±0   34m 28s ⏱️ -21s
8 736 tests +4  8 539 ✅ +4  197 💤 ±0  0 ❌ ±0 
9 368 runs  +4  9 171 ✅ +4  197 💤 ±0  0 ❌ ±0 

Results for commit d180e17. ± Comparison against base commit ecef6db.

♻️ This comment has been updated with latest results.

@rbuergi

rbuergi commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

The red is not reachable from this diff — closure analysis rather than a re-run:

  • Failing test: MeshWeaver.Hosting.Monolith.Test.PandasExplorerLayoutAreaTest.Explorer_NoPythonNode_RendersToolbarAndDegradesGridWithoutHanging (1 fail / 8,736 tests).
  • This diff touches three files, and the only production one is
    src/MeshWeaver.Connection.Orleans/OrleansRoutingService.cs.
  • MeshWeaver.Hosting.Monolith.Test's transitive project closure is 35 projects, and
    MeshWeaver.Connection.Orleans is not one of them.
    The changed assembly cannot load in the
    process under test, so no behaviour of it can be observed by that test.

An experiment is void when the fix cannot load in the process under test; the same reasoning
applies in reverse to a red. This is Monolith hosting — the non-Orleans path.

Re-running to see it go green would be a re-roll, not evidence, so I am recording the closure
instead. Worth noting the test name ends WithoutHanging: it is timing-shaped, which puts it in
the family core#2700 addressed and #2708 just landed TestTimeouts for.

@rbuergi
rbuergi merged commit 67b4985 into main Aug 30, 2026
34 of 36 checks passed
rbuergi added a commit that referenced this pull request Aug 30, 2026
… the first (#2654 owns the residual) (#2744)

AcceptsStringSplice — the one negotiated capability of the owner→subscriber fan-out
(#1284/#1414) — was set at exactly ONE of the three sites that post a SubscribeRequest:
the initial subscribe. Resubscribe (owner recycle / StreamEndedEvent) and
SynchronizationStream.RequestFreshSnapshot (the frame-loss resync) both omitted it.

When the owner cannot match a re-subscribe to a live server-side stream it BUILDS a fresh
one and reads the capability off THAT request — which is exactly what an eviction on the
router's TargetUnserved verdict (#2620), an owner recycle or a torn-down sync hub produces.
From that cycle on, the owner fans out whole-string `replace` frames to a mirror that
understands splices: the per-subscriber quadratic #1284/#1414 removed, reinstated silently
and permanently, on precisely the paths that fire during a resync storm. Nothing fails — a
replace is a shape every subscriber understands — so no assertion on the FRAMES could catch
it; the only place it is visible is the request.

Fixed at the root, not with a third literal: JsonSynchronizationStream.MintSubscribeRequest
is now the one place a C# subscriber mints a SubscribeRequest, because a capability is a
property of the applier this assembly ships, not of the moment one post was written. Nothing
else changes — the resync still posts with no identity and the same Subscriber, the recycle
re-ask still with SystemUserId.

ResubscribeKeepsNegotiatedCapabilitiesTest drives the real resync (the deterministic
one-frame wire loss StreamFrameLossResyncTest already uses) and asserts every posted
SubscribeRequest declares the capability. Verified RED on the unfixed tree, GREEN with the
fix; MeshWeaver.Data.Test 394/394.

Docs: DataSyncAndCrdt §6 now documents the BasedOnVersion chain and states how to read
`[SYNC_STREAM] Frame loss detected` — a RESYNC counter, not a data-loss counter. The raw
total diagnoses nothing; the per-stream count and whether a Full follows do, and the driver
is almost always upstream (a latched stream attach #2633/#2692 → #2645, the #2620 eviction
cycle, an owner recycle). That is the recorded disposition of #2641's 847-lines-in-30-min.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi deleted the fix/orleans-stream-reliability branch October 10, 2026 13:10
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