Repository navigation
A live query's early→live handoff dropped a change notification permanently - #2295
Conversation
…permanently Every live children/descendants query opened TWO subscriptions on the adapter's change feed: an "early" one buffering into a backlog while !initialDone, and a "live" one attached inside the initial-results callback, after which the early one was disposed. IsolatedChangeFeed snapshots its observer list BEFORE delivering — it must, because subscribers attach and detach mid-publish. So a write published in the instant before the live subscription was attached is delivered against a snapshot holding ONLY the early observer, and by the time that delivery ran the callback had already set initialDone = true. The early handler's `if (!initialDone)` threw the notification away; the live observer never saw it because it was not in the snapshot; and nothing re-triggers the re-query. The row stayed invisible to that subscription for its whole life — while a point read, and any fresh subscription, returned it immediately. Measured, not inferred. Instrumenting both observers reproduced it in MeshWeaver.AI.Test (4-wide, DOTNET_PROCESSOR_COUNT=4, ~1 in 8 runs): the notification arrives with match=True on the early observer AFTER "INITIAL items=0 backlog=0", and for that base path there is no live delivery and no re-query for the rest of the run. That is the long-running ThreadTokenUsageTest / ModelSubstitutionTest WaitForUsage red (#1040, #1812, #2001) — the token-usage satellite WAS written, with the right counts, on the Completed AND the Cancelled path; the reader's listing simply never learned of it. In the portal the same shape makes any live children listing (the chat token chip, a notification bell, a folder view) miss a node created in that window until something else changes under the same base path. One subscription for the query's whole lifetime now: the backlog-vs-live routing decision is made inside the same critical section that publishes the buffer, so a notification lands in exactly one of the two and can never fall between them. The shape existed in all four providers (StorageAdapterMeshQueryProvider, PostgreSqlMeshQuery, PostgreSqlPartitionedMeshQuery, CosmosMeshQuery) and is corrected in all four; it also removes the double-capture window the old comments accepted, and puts Cosmos's disposal order right. No budget widened, no retry, no watchdog. LiveQueryHandoffDropTest pins the window deterministically — it fails on the pre-fix provider and passes on the fix in 43 ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes a race in live query subscriptions where a change notification could be dropped during the Initial→live handoff, causing a live children/descendants query to permanently miss a newly-created node until the subscription was restarted.
Changes:
- Refactors live-query change-feed handling to use a single subscription for the query lifetime, switching from backlog buffering to the live requery buffer atomically.
- Applies the same single-subscription handoff fix across in-memory adapter queries, PostgreSQL queries (both single and partitioned), and Cosmos queries.
- Adds a deterministic regression test that pins the “snapshot-before-deliver” window and verifies the live requery still happens.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Test/LiveQueryHandoffDropTest.cs | New regression test that deterministically reproduces the dropped-notification handoff window. |
| src/MeshWeaver.Hosting/Persistence/Query/StorageAdapterMeshQueryProvider.cs | Uses one lifetime subscription and an atomic backlog→liveBuffer handoff to prevent permanent missed notifications. |
| src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPartitionedMeshQuery.cs | Applies the same single-subscription backlog/live routing fix to partitioned PostgreSQL queries. |
| src/MeshWeaver.Hosting.PostgreSql/PostgreSqlMeshQuery.cs | Applies the same single-subscription backlog/live routing fix to PostgreSQL queries. |
| src/MeshWeaver.Hosting.Cosmos/CosmosMeshQuery.cs | Applies the same single-subscription backlog/live routing fix to Cosmos queries. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-26-live-lists-stop-missing-a-just-created-node.md | Adds release-note entry describing the user-visible effect (live lists no longer silently miss a just-created node). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…iew) The gate that pins the Initial against the notification's fan-out snapshot ignored its wait's return value. A timed-out gate lets the scope walk continue on its own, which un-pins the two — the test would then pass having never held the window it exists to hold. It now records the timeout and the test asserts on it, so the run cannot silently verify nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Controlled before/after on the reproducing loopThe deterministic
0 in 135 post-fix, 4 in ~95 pre-fix. Note the pre-fix control's hit is a Completed-round test — One unrelated failure, attributed rather than ignoredPost-fix run 1 also saw one Note on the baseThis branch is cut from |
…f-drops-a-notification
A live query's early→live handoff dropped a change notification — permanently
Every live children/descendants query opened two subscriptions on the adapter's change feed: an
"early" one that buffered into a backlog while
!initialDone, and a "live" one attached inside theinitial-results callback, after which the early one was disposed.
IsolatedChangeFeedsnapshots its observer list before delivering — it must, becausesubscribers attach and detach mid-publish. So a write published in the instant before the live
subscription was attached is delivered against a snapshot holding only the early observer, and
by the time that delivery ran the callback had already set
initialDone = true. The earlyhandler's
if (!initialDone)threw the notification away; the live observer never saw it because itwas not in the snapshot; and nothing re-triggers the re-query. The row stayed invisible to that
subscription for its whole life — while a point read, and any fresh subscription, returned it
immediately.
A permanent miss dressed as a timeout, which is why no budget could ever have fixed it.
Measured, not inferred
This started as an investigation into whether cancelled agent rounds go unbilled — the writer
hypothesis #2244 pre-registered when its reader-side fix did not stop the
WaitForUsagereds.The writer is exonerated. Instrumenting the wait to read the node back at failure time, and
reproducing locally (
MeshWeaver.AI.Test4-wide withDOTNET_PROCESSOR_COUNT=4, onedotnet testat a time, ~1 failure in 8 runs):
The satellite is written, with the right counts, keyed by the right model — on the Completed and
the Cancelled path. The test's own live subscription emitted exactly one snapshot, the empty
Initial, then nothing for the full 20 s.
Subscribing the same shared upstream under System (which makes
WrapWithPerUserRlsshort-circuit to the raw, unfiltered stream) exonerated the per-user RLS filter too — raw saw
exactly what filtered saw. Instrumenting the provider then named the culprit outright:
…and, for that base path, zero
LIVE seenand zeroREQUERYfor the rest of the run.The fix
One subscription for the query's whole lifetime. The backlog-vs-live routing decision is made
inside the same critical section that publishes the buffer, so a notification lands in exactly one
of the two and can never fall between them. The same shape existed in all four providers —
StorageAdapterMeshQueryProvider,PostgreSqlMeshQuery,PostgreSqlPartitionedMeshQuery,CosmosMeshQuery— and is corrected in all four. It also removes the double-capture window the oldcomments accepted, and puts Cosmos's disposal order right as a side effect.
No budget was widened, no retry or watchdog added.
The test
LiveQueryHandoffDropTestpins the window deterministically: it holds the query's scope walk open,commits the write, takes the notification's fan-out snapshot while only the pre-Initial subscriber
exists, releases the walk, asserts the Initial is genuinely empty, and only then delivers.
MeshWeaver.Hosting.Testis 226/226 green.Scope note
Related but not fixed here, and deliberately kept separate: a round whose provider never emitted
usage before the cancel/error records zero tokens, silently (a Debug-level no-op, no tokenizer
or estimate anywhere in the tree). That is real unbilled spend and it is #595, which I reopened —
its last comment said "Keeping this issue open, scoped to that estimate" and it was then
auto-closed as COMPLETED by a PR that shipped only the two surgical items.
Also noticed while reading the logs, not changed here:
MeshQuery.InitialStallProbeDelayis 20 sand
ThreadTokenUsageTest.SettleBudgetMsis 20 000 — so in CI run32894972304the one warningthat would have named a stalled query provider fired ~30 ms after
TEST END. A stall probe armed atexactly the consumer's budget can never explain that consumer's timeout.
Root-causes the
WaitForUsageflake family closed prematurely as #1040, #1812 and #2001 — each ofthose diagnosed a different reader shape and none reached the notification drop underneath. Reported
on #1384 (the flake-population umbrella), which stays open.