Fix idle-page crash: don't poison the read cache with transient grain-reactivation rejects#470
Open
rbuergi wants to merge 1 commit into
Open
Fix idle-page crash: don't poison the read cache with transient grain-reactivation rejects#470rbuergi wants to merge 1 commit into
rbuergi wants to merge 1 commit into
Conversation
… rejects
When Orleans idle-collects a per-node MessageHubGrain, the next read's
SubscribeRequest can hit the mid-DeactivateOnIdle activation. Orleans forwards
it up to MaxForwardCount (=2) times and then transiently rejects it:
Delivery to 'X' failed: Forwarding failed: tried to forward message
Request [...]->[messagehub/X] IMessageHubGrain.DeliverMessage(...)
#...[ForwardCount=2] for 2 times after "DeactivateOnIdle was called."
to invalid activation. Rejecting now.
or, if reactivation is slow, the 60s hub-request timeout:
No response received in hub cache/... within 00:01:00 for request
SubscribeRequest (...) → target X. The request may have been
undeliverable or the target hub was not found.
Root cause: MeshNodeStreamCache's READ-path hydration bookkeeping recorded
ANY upstream error into the storm-breaker negative cache
(`ex => RecordNegative(p, ex)`), unconditionally. A transient reactivation
reject was therefore cached exactly like a genuinely-missing node: GetStreamRaw
then replayed that raw Orleans reject to every reader for the backoff window
(2s, growing) AND refused to re-probe the grain that had already reactivated.
That is precisely why navigating to a just-idle page intermittently crashed and
a manual reload "fixed" it — the reload merely outlasted the negative window so
the re-probe landed on the fresh activation.
The WRITE path already guarded RecordNegative with IsMissingNodeFailure ("only
missing-node failures record, not RLS denial / transient, so a legitimately-
existing node is never falsely suppressed"). The READ path had no such guard —
an asymmetry.
Fix: gate the read-path RecordNegative with the same IsMissingNodeFailure guard,
and make that predicate transient-aware (transient takes precedence) via a new
IsTransientOwnerFailure classifier that mirrors RoutingGrain.IsTransientFailure
and AreaErrorClassifier.IsTransientHubFailure. A transient owner miss (Orleans
reject / "invalid activation" / "Rejecting now" / "Forwarding failed" /
TimeoutException / "target hub was not found" / "undeliverable") is now forwarded
to the current subscriber (whose caller / the area's transient classifier retries)
but NEVER recorded — so the very next read re-probes the reactivated grain
immediately. Genuine missing nodes ("No node found") are still recorded, so the
storm-breaker keeps doing its real job.
Tests:
- MeshNodeStreamCacheNegativeClassifierTest (8, deterministic): pins the exact
transient-vs-missing decision using the verbatim prod message strings — the
forwarding-reject and the 60s timeout classify transient (NOT missing), a real
"No node found" classifies missing, RLS denial is neither.
- OrleansIdleReactivationNoWedgeTest (2): end-to-end reactivation smoke test —
a read right after the owning grain is deactivated transparently reactivates
and returns the node, and an immediate re-read stays healthy (no poisoned
negative window). A single in-memory test silo reactivates too cleanly to
reliably hit the two-hop forwarding-reject window, so this is a smoke test for
the reactivation path, not the before/after pin (that role is the classifier's).
- MissingSatelliteTest (existing) still green: genuine missing-node storm-breaker
recording + fast-fail is preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug (two symptoms users hit on memex.meshweaver.cloud)
When Orleans idle-collects a per-node
MessageHubGrain, navigating to that page intermittently crashes the area, and a manual browser reload "fixes" it. Two error shapes surface:SubscribeRequesthits the mid-DeactivateOnIdleactivation; Orleans forwards it up toMaxForwardCount(=2) times then transiently rejects:Root cause
The retry infrastructure that already exists (
RoutingGrain.DeliverToGrainWithRetryretries the transient Orleans reject; the grain reactivates) is sound and unchanged. The defect is one layer up, inMeshNodeStreamCache.The read-path hydration bookkeeping recorded any upstream error into the storm-breaker negative cache, unconditionally:
So a transient grain-reactivation reject (
OrleansMessageRejectionException→ routingDeliveryFailure{ErrorType.Failed}"Forwarding failed … Rejecting now.", or the 60 sTimeoutException) was cached exactly like a genuinely-missing node.GetStreamRawthen replayed that raw Orleans reject to every reader for the whole backoff window (2 s, growing) and refused to re-probe the grain that had already reactivated. That is precisely why the page crashed and a manual reload "fixed" it — the reload merely outlasted the negative window so the re-probe landed on the fresh activation.The write path already guarded this (only
IsMissingNodeFailurerecords — "not RLS denial / transient, so a legitimately-existing node is never falsely suppressed"). The read path had no such guard — an asymmetry.Orleans internals confirming the class of reject (
Microsoft.Orleans.*10.2.0):TryForwardRequestrejects withRejectionTypes.Transientand the exact "Forwarding failed … for {ForwardCount} times … Rejecting now." message onceMayForward(ForwardCount < MaxForwardCount, default 2) is false.DeactivateOnIdletheDeactivationExceptionis null, so the caller receivesnew OrleansMessageRejectionException(rejectionInfo)— a transient failure, retryable, never a missing node.The fix
Gate the read-path
RecordNegativewith the sameIsMissingNodeFailureguard the write path uses, and make that predicate transient-aware (transient takes precedence) via a newIsTransientOwnerFailureclassifier that mirrorsRoutingGrain.IsTransientFailureandAreaErrorClassifier.IsTransientHubFailure— so all three layers agree on what is worth a retry vs. a suppress.A transient owner miss is forwarded to the current subscriber (whose caller / the area's transient classifier retries) but never recorded, so the very next read re-probes the reactivated grain immediately. Genuine missing nodes ("No node found") are still recorded — the storm-breaker keeps doing its real job.
Tests
MeshNodeStreamCacheNegativeClassifierTest(8, deterministic) — pins the exact transient-vs-missing decision using the verbatim prod message strings: the forwarding-reject and the 60 s timeout classify transient (NOT missing); a real "No node found" classifies missing; RLS denial is neither; the inner-exception chain is walked.OrleansIdleReactivationNoWedgeTest(2) — end-to-end reactivation smoke test: a read right after the owning grain is deactivated transparently reactivates and returns the node, and an immediate re-read stays healthy (no poisoned window).MissingSatelliteTest(existing) still green — genuine missing-node storm-breaker recording + fast-fail is preserved.Local results (Release,
-warnaserror -p:CIRun=truebuilds clean; 0 warnings)Why not the other candidate fixes
DeliverToGrainWithRetry) and correct; it re-activates the grain. The residual crash is the cache poisoning downstream of a successful reactivation.Please review — this is prod concurrency code and I have not merged it. Please gut-check whether the
IsTransientOwnerFailuresubstring set should also cover any other transient banner you've seen in the wild.🤖 Generated with Claude Code