Skip to content

Both Orleans routers' answer-once guards are dead code: Package() erases the payload type before they run, so the router NACKs heartbeats and can NACK a NACK #1485

Closed

Description

@rbuergi

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>() == true
  • OrleansRoutingService.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 a DeliveryFailure, 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 HeartBeatEvent and a DeliveryFailure, 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

  1. added 3 commits that reference this issue on Aug 14, 2026
  2. added a commit that references this issue on Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions