Durable Streams Are Mesh Nodes
Read first: Orleans Stream Pub-Sub Durability (the defect and why a durable
PubSubStoredid not close it) and Pod-Hub Delivery (the transport swap that made the stream a fallback). This page is the design those two pages end on. Maintainer direction, 2026-08-27, recorded on #2320 / #2322: "we can essentially use mesh nodes for durable streams" — do not evaluateMicrosoft.Orleans.Streaming.AdoNet.
Status: design, with the first slices landed. Everything under Where it stands names the PR that shipped it or the issue it is waiting on.
The one idea
"Use mesh nodes for durable streams" is not one replacement — it is three different answers, because the memory stream carries three different kinds of traffic, and durable means something different for each:
| traffic | what "durable" must mean | the node-backed answer |
|---|---|---|
data-synchronization frames (patches, DataChangedEvent) |
a lost frame is recovered, never replayed | already the node — MeshNode.Version + the BasedOnVersion resync; nothing to build |
request / response (SubscribeRequest, GetDataRequest, a NACK) |
the requester learns fast that the target is not live | not durability at all — a transient NACK within milliseconds, so the caller's own recovery runs |
cross-silo change notifications (IMeshChangeFeed) |
every process sees every commit, or reconciles on start | the storage layer's own change feed — PG LISTEN/NOTIFY, which is already running on every pod |
| at-least-once work items (webhooks, platform builds, payments, inbound mail) | survives a restart, consumed exactly once in effect | the _Inbox node pattern — already shipped, three consumers in production |
A durable stream provider would have bought the first two rows a property they do not need and left the third and fourth exactly where they are. That is why the provider decision went the other way, and why the memory stream can be retired without a successor of the same shape.
What the memory stream still carries — measured on main, not remembered
Registered once, silo.AddMemoryStreams(StreamProviders.Memory) in
OrleansServerRegistryExtensions.cs. Every remaining user, from a grep of GetStreamProvider /
GetStream< across src/:
| user | what rides the stream | who consumes it | if a frame is lost |
|---|---|---|---|
RoutingGrain.BuildPodHubRoute → FallBackToStream |
a delivery to a stream-routed address (portal, client, cache, mesh, import) only after the directed IPodHubGrain.Deliver threw PodHubNotHereException |
the owner's SubscribeWhenStreamingReadyAsync subscription |
the requester waits out its budget (#2320, #2322, #2406) |
RoutingGrain.PostFailure → PublishFailureOverStream |
a DeliveryFailure to a stream-routed sender, when the directed NACK failed for any reason |
same | the sender waits out its budget |
OrleansMeshChangeFeed.BroadcastAsync |
every MeshChangeEvent (Created / Updated / Deleted), one stream per kind |
PathCacheInvalidatorGrain on every other silo → InProcessMeshChangeFeed.PublishLocal |
permanent, silent, per-node staleness on every other silo — the path-resolution cache, the remote-stream cache, NodeTypeRebindWatcher, EventSubscriptionRunner, AccessGrantNotifier, SyncedQueryMeshNodes all miss it for the life of the process |
RootMeshHubReplyStreamService |
the mesh/{id} root hub's subscription (not a publisher) |
the root hub | cross-silo replies to the root hub — served by the directed call since the swap; the stream is its fallback |
Two facts that change the shape of the design, both verified from source:
- No production process hosts hubs as an Orleans client.
UseOrleansMeshClienthas exactly two callers, both test fixtures (OrleansMeshTestBase,OrleansDocumentationTest).Memex.Portal.Distributedis a co-hosted silo; the monolith,LocalMeshand the bake host run no Orleans at all. So "a client cannot host a grain, therefore it keeps the stream permanently" — the standing justification for the fallback — describes the test rig, not the fleet. - The database's own cross-process feed is live in production.
AddPartitionedPostgreSqlPersistenceregistersPostgreSqlChangeListenerand theIHostedServicethat opens itsLISTEN mesh_node_changessession (PostgreSqlExtensions.cs, pinned byChangeListenerWiringTestssince #1814/#1816). Every commit on anymesh_nodestable firesnotify_mesh_node_changes(), which already de-duplicates no-op updates. The feed carries{path, op}and surfaces asIStorageAdapter.Changes(DataChangeNotification,Entity = null). Several code comments still say this session "is not started in the partitioned wiring"; they date from before #1816 and are wrong — see Stale claims below.
1 · Data synchronization — already node-backed, nothing to build
A SynchronizationStream mirror does not need the transport to be lossless: every patch carries
BasedOnVersion, a gap is detected on arrival, and the mirror re-requests from the node — whose
Version is the durable, monotonic revision counter. That IS the durable stream, and it is why
the earlier "frame loss" storms (#1384, #2641) were resync storms rather than data loss.
What the transport owes this row is only honest classification: a fault that is a lifecycle
transition (a silo departing, a grain-directory handover, a container disposing) must reach the
mirror as ErrorType.ShuttingDown, which the resubscribe latch rides out — never as a terminal
Failed, which tears the mirror down. #2518 (directory instability), #2647 (scope teardown) and
#2645 (attach retry) are that work. Kept, unchanged.
2 · Request / response — retire the stream from the routing leg
A request whose target is not live should fail in milliseconds, transiently. The stream fallback does the opposite twice over: a publish into a stream with no live subscriber succeeds and discards (the subscriber probe narrows this but fails open by design), and a publish into a stream whose queue grain is wedged or whose producer never registered stalls for 30–60 s (#2322, #2320, #2406 — all three are Orleans-internal, and there is no MeshWeaver line to change on that path). Durability cannot fix a request that should not have been queued.
The design is the roll plan's release N+2, made precise:
PodHubNotHereafter the directed call is answered with a transient NACK, not a publish.RoutingGrain.BuildPodHubRoute'sFallBackToStream()arm becomesPostFailure(…, ErrorType.ShuttingDown, TargetUnserved: true)— the same verdict the subscriber probe already produces for "no silo serves this hub" (#1742), now reached in one hop.SynchronizationStreamandMeshNodeStreamCacheride it out; a requester gets its answer inside the directed call's own budget instead of the stream's.- The owner's claim gets a DERIVED lifetime.
OrleansRoutingService.AttachPodHubused to make six attempts over ≈3 s and then give up atDebug— after which a silo-hosted hub kept the stream forever, invisibly (the only signal was the router-side fallback line). The claim now retries with its capped backoff until one of two real terminals: the hub's registration is disposed, orIHostApplicationLifetime.ApplicationStoppingfires (the gateGrainWhileRunningalready expresses). Once the initial budget is exhausted on a process that can host grains the line isWarningnaming the hub — abnormal, and the fleet has no clients for which it would be noise. This is the #2426 rule applied to the claim: no cleanup message a restarting process would never send, only lifetimes derived from the hub and the host.- A third terminal, and it is derived too: IMPOSSIBILITY. A process that cannot host a grain
can never win the claim —
PodHubGrainis[PreferLocalPlacement], so from a cluster client the activation lands on some silo with no local route and answersfalse, for ever. There the initial budget is the end and the give-up stays atDebug, because that is the expected permanent outcome. Retrying it would not be a lifetime, it would be a poll that cannot converge — and a measurable one: every attempt makes the silo log[POD-HUB] Attach … landed on a silo that has no local routeatInformation, i.e. one line per hub per backoff interval, which is the storm shape #2426/#2546 exist to remove. The discriminator is Orleans' own:ILocalSiloDetailsis registered byDefaultSiloServicesand by nothing else.
- A third terminal, and it is derived too: IMPOSSIBILITY. A process that cannot host a grain
can never win the claim —
- The stream stays only for CLIENT-hosted address types, by declaration. The fallback is gated
on the address type being declared client-hosted (
MeshBuilder.AddClientHostedAddressType), never on the grain answering "not here". In production no address type is declared client-hosted, so the router never publishes. The Orleans test rig declares all four built-in stream-routed types — it hosts a hub of each on its cluster client (client/{id}fromGetClient, the client host's own rootmesh/{guid},portal/{guid}in the documentation/graph/markdown tests, and the client'scachehub) — and keeps its stream until the rig hosts its hubs on a silo, at which pointAddMemoryStreamsandPubSubStorego with it. - The verdict is
ShuttingDown+TargetUnserved, and the owner-side eviction had to be re-gated to see it.DataExtensions.HandleTargetUnservedFailure— the #2426/#2546 fix that stops an owner fanning changes out to a dead subscriber forever — requiredTargetUnserved && ErrorType == NotFound. That second test was redundant belt-and-braces from the era when the only producer of the stamp was the stream leg's subscriber probe; left in place it would have made the new one-hop verdict inert, silently re-opening the leak for every dead circuit in the fleet. The gate is now the STAMP alone, which is whatDeliveryFailure.TargetUnserved's own contract always said ("only the router … may stamp this"). The two facts are complementary rather than contradictory: the subscriber ridesShuttingDownout and re-asks, while the owner drops the server-side half it can no longer push to. StreamMessageSizeGuard(#1890) needs no code change, and did not get one. The guard exists to turn a silent drop into a loud, NACK'd refusal: an oversized payload on the memory stream succeeds at the publish and dies insidePersistentStreamPullingAgent's non-convergent retry loop, naming only a queue id. On the directed call the wall is Orleans'MaxMessageBodySizeand crossing it throws, whichBuildPodHubRoute'sTerminalCallFailurealready turns into a classifiedDeliveryFailurenaming the address, the delivery id and the sender — the outcome the guard exists to produce. Retargeting the constant itself (plumbingSiloMessagingOptionsintoRoutingGrainplus its own refusal shape) is a separate change and is not required for correctness here; the guard stays onPostToStream, which after this slice is reachable in production for nothing at all.
The N+2 gate, and why these two slices shipped without waiting for it
The roll plan's gate was: a full rolling deploy with none of [ROUTE] Pod-hub grain for {Address} is not attached — falling back to the stream publish in Loki. That gate measured a
risk that the two slices above jointly remove, so it was satisfied by construction rather than
by observation:
- The gate's worry is the window "the owner exists, but its claim has not landed" — in which
removing the fallback would drop a delivery to a live hub. Slice 2 does not drop it: it
ANSWERS it, with a transient NACK inside the directed call's own budget. Every consumer on
that path already has recovery machinery armed for exactly this verdict
(
SynchronizationStream's resubscribe latch,MeshNodeStreamCache.IsTransientOwnerFailure,MeshNodeStreamExtensions' paced retry), which is why the verdict isShuttingDownand notNotFound. The pre-change alternative was strictly worse for the same window: a publish that succeeds and discards when nobody is subscribed, or stalls 30–60 s on a wedged queue grain — the failure with no signal at all. - Slice 1 closes the window rather than surviving it. The claim now retries until it lands, so "owner exists, claim not landed" resolves by retry instead of persisting for the life of the process. Before slice 1 it could persist forever, which is precisely why the gate was needed.
- And the gate's own instrument was unreliable in the direction that matters: the line it counts
is
Information, emitted per delivery, and its absence was never proof (the page said so). AWarningnaming the hub, emitted once per claim that has not landed, is a strictly better instrument — and it is what slice 1 adds. The fallback line survives, but after slice 2 only a DECLARED client-hosted type can reach it, so in the fleet it cannot be emitted at all.
Residual risk, stated plainly: an address type that is genuinely client-hosted in some deployment
and was never declared would go from "works over the stream" to "NACK'd transiently". No such
deployment exists — UseOrleansMeshClient has only test-fixture callers — and the symptom would
be loud (a windowed Warning naming the address) rather than silent.
3 · Cross-silo change notifications — the storage layer's feed is the durable channel
This is the only memory-stream user whose loss is permanent, and it is also the one with a ready-made durable substitute that the platform already operates, monitors and backs up.
Today there are two parallel cross-process channels for the same commit:
write ──commit──► pg_notify('mesh_node_changes', {path, op}) ──LISTEN──► IStorageAdapter.Changes ─► synced queries re-run,
MeshDataSource reconcile re-reads
└─post-commit─► IMeshChangeFeed.Publish(MeshChangeEvent) ─local Subject─► consumers
└─► Orleans memory stream "mesh-{kind}" ─► PathCacheInvalidatorGrain (other silos) ─► PublishLocal
The design collapses the second cross-process leg onto the first:
- The NOTIFY payload carries
nodeTypeandversionbesidepathandop— the trigger already hasNEW.node_type/NEW.versionin hand.DataChangeNotificationgainsNodeTypeandVersionas optional members (additive; every existing producer passesnull). Core lands first, the trigger change in the PostgreSql adapter second; the relay below tolerates a payload without them by re-reading the node before any consumer that filters on type sees the event. - A relay, not a grain. A mesh-scoped singleton
StorageChangeFeedRelaysubscribesIStorageAdapter.Changesand relays each notification intoInProcessMeshChangeFeed.PublishLocal(MeshChangeEvent). It replacesOrleansMeshChangeFeed's broadcast queue andPathCacheInvalidatorGrainentirely;IMeshChangeFeedisInProcessMeshChangeFeedon every host, and a monolith orLocalMeshprocess that shares a database with another process gains cross-process invalidation it never had. - Own-write echoes are already tolerated. Every consumer of
IMeshChangeFeedis an invalidation or aPending → Fired-gated continuation, and on the publishing silo they already receive each own write twice — once fromPublish, once relayed back by that silo's ownPathCacheInvalidatorGrain. The relay changes the second delivery's transport, not its existence. - Loss semantics are strictly better. A NOTIFY is missed only inside the listener's own
reconnect window (a 5 s retry loop, logged at
Error), which is the window the synced queries already accept and the reconcile-on-start pattern in Event Subscriptions already covers. There is no rendezvous grain to time out, no queue grain to lose its RAM, and no membership handover in the path — the three mechanisms behind #2320, #2322 and #2406. - Backend-agnostic by construction. Cosmos and Snowflake already feed
ChangeswithEntity = null; the in-memory and file-system adapters feed it in-process. The relay does not know which one it is on.
4 · At-least-once work items — the _Inbox node is the durable stream
Where a message genuinely must outlive the process that will handle it, the node-backed stream already exists and is documented: Webhook Inbox. Its contract, restated here because it is exactly the contract a durable stream needs:
- an entry is a write-once node at
{target}/_Inbox/{id}— its own existence is its lifetime; - the consumer is a live children query taking
Initial | Added | Reset, processed withConcat(one at a time) —Initialis the replay-on-start leg, so an entry written while the consumer was down is delivered when it comes up; - the ack is a delete, under system identity, on every outcome including "unverifiable" and "irrelevant" — a poison entry is dropped, never looped; every action is idempotent because replay is the normal case, not the exception;
- the consumer lives on an always-on hub. A drain armed on an on-demand per-instance hub runs
only while somebody happens to be looking at that node — Plugins#777 is precisely that defect
(
Hosting/Deployment's inbox watcher stopped consuming platform-build announcements until a Deployment page was opened). A fleet-wide consumer belongs beside a hub warmer or in a host-level hosted service, and wants the positive liveness signal "the inbox is non-empty and ageing".
The derived-lifetime rule, applied
#2426 found a server-side subscription that only an explicit UnsubscribeRequest could dispose —
immortal by construction, because a portal that restarts never sends one. Every row above is
checked against it:
| row | the lifetime, and what derives it |
|---|---|
| data sync | the mirror's subscription; ended by the owner's TargetUnserved verdict (#2620) or the subscriber's own disposal |
| request / response | the pod-hub claim; ended by hub disposal or ApplicationStopping — never by a message |
| change notifications | the LISTEN session; owned by the process, ended by host stop |
_Inbox entries |
the entry's own existence; ended by the consumer's delete |
Where it stands, and the order of work
| slice | state | note |
|---|---|---|
| classification of lifecycle faults as transient | landed — #2518, #2645, #2647 | row 1 needs nothing else |
| this design | this page | records the direction on #1742, #2320, #2322, #2406 |
| stale "listener never started" comments corrected | landed with this page | see below |
pod-hub claim: indefinite, derived lifetime, Warning where grains can be hosted |
landed — #2745 | closed the #1742 residual "a claim that fails to land degrades silently"; core only |
routing: transient NACK on PodHubNotHere; fallback gated on declared client-hosted types |
landed — #2745 | closed #2320, #2322, #2406 as made unreachable. Shipped with slice 1 rather than after a clean roll — see The N+2 gate above for why the two together satisfy it by construction |
owner-side eviction re-gated on the TargetUnserved STAMP alone |
landed with the slice above | required by it: gating on NotFound would have made the new verdict inert and re-opened #2426/#2546 |
DataChangeNotification.NodeType/Version + StorageChangeFeedRelay |
next | core first; the relay must tolerate a payload without the new fields |
notify_mesh_node_changes() emits nodeType, version |
after the core slice | PostgreSql adapter (MeshWeaver.Plugins); a schema-initializer revision, re-applied by the existing DROP-then-CREATE |
delete OrleansMeshChangeFeed broadcast + PathCacheInvalidatorGrain |
after both | the memory stream then carries routing fallback only |
StreamMessageSizeGuard retarget onto MaxMessageBodySize |
optional, not blocking | the directed call already THROWS at that wall, which is the outcome the guard produces — see the bullet above |
test rig hosts hubs on a silo → AddMemoryStreams deleted |
last | the only remaining user |
How to check a live cluster
# 🚨 Must be EMPTY, always, not merely after a roll: only an address type DECLARED client-hosted
# can reach this line, and production declares none. A hit means somebody added a declaration.
{namespace="memex-cloud"} |= "falling back to the stream publish"
# THE instrument now — a hub whose claim has not landed, once per claim, naming the address.
# The claim keeps retrying, so a matching "landed after its initial budget was exhausted" line
# for the same address is the resolution; one without it is a hub still on no transport at all.
{namespace="memex-cloud"} |= "Pod-hub claim for" |= "did not land"
# the transient NACK that replaced the publish — windowed to one Warning per address per 60 s
{namespace="memex-cloud"} |= "was refused: no silo in this cluster is currently serving that hub"
# the database feed is up on every pod — one line per pod per LISTEN (re)connect
{namespace="memex-cloud"} |= "PostgreSQL LISTEN started on mesh_node_changes"
# the stream-provider failure family this design retires
{namespace="memex-cloud"} |~ "RegisterAsStreamProducer failed|memorystreamqueue.*Enqueue"
Stale claims this page corrects
Code comments and doc pages in core (BuildCoordinationExtensions, BuildNodeType,
BuildProtocolDriver, RegistryUpdateReconciler, Build Coordination,
Plugin Update on Green Build) stated that
PostgreSqlChangeListener is "registered and never started in either partitioned-PG overload";
the PostgreSql adapter's PostgreSqlPathRoutingAdapter (in MeshWeaver.Plugins) still says "the
pg_notify LISTEN fallback is disabled for partitioned PG". That was #1440 as filed on 2026-08-13;
it was the middle leg of the #1814 outage and was fixed by #1816 — the partitioned overload now
registers the hosted service that opens the session, and ChangeListenerWiringTests fails the
build if it ever stops. The conclusions those comments draw (read the durable witness, never wait
to be told — BuildProtocolDriver, BuildNodeType.ArbitrateDurably, ObserveBuildGo,
RegistryUpdateReconciler) remain correct for a different reason: a NOTIFY is delivered to a live
LISTEN session on the same database and is never replayed, so a mirror that activates after the
write — or a deployment on another database — is still not told. Every core occurrence is corrected
with this page; the adapter's one is corrected in its own repo.
Related
- Orleans Stream Pub-Sub Durability — the defect,
the durable
PubSubStore, and the two residuals this page answers - Pod-Hub Delivery — the transport swap and the N+2 gate
- Event Subscriptions — the live + reconcile-on-start pattern
- Webhook Inbox — the
_Inboxnode contract - Error Propagation & Wedges — "an undeliverable
delivery must surface as a
DeliveryFailure, never as silence" - Issues: #1742, #2320, #2322, #2406, #2426, Plugins#777