fix(stream): make the broadcast the only writer of the pool cache - #150
Conversation
List subscriptions receive positional JSON-patch ops computed against a per-key pool cache. Two paths wrote that cache in opposite orders — live inserts ascending by Created, Server.fetch descending — and fetch ran on every REST read and every new subscriber, silently re-ordering the cache under already-attached subscribers. The next positional op then addressed the wrong element: a delete removed the wrong row, a replace overwrote the wrong record. Restore one invariant: the pool cache changes only through a broadcast that bumps the version and delivers the matching op or snapshot. - F1: insertSorted maintains descending Created (newest first), matching the pinned REST descending contract. - F2: REST reads serve straight from storage + read filters via fetchREST, never touching the pool. A WS subscriber attaching to an initialized pool is served its snapshot from the cache under the pool lock (ServeCache); only pool creation loads from storage. Applies to list and object pools. - F3: a coalescing resync scheduler rebuilds the matching pools from storage and broadcasts a version-bumped snapshot at the two observable desync sites — a dropped watch event and a recovered watch panic. Enqueue is non-blocking and does no inline storage I/O; the worker stops on a PreShutdown hook before storage close. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eload failures Address self-review findings on the single-writer change: - Blocker: ServeCache's fallthrough returned a nil Objects slice for a glob pool emptied by a glob delete (cache.Objects niled by processListDel while Version stays non-zero). fetch then encoded json null, so a subscriber attaching after a glob delete received a null list baseline instead of []. Return a non-nil empty slice at the (provably glob-only) fallthrough so the snapshot is always a valid list. Guarded by TestWsGlobSnapshotAfterGlobDeleteIsEmptyList. - Observability: reloadPool now logs each storage/filter/encode failure so a failed resync of an already-silent desync is visible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CBosch101
left a comment
There was a problem hiding this comment.
APPROVE — verified locally at head 0666d0d: go build ./..., go vet ./..., and go test ./... -race -count=1 all green (the six named regression/scenario tests pass under -race).
The invariant is restored cleanly on all three fronts:
- F1 (
stream/patch.go) —insertSortedflipped to descending; single caller, comment updated, and thepatch_testfixtures were reordered to match. Live inserts andfetchre-init now agree on newest-first ordering, so a positional op can't address a re-ordered element. - F2 (
rest.go→fetchREST) — REST reads no longer touch the pool cache, closing the path where an out-of-band read silently re-ordered the cache under attached subscribers with no version bump. - F3 (
scheduleResync/resyncWorker/Stream.ResyncPools) — the coalescing token+set worker is race-clean: send and close both run underresyncMubehind aresyncClosedguard (no send-on-closed); a dropped non-blocking send can't lose a resync because the key stays inresyncSetand a pending token drains the full set;ResyncPoolsmirrorsBroadcast'spool.mutex→nextVersion→broadcastPooldiscipline (no lock-order inversion) and never resurrects aVersion==0pool.
Both known edges are disclosed and tracked — #151 (the close-callback budget can skip the resync stop hook, gated on a non-default CloseCallbackBudget>0) and #152 (the first-attach fetch+attachConn window is non-atomic, a pre-existing window this change narrows). Neither is introduced here, so neither blocks.
🤖 reviewed by Claude Code (claude-runner) on behalf of @CBosch101
mhsantoidnerd
left a comment
There was a problem hiding this comment.
Approving. Reviewing as the reporter of the downstream symptom (bulk #1178) — the diagnosis here is sharper than mine was, and F2 removes the exact trigger I had.
Verified
- F1's binary-search flip is correct, not just inverted: the new predicate finds the first element with
Created <= obj.Created, which is the right insertion point for a descending list. - Ordering is now consistent across every path that seeds a pool — all three list reads use
GetListDescending,InitCacheObjectsWithVersionhas exactly one caller (the pool-creation branch offetch), and live inserts are descending. No ascending seed left to reintroduce the mismatch. - F2 kills my repro's trigger specifically. Every reproduction I ran read the collection over REST before deleting — so my own measurement was creating the desync. With
fetchRESTtouching no pool state that path is gone, and F1 makes the two orders agree even if a fetch did run. - Not re-run: ooo's own suite. @CBosch101 verified
build/vet/test -raceat this head and I didn't duplicate it.
Non-blocking
- #152 is mis-scoped — the window is not first-attach-only.
ServeCachereleasespool.mutexbefore returning, thenStream.NewrunsOnSubscribeand the WebSocketUpgradehandshake beforeattachConntakes the lock (stream/stream.go:279-298). A broadcast landing in that interval reaches neither the snapshot (copied earlier) nor the conn (not yet inpool.connections), and the conn is stamped with the pre-broadcast version — so a later positional op applies to a stale base on any attach, not only pool creation.
Nor is the window meaningfully shorter: it still spans a network handshake, which dominates the storage read F2 removed. Worth correcting #152's scope, and noting that only its second suggested direction (resolve the snapshot after registering, under one critical section) covers the non-first-attach case — locking across the storage load fixes just the first.
Downstream heads-up for the pin bump: consumers watching a list get built up incrementally will see it newest-first now, where ascending inserts previously put new records at the wrong end. That is the fix working, but it is a visible ordering change in any UI that renders subscription order directly.
🤖 Generated with Claude Code
What & why
List subscriptions receive positional JSON-patch ops computed against a per-key pool cache. Two code paths wrote that cache in opposite orders — live inserts kept it ascending by
Created, whileServer.fetchre-initialized it descending — andfetchran on every REST read of a glob key and every new subscriber, silently re-ordering the cache under already-attached subscribers with no version bump and no message. The next positional op then addressed the wrong element: a delete removed the wrong row, a replace overwrote the wrong record's data (duplicating one record, losing another).Symptom in the field: an operator with a list open sees the wrong row vanish on a delete, or a rename land on the wrong record — but only when a collection read or a second subscriber landed in between (which is why a reload appeared to "fix" it).
This restores one invariant: the pool cache changes only through a broadcast that bumps the version and delivers the matching op or snapshot.
The three parts
insertSortednow maintains descendingCreated, matching the pinned REST descending contract (ClientCompatibilityTest/ListSortOrder). Every ascending test fixture adjusted.fetchREST), with no pool interaction of any kind. A WS subscriber attaching to an already-initialized pool is served its snapshot from the cache under the pool lock (ServeCache— copy under lock, encode outside); only pool creation loads from storage. Applies to list (glob) and single-object pools alike.DroppedEvents) and a recovered watch panic (WatchPanics). Both now schedule (never inline — the drop callback runs on the storage sender goroutine under storage's lock) a rebuild of the matching pools from storage plus a version-bumped snapshot broadcast, via a coalescing scheduler (buffered channel + per-key set + dedicated worker started inStart, stopped by aPreShutdownhook before storage close). No standing self-heal anywhere else.Tests
TestClientListDeleteAfterCollectionRead,TestClientListDeleteAfterSecondSubscriber.TestClientListReplaceAfterCollectionRead(rename-after-read holds),TestWsReconnectGateAfterCollectionRead(the?v=reconnect gate),TestWsResyncAfterDroppedEventSnapshot(F3, viaDelSilentdivergence injection),TestWsGlobSnapshotAfterGlobDeleteIsEmptyList(glob-delete snapshot is[], notnull).go test ./... -race -count=1; baselineTestClientListDeletegreen at-count=10 -race; benchmarks compile and run.Reviewed
Hard self-review (truthseeker rigor) verified the F3 concurrency (send-under-lock serialized against close, no missed-wakeup/deadlock/send-on-closed, lock discipline mirrors
Broadcast, no inline storage I/O on the drop path, worker stopped before storage close), the F2 invariant (the broadcast + resync are the only writers of an initialized pool's cache), and F1 completeness. One blocker found during review (an emptied glob pool servednullinstead of[]) is fixed and guarded.Two pre-existing items surfaced and tracked, not fixed here: the close-callback budget can skip the resync stop hook (shared with the existing
LimitFiltercleanup hook) — #151; and the first-attachfetch+attachwindow is non-atomic, which this change narrows to first-attach only — #152.Notes for downstream
Downstream consumers pin ooo by commit; pin bumps happen after this merges. The
ooo-clientpatch-failure resilience work is a separate, independent PR.🤖 Generated with Claude Code