Durable Streams Are Mesh Nodes

Read first: Orleans Stream Pub-Sub Durability (the defect and why a durable PubSubStore did 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 evaluate Microsoft.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 nodeMeshNode.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.BuildPodHubRouteFallBackToStream 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.PostFailurePublishFailureOverStream 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:

  1. No production process hosts hubs as an Orleans client. UseOrleansMeshClient has exactly two callers, both test fixtures (OrleansMeshTestBase, OrleansDocumentationTest). Memex.Portal.Distributed is a co-hosted silo; the monolith, LocalMesh and 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.
  2. The database's own cross-process feed is live in production. AddPartitionedPostgreSqlPersistence registers PostgreSqlChangeListener and the IHostedService that opens its LISTEN mesh_node_changes session (PostgreSqlExtensions.cs, pinned by ChangeListenerWiringTests since #1814/#1816). Every commit on any mesh_nodes table fires notify_mesh_node_changes(), which already de-duplicates no-op updates. The feed carries {path, op, node_type} and surfaces as IStorageAdapter.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:

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:

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:

  1. The notification contract carries NodeType and Version. The PostgreSQL trigger already sends node_type beside path and op, and has NEW.version in hand. DataChangeNotification gains NodeType and Version as 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 a Created/Updated with no node and no version AFTER that Deleted would read as a retype to "(none)" to NodeTypeRebindWatcher and 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 (ReReadCoalescing in MeshWeaver.Mesh.Contract: a 50 ms Throttle that always emits the LAST trigger of a burst, then Concat so reads on a path are serialised) — the same operator and the same window the per-node hub's own reconcile in MeshDataSource has 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 reference ReReadCoalescing.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 sets DataChangeNotification.NodeType and Version from 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 at Error, once per NOTIFY per replica).

  2. A relay, not a grain. The mesh-scoped InProcessMeshChangeFeed owns one StorageChangeFeedRelay, which subscribes IStorageAdapter.Changes and relays each notification into the process-local IMeshInvalidationFeed. Each process therefore receives its own database listener's copy directly. A monolith or LocalMesh process that shares a database with another process gains cross-process invalidation too. The old Orleans broadcast classes remain binary-compatible during the additive rollout; their PublishLocal entry point now feeds only invalidators. After the PostgreSQL payload upgrade ships, the dead wrapper registration and PathCacheInvalidatorGrain can be deleted without a mixed-version gap.

  3. Cache invalidation and logical delivery are separate. A direct IMeshChangeFeed.Publish reaches both logical subscribers and the writer's local invalidators. A storage or Orleans echo reaches only IMeshInvalidationFeed. This boundary is load-bearing: logical subscribers include AccessGrantNotifier (which can send mail) and InstanceSyncCoordinator (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.

  4. 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.

  5. Backend-agnostic by construction. Cosmos and Snowflake already feed Changes with Entity = 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:

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.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.