Repository navigation
fix(#1186): a failed read is not an absent node — a short snapshot says so - #5165
Conversation
…ys so `StorageAdapterMeshQueryProvider` catches a store fault, logs it at Warning, drops the rows it could not get and emits its `Initial` over what survived. Keeping the survivors is deliberate and stays — one corrupt row must not kill a listing — but the frame claimed a COMPLETE answer, so the provider was never recorded in `SilentProviders`, every consumer that refuses to read absence off a floor could not fire, and the gap travelled outward as a fact about the NODE: `PathResolutionService` resolved `null` and `MessageHubGrain` reported "the node does not exist or no query provider claims its partition" over a node that was present and a provider that had answered. Both clauses were measured false on #1186 — three of three failing paths read back present on the live mesh. The verdict: `QueryResultChange.SnapshotIncomplete`, set by a provider on its own frame, folded by `MeshQuery` into the `SilentProviders` field every existing consumer already reads (#4557). No new mechanism, no availability change, no bound touched: the same rows are served and the frame admits it is short. The fold runs on the multi-provider merge AND the single-provider fast path, because a lone provider is the shape a Monolith and every test actually run. The completeness sink is per SUBSCRIPTION, never a field: a field would be process-wide mutable state shared by every concurrent query on that adapter, so one partition's timeout would mark every other caller's snapshot incomplete and nothing would ever clear it. A teardown cancellation is deliberately not recorded — that query is stopping, not answering. With a silent read faulting on its own terminal (#5042) and a short read refused as a floor, the grain's remaining arm is genuinely determinate, so it drops the two-cause union it used to offer and says what it now knows. Controls, both sides of the change: a store that throws on the read that would have found the node ⇒ incomplete + named provider, survivor still served; a healthy store asked for a path nobody wrote ⇒ complete, `SilentProviders` empty. Without the second case an implementation that stamped every frame incomplete passes — and refuses every genuinely absent node in production. Both fixtures count and assert the faults they injected, so a refactor that stops taking the faulting leg fails loudly instead of leaving the test vacuous. Measured: 3/3 pass with the fix; with `SnapshotIncomplete` reverted to `false` and the test bin's `MeshWeaver.Hosting.dll` rebuilt, 2 failed / 1 passed — the two fault cases red, the must-not-move control green. Query layer 29/29. `MeshWeaver.Hosting`, `MeshWeaver.Hosting.Orleans` and the test project each build `-c Release -warnaserror` with 0 Error(s), 0 Warning(s). Closure of #1186 awaits production verification, per sev:H policy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The change is cohesive, reuses the established SilentProviders contract as intended, and is covered by focused regression tests and documentation without introducing async/await into src/.
Review effort: Lite
Findings: None
What changed in this PR
This PR fixes a diagnostic/correctness gap in the query layer where a provider could emit an Initial frame after swallowing a store read fault, but the resulting frame still looked complete downstream—allowing failed reads to be misinterpreted as “absent nodes” (#1186). It introduces a provider-side completeness verdict (SnapshotIncomplete) and folds it into the existing SilentProviders mechanism so existing consumers refuse “floors” without adding a second decision channel.
Changes:
- Add
QueryResultChange<T>.SnapshotIncompleteand thread it throughStorageAdapterMeshQueryProviderso anInitialsnapshot can explicitly declare it is incomplete due to dropped reads. - Fold
SnapshotIncompleteintoSilentProvidersinMeshQueryfor both multi-provider merge and single-provider fast path, reusing the existing downstream floor-refusal behavior. - Add targeted regression tests and a new architecture doc page describing the failure mode and why the fix is structured as a fold into
SilentProviders.
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Test/AFaultedReadIsNotAnAbsentNodeTest.cs | Adds regression coverage for “faulted read ≠ absent node”, including negative controls. |
| src/MeshWeaver.Mesh.Contract/Query/QueryResultChange.cs | Adds SnapshotIncomplete to carry provider completeness on the wire. |
| src/MeshWeaver.Hosting/Persistence/Query/StorageAdapterMeshQueryProvider.cs | Records dropped reads per subscription and stamps SnapshotIncomplete on the emitted Initial. |
| src/MeshWeaver.Hosting/Persistence/Query/MeshQuery.cs | Folds SnapshotIncomplete into SilentProviders (single-provider + multi-provider paths). |
| src/MeshWeaver.Hosting.Orleans/MessageHubGrain.cs | Updates the determinate-absence activation error message to avoid an incorrect two-cause union. |
| src/MeshWeaver.Documentation/Data/Architecture/AFailedReadIsNotAnAbsentNode.md | Documents the end-to-end chain and the “single verdict, folded into existing field” rationale. |
| src/MeshWeaver.Documentation/Data/Architecture.md | Adds the new architecture page to the Architecture index. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Blast-radius check: all three existing
|
| before | after | |
|---|---|---|
| store fault, requested path not found | resolved as absent; frame cached as durable truth | resolution refused, naming the provider; chain evicted so the next call re-asks |
| store fault, requested path still found | answered, cached | answered, chain evicted so the next call re-asks |
| healthy read, path genuinely absent | answered absent, cached | unchanged — answered absent, cached |
Nothing hangs, nothing retries in a loop, and no availability is lost: the only behaviour that changes is that a snapshot built over an incomplete read stops being kept as the process's durable answer. The third row is the one the AHealthyStoreMissingThePath_StaysADeterminateAbsence control pins.
What I did not establish: I did not measure the eviction rate this adds under a store partly down. A backend faulting steadily on one partition will now evict that query chain on every frame, so each caller rebuilds rather than replaying — correct, but more work than before, and I have no number for it. If that turns out to matter, the place to bound it is EvictUnansweredQuery, not the naming.
A store fault caught inside a query provider arrived downstream as a COMPLETE answer
StorageAdapterMeshQueryProvidercatches a store fault, logs it at Warning, drops the rows it could not get, and emits itsInitialover what survived. Keeping the survivors is deliberate and stays — one corrupt row must not kill a listing. What was wrong is that the frame then claimed a complete answer, so:QueryResultChange.SilentProviders(it did emit an Initial);PathResolutionService's floor refusal guards on exactly that field;null, andMessageHubGrainreported "Either the node does not exist or no query provider claims its partition."Both clauses of that sentence were measured false in the cases that fire (#1186): three of three failing paths read back present on the live mesh, and the provider behind the gap had claimed the partition and answered. The union was asserted as exhaustive while the real cause was a third thing.
The fix: one verdict, folded into the field that already exists
QueryResultChange.SnapshotIncomplete— set by a provider, on its own frame, when it answered over reads it could not complete.MeshQueryfolds it intoSilentProviders, attaching the provider name it already knows.That reuse is the point:
SilentProvidersnames a provider that produced no Initial; one that produces a shorter Initial is the same lie with a frame attached. SoPathResolutionService,MeshNodeStreamCacheandSyncedQueryMeshNodesall cover this case with no change of their own — no second decider over one question.No bound was widened, no retry added, no catch removed, no availability lost. The same rows are served; the frame admits it is short.
With a silent read faulting on its own terminal (#5042) and a short read refused as a floor, the grain's remaining arm is genuinely determinate, so it drops the two-cause union and says what it now knows. Three conditions, three sentences, no union.
Controls — one on each side of the change
SnapshotIncompletereverted tofalseListChildPaths) throwsSilentProvidersempty, present row servedThe third case is not a nicety: without it, an implementation that stamped every frame incomplete passes the first two — and refuses every genuinely absent node in production, stalling every per-node hub activation in the mesh. Both fixtures count and assert the faults they injected, so a refactor that stops taking the faulting leg fails loudly instead of leaving the test vacuous.
Negative control run with the test bin's
MeshWeaver.Hosting.dllrebuilt (timestamp checked, not assumed): 2 failed / 1 passed.Verification
dotnet build -c Release -warnaserror, one project per invocation:MeshWeaver.Hosting0 Error(s) 0 Warning(s);MeshWeaver.Hosting.Orleans0 Error(s) 0 Warning(s);MeshWeaver.Hosting.Test0 Error(s) 0 Warning(s);MeshWeaver.Documentation.Test0 Error(s).PathResolution,QueryFanIn,AFaultedRead,PagedQueryTotalOrder,LiveQuery): 29 total / 29 succeeded / 0 failed.DocumentationLinkIntegrityTest: 1 / 1 passed (it caught a wrong sibling-link form first — fixed and re-run).Scope statements
Pairs-with: none — this diff removes no public type and no public member. It ADDS oneinitproperty toQueryResultChange` and one private nested class; nothing outside core referenced anything removed, because nothing was removed.Implementers: none — no member was added to a public interface or abstract class.SnapshotIncompleteis aninit` property with a default on a record, so no implementer is obliged.Mirror-sync: none — nostrings.{en,de}.json` key is added, removed or re-worded. The two sentences that changed are operator-facing diagnostics in exception text and a log line, English by house rule, and neither is a catalog key.What this does NOT establish
#1186issev:H, so it closes only on production verification — this PR merges and the issue stays open with the evidence recorded.Initialat all — that is feat: the query fan-in's Initial stall is a TERMINAL, not a warning #5048's subject, causally upstream, and separately ticketed.Logsread on a replica carrying this change.Refs #1186
🤖 Generated with Claude Code