Repository navigation
Description
Both routers guard their NACK path with an answer-once contract that cannot ever match on the routed path, because the payload has already been type-erased to RawJson before it gets there.
The mechanism
MeshBuilder.cs:238 packages every delivery before handing it to the router:
return routes.Hub.ServiceProvider.GetRequiredService<IRoutingService>()
.DeliverMessage(delivery.Package(routes.Hub.JsonSerializerOptions));MessageDelivery.Package replaces the payload with new RawJson(serialized). So at both guard sites, delivery.Message is always RawJson:
RoutingGrain.cs:415—delivery.Message is DeliveryFailure || delivery.Message?.GetType().HasAttribute<CanBeIgnoredAttribute>() == trueOrleansRoutingService.cs:380—MayAnswer(...), same two clauses
Neither clause can be true. Confirmed empirically: the trace reads RouteMessage: NotFound for RawJson → ….
What the guards were for, and therefore what is happening instead
The comments state the contract precisely, and it is the right contract:
[CanBeIgnored]messages (HeartBeatEvent, Shutdown/Dispose) are fire-and-forget: there is NO awaiting Observe callback to fail […] It would be dropped as unhandled at the sender, or — for a permanently-gone owner that is heart-beaten every interval — re-posted forever, which IS the NotFound storm.
Never answer a
DeliveryFailure: the answer is itself aDeliveryFailure, so answering one loops.
Since neither clause fires on the routed path, the router does NACK heartbeats to a departed owner, and can NACK a NACK. The monolith RoutingServiceBase guards (:161, :359) sit before packaging and do work — so the two routers, which the comment says "both agree", disagree in production.
Severity
Not theoretical: the heartbeat cadence is 45 s and SyncStreamOptions.FirstHeartbeat is 5 s, so a permanently-gone owner generates NACK traffic indefinitely, and it does so during teardown — "exactly the worst moment, since the paths that give up are shutdown and dispatch failure, when the volume of control messages is highest and the process has least capacity", per the doc comment on MayAnswer. That is a plausible contributor to several open teardown/storm items (#1470's neighbourhood, the doc-recompile NotFound-storm wedge).
The fix is already precedented in the same method
Package already solves the identity-across-type-erasure problem once, and documents why:
// 🚨 Carry the payload's IDENTITY across the type erasure. From here on the delivery
// is `type=RawJson` and nothing downstream can tell WHAT it is about without parsing
// the payload — which the receiving hub's storm breaker runs far too hot to do […]
return message is IDiagnosticKeyed keyed && keyed.DiagnosticKey is { Length: > 0 } key
? packaged.SetProperty(IDiagnosticKeyed.DeliveryProperty, key)
: packaged;The storm breaker got its identity stamp in #1200. The answer-once guards need the same stamp — the pre-packaging payload type (or a computed mayAnswer flag) written as a delivery property, and both guard sites reading the property rather than delivery.Message.
Prefer stamping the fact over the type name if the guards only ever ask this one question: it keeps the property from becoming a general-purpose type oracle that invites parsing decisions downstream.
Acceptance
- A test that packages a
HeartBeatEventand aDeliveryFailure, routes each to a NotFound target through both the Orleans router and the monolith router, and asserts no NACK is emitted — failing on the pre-fix tree for the Orleans paths and passing for the monolith ones, which is the "both routers agree" claim made executable. - Sweep for any other guard that inspects
delivery.Message's CLR type after the packaging boundary; this defect class is not obviously limited to these two sites.
Found while investigating #1469. Verified by reading MeshBuilder.cs:222-239, MessageDelivery.cs:182-208, RoutingGrain.cs:405-417, OrleansRoutingService.cs:370-382.
Activity
test(#1485): pin that the suppression stamp survives the JSON wire
test(#1485): subscribe and match — drop FirstAsync/ToTask/TaskComplet…
fix(#1485): the suppression diagnostic names what it suppressed, at a…
Merge pull request #1502 from Systemorph/fix/1485-answer-once-across-…
review: never answer an answer — MayAnswer() guards the requester-NAC…
fix: ask the awaited-by-sender question before the overflow answer, n…