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 |
intended: PathCacheInvalidatorGrain → process-local cache invalidation; actual default composition selects the local feed before the wrapper, and the grain key would activate once per cluster rather than once per silo |
permanent, silent, per-node staleness on replicas that never receive it — the path-resolution cache, the remote-stream cache, NodeTypeRebindWatcher, activation-failure registry and 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, node_type}and surfaces asIStorageAdapter.Changes(DataChangeNotification, with an identifier-only descriptor). 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.
Before the storage relay there were two parallel cross-process channels for the same commit:
write ──commit──► pg_notify('mesh_node_changes', {path, op, node_type}) ──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 first core slice collapses the cache-invalidation leg onto the storage feed while retaining the Orleans broadcast during the additive rollout:
The notification contract carries
NodeTypeandVersion. The PostgreSQL trigger already sendsnode_typebesidepathandop, and hasNEW.versionin hand.DataChangeNotificationgainsNodeTypeandVersionas optional members. Typed in-process producers derive the hints from their entity immediately; older and path-only backend payloads leave them null. 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. That compatibility read has a five-second bound. An error or silence still emits a path-only version-zero invalidation, so one backend fault cannot wedge the relay or leave the replica's exact-path cache untouched. A read that finds NO row emits nothing: the row is gone, the delete that removed it is self-contained and was relayed on arrival — and aCreated/Updatedwith no node and no version AFTER thatDeletedwould read as a retype to "(none)" toNodeTypeRebindWatcherand recycle a hub the delete is already tearing down. Same rule as the per-node hub's own reconcile.🚨 The compatibility read is coalesced per path, through the ONE coalescer (
ReReadCoalescinginMeshWeaver.Mesh.Contract: a 50 msThrottlethat always emits the LAST trigger of a burst, thenConcatso reads on a path are serialised) — the same operator and the same window the per-node hub's own reconcile inMeshDataSourcehas used since #1440. The relay first shipped reading ONCE PER NOTIFICATION, ahead of any coalescer, and the plugins suite's read-storm guard (CrossProcessChangeFeedTest.AnEntitylessBurst…, which measures that window by name) went red on the first set carrying it: 200 entity-less notifications on one path cost 801 reads against a query-layer baseline of 600 — 200 relay reads plus the one coalesced reconcile read, 201 where the contract is "a handful" (< 20). That is the #223 shape — a notification storm turned into a read storm — on every replica, for every bulk import under the older notifier (#4139). A second coalescer with a second window is how this comes back with a different number: both callers referenceReReadCoalescing.Window.The newest notification on a path always wins. Every notification on a path joins that path's group; self-contained ones (node, hints, or a delete) are relayed on arrival, and the coalescer fires with the LAST notification of the burst once the path has been quiet for the window — a read is owed only when that last one is not self-contained, so a burst that ends on a hinted update or a delete reads nothing. A read in flight is overtaken by ANY later notification on its path and then says nothing: the later one is either self-contained (already relayed, newer than anything the read could return) or starts its own coalesced read. So a read result is published only while it is the newest information about its path, and a path-only fallback for a read that faulted or timed out can never land after a newer event — which is what kept the remote-stream resubscribe gate (a version-zero event announces
received + 1) from refreshing a healthy stream. A path's group lives (window + read bound) past its last notification: with overtaking at most one read is ever pending per group, it starts at the window and is over by the bound, so a notification during it joins the same group and overtakes it, and one after the group closed finds nothing in flight. Reads on a path never overlap; an idle path holds no state.The PostgreSQL listener today carries a backend-private
ChangedNodeDescriptor(path + node type) as the entity and sets neither hint, so every production NOTIFY takes the coalesced read on every replica until the plugins module setsDataChangeNotification.NodeTypeandVersionfrom the payload — the follow-up #4104's body names. The relay classifies that descriptor silently: a foreign entity is the feed's designed shape, not a fault (the first relay logged it atError, once per NOTIFY per replica).A relay, not a grain. The mesh-scoped
InProcessMeshChangeFeedowns oneStorageChangeFeedRelay, which subscribesIStorageAdapter.Changesand relays each notification into the process-localIMeshInvalidationFeed. Each process therefore receives its own database listener's copy directly. A monolith orLocalMeshprocess that shares a database with another process gains cross-process invalidation too. The old Orleans broadcast classes remain binary-compatible during the additive rollout; theirPublishLocalentry point now feeds only invalidators. After the PostgreSQL payload upgrade ships, the dead wrapper registration andPathCacheInvalidatorGraincan be deleted without a mixed-version gap.Cache invalidation and logical delivery are separate. A direct
IMeshChangeFeed.Publishreaches both logical subscribers and the writer's local invalidators. A storage or Orleans echo reaches onlyIMeshInvalidationFeed. This boundary is load-bearing: logical subscribers includeAccessGrantNotifier(which can send mail) andInstanceSyncCoordinator(which can write to another instance). Sending a database echo through the logical feed would run those effects once per replica, while PostgreSQL can additionally echo the writer's own commit. Cache invalidators are idempotent and deliberately run in every process; logical effects retain the publisher's single delivery. Both channels serialize concurrent publishers. Invalidation subscribers are isolated per callback, so a broken cache misses its own event and cannot prevent later caches from receiving the same commit.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.
The production proof that selected the relay
On 2026-09-12 the public two-replica portal held two incompatible answers for the same path after a
successful plugin publication. An exact read of Hosting/PlatformBuilds/plugins on one request
returned the old v7 row and source SHA, while the children query returned a newly-created v1 row
with a different storage identity and the current SHA. The new row had committed and its Created
notification had been emitted; one process's exact-path cache had simply never seen it.
The reason is structural twice over. The default Orleans composition calls
AddPartitionedInMemoryPersistence first; that reaches AddMeshCatalog and registers
IMeshChangeFeed as the process-local feed, so the later Orleans wrapper's TryAdd is ignored.
Even if that order were reversed, OrleansMeshChangeFeed publishes every kind at stream id
Guid.Empty, and PathCacheInvalidatorGrain is an ordinary grain at that same key. Orleans grain
identity is a cluster singleton, although the class comment claimed one activation per silo. Its
handler calls a process-local InProcessMeshChangeFeed, so only the process hosting that one
activation is invalidated. No retry, registration reorder or second publish can guarantee delivery
to every process. Subscribing each process to the storage notification stream removes both
impossible fan-out assumptions.
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 |
implemented in the first core slice; the compatibility read coalesced per path and overtaken by anything newer in the second (#4139) | relay owns the mixed-version reread through ReReadCoalescing; tests pin the logical/invalidation boundary, old-payload recovery, per-event failure isolation, two independent replica feeds, one read per burst, no read for a burst that ends self-contained, overtaking by a hinted update / a delete / a newer burst, and reads that never overlap on a path |
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