IStorageAdapter.Changes is the in-process broadcast every subscriber inside the silo depends on to learn that storage moved: per-node hubs reconcile their cached workspace state from it, and every live synced query folds it into the result set it is keeping current. A view that databinds to a children listing, the $security-access query the permission evaluator folds, an agent watching a namespace — all of them are downstream of this one feed.

So the feed has a contract, and it is stronger than "it is an IObservable":

Delivery to one subscriber is independent of every other subscriber. A subscriber that throws misses that notification and nothing more. It never costs another subscriber a notification, it never costs the publishing write, and it is never silent.

The class that enforces it is IsolatedChangeFeed (MeshWeaver.Mesh.Contract, namespace MeshWeaver.Mesh.Services). It lives next to the interface it enforces, and every adapter publishes through it — there is no adapter-local variant.

Why a plain Subject<T> is the wrong primitive

Subject<T>.OnNext delivers to its observers synchronously, in subscription order, on the caller's thread. There is no isolation in that loop: the first observer that throws unwinds out of OnNext, and every observer registered after it never sees the notification at all. Which subscribers are starved is decided by subscription order — an ordering nobody controls, chose, or can even observe.

Adapters used to compound that by wrapping the publish:

// ❌ The shape this contract exists to forbid.
try { _changes.OnNext(new DataChangeNotification(path, …)); }
catch { /* best-effort — never throw out of a write */ }

The catch is the amplifier. Without it the write would at least fail loudly. With it: no error, no log line, and the write reports success — while an arbitrary suffix of the subscriber list silently missed a change it will now never be told about.

The thrower is not hypothetical

Every live synced query owns a persistence.Changes → changeBuffer pipeline in StorageAdapterMeshQueryProvider. Teardown disposes that pipeline's CompositeDisposable, and a pipeline caught mid-teardown has a disposed changeBuffer while its feed subscription is still delivering into it — an ObjectDisposedException straight into the fan-out.

That window is not rare on any backend. One-shot queries — IMeshService.QueryAsync, autocomplete, path resolution — open and tear down one of these pipelines constantly, so on a busy mesh the window is hit whenever a write lands during a teardown. It needs no parallelism and no unusual load, which is precisely why the resulting failures read as intermittent.

The two symptoms this produced, on the two backends where it was found:

Backend What the surviving subscribers lost How it looked
Postgres (#889) the $security-access:{partition} query the permission fold reads; its Replay(1) cache stayed frozen at the pre-write snapshot reads evaluated against stale permissions until something re-triggered the query; in tests, PaywallRealGateShapeTests timing out on its fold barrier
In-memory (#1053) a live scope:children listing a children query that silently stopped re-emitting after a create that completed successfully — 30 s timeout, write present in storage, nothing wrong anywhere

Note what both have in common: the write succeeded, the notification was published, and the notification was relevant. Nothing upstream of delivery is at fault. Chasing "did the write publish?" or "does PathMatcher.ShouldNotify agree?" is chasing the wrong half — the drop happens after relevance matching, in the fan-out.

What IsolatedChangeFeed guarantees

public sealed class IsolatedChangeFeed
    : IObservable<DataChangeNotification>, IObserver<DataChangeNotification>, IDisposable
{
    public IsolatedChangeFeed(ILogger? logger, string adapter);
}

It is both the observable the adapter exposes and the observer the adapter publishes into, so an adapter holds exactly one field and needs no fan-out code of its own.

1. Delivery walks an immutable snapshot. The observer list is an ImmutableList<…> read once, before delivery starts, with no lock held for the loop. A subscriber that subscribes or disposes during a publish cannot disturb the fan-out in flight.

2. Each observer is isolated, and the two failure modes are treated differently.

foreach (var observer in observers)
{
    try { observer.OnNext(value); }
    catch (ObjectDisposedException ex)
    {
        Remove(observer);          // provably dead — every later notification would throw again
        _logger?.LogWarning(ex, "…was disposed while still subscribed…; dropped from the feed…");
    }
    catch (Exception ex)
    {
        // isolated but KEPT — see below
        _logger?.LogWarning(ex, "…threw on {Path}… and MISSED that notification; it remains subscribed…");
    }
}

3. Both arms log at Warning, and each names the adapter, the path, and how many other observers were unaffected. Not Debug — a subscriber that missed a change is holding a stale view of the mesh, and on the security-fold path that means stale permissions. A swallowed fault on that path is exactly what made the Postgres instance take three CI runs to see.

4. OnError and OnCompleted are isolated the same way, so a terminal signal cannot be lost to one bad observer either.

5. Dispose() detaches every observer and refuses further subscriptions (later Subscribe calls return Disposable.Empty), so a torn-down adapter cannot resurrect a feed.

Writing or reviewing an adapter

Hold one IsolatedChangeFeed field, expose it as Changes, publish into it directly, and do not wrap the publish in catch:

private readonly IsolatedChangeFeed _changes;

public InMemoryStorageAdapter(ILogger<InMemoryStorageAdapter>? logger = null)
{
    _changes = new IsolatedChangeFeed(logger, "in-memory");
}

public IObservable<DataChangeNotification> Changes => _changes;

Three rules that go with it:

Current publishers, all on the same class:

Adapter Feed name
InMemoryStorageAdapter in-memory
SqliteStorageAdapter sqlite
CosmosStorageAdapter cosmos
PostgreSqlStorageAdapter the partition's schema (or public)
SnowflakeStorageAdapter the partition's schema (or public)
PostgreSqlPathRoutingAdapter path-router
SnowflakePathRoutingAdapter sf-path-router

Reading the symptom

If a live view, query, or watcher stops updating while writes keep succeeding, this is one of the two mechanisms to check (the other is a .Take(1) on a stream feeding a live binding — see Asynchronous Calls rule 8).

Search the log for the feed's own warnings before hypothesising anything upstream:

Change-feed observer <Observer> was disposed while still subscribed (<adapter>); dropped from
the feed at <path>. Delivery to the other N observer(s) was NOT affected.

That line is the whole diagnosis: it names the adapter, the path, and the observer type. Its absence is informative too — no isolated fault means the fan-out was clean and the gap is genuinely elsewhere (publication, relevance matching, or the synced-query fold).

Pinned by

Test What it holds
InMemoryChangeFeedFanoutIsolationTest (test/MeshWeaver.Hosting.Test) the fan-out contract at the adapter
IsolatedChangeFeedTests, MergedFeedFanoutIsolationTests (test/MeshWeaver.Hosting.PostgreSql.Test) the same contract on the merged per-schema feeds
SyncedQueryChangeFeedStarvationTest (test/MeshWeaver.Hosting.Test) the end-to-end symptom: a live children query re-emits even while a torn-down pipeline sits on the feed

The starvation test was written with a negative control: with only the adapter's feed reverted it fails in 30 s with the reported signature verbatim, and passes in 0.5 s with the feed restored. That control is also what ruled out the two competing explanations — it holds publication and relevance matching fixed, so only the fan-out differs between red and green.

See also

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.