Fix: stream could emit nothing when a write raced its initial query (+ exp 256: never emit stale) - #274
Conversation
A write that landed while a stream's initial query was in flight marked the entry dirty, and the initial result was then discarded in favour of a catch-up re-query. But `lastResultHash` had already been set from those same rows, so `selectIfChanged` compared against them, reported "unchanged", and emitted nothing — leaving the stream permanently silent, never emitting even its first value. Opening a stream and writing immediately is ordinary usage, and the documented contract is that a stream emits current results immediately. Always emit the initial result, then re-query if the entry was dirtied; the re-query still suppresses itself when nothing actually changed, so no duplicate emission. This was reproducing at ~33% as the long-standing write_coalescing "flake". It is not a flake: the regression test added here (seeded so the write reliably races the initial query) fails 5/5 without the fix and passes 6/6 with it, and that test file's comment claiming the shape "can't flake under load" is corrected to describe what it actually guards. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eriment Writeup, README row, and signal entry with two claims: 255.1 (the emission guarantee and why suppressing it stranded streams) and 255.2 (a 1-in-3 reproduction is a bug, not a flake — and a comment asserting otherwise redirects investigation). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rrect Exp 255 guaranteed a stream always emits; this settles which rows the first emission carries when a write raced the initial query. Measured both designs across race windows widened by seed size: time-to-correct (p50) A 8.1ms -> B 9.5ms at 20k; 18.5 -> 18.9 at 60k stale frames/stream A 1.00 -> B 0.00 at every raced size no-race control identical (13.3ms, 2 emissions) A's faster first emission measures the wrong clock: it delivers rows already known superseded, so the subscriber must disregard them anyway. B costs about a millisecond of time-to-truth and removes the list-jump artifact entirely. Shipping B: poison the comparison baseline when the entry is dirtied so the catch-up re-query is guaranteed to emit. This is not the exp 255 bug — that was suppression WITHOUT poisoning, which let both emissions cancel. Exp 255's writeup and claim are restated for the shipped semantics, and 255.1 now refines 256.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Updated: folded in exp 256, which changes the fix to its better form. The original commit fixed the silence by always emitting the initial rows, then correcting. That worked, but left a design question the bug had obscured: once emission is guaranteed, which rows should the first one carry — the known-superseded initial rows (emit-then-correct), or only the corrected result (suppress-stale)? Measured both, across race windows widened by seed size:
A's faster first emission measures the wrong clock — it delivers rows already known superseded, so the subscriber has to disregard them anyway. On time-to-correct-value the two are within ~1 ms, and B removes the stale frame entirely (the list-jump artifact a reactive stream exists to prevent). Unraced streams are identical. Shipping B: poison the comparison baseline when the entry is dirtied, so the catch-up re-query is guaranteed to emit and that emission becomes the stream's first. This is not a return to the bug. The exp 255 defect was suppression without poisoning — the re-query compared against the very rows it replaced, reported "unchanged", and both emissions cancelled. The sentinel guarantees a mismatch, and Exp 255's writeup and claim are restated for the shipped semantics; |
Review pass on the folded PR found the sentinel was wrong. `selectIfChanged` reports "unchanged" only when hash AND row count both match, so poisoning either suffices — but only the row count has an impossible value. An empty result hashes to 0 (`_finishInitialHash` short-circuits on rowCount == 0), so a hash sentinel of 0 is a legitimate baseline, not an unreachable one: a stream over a query matching no rows would compare 0 against 0, report "unchanged", and go silent — exactly the exp 255 bug, reachable only for empty results. The shipped code already set both, so behaviour was correct, but the comment credited the hash for a guarantee the row count was providing. Anyone simplifying by dropping the row count would have reintroduced the bug for empty results with no test to catch it. Now poisons the row count alone, named `_neverMatchingRowCount` and documented with why a hash sentinel cannot work. Adds an empty-result regression test: 0/3 against the hash-only sentinel, 4/4 against this one. Recorded as claim 255.3 with the general lesson — a forced-mismatch sentinel needs a value the compared domain cannot produce. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`entry.lastRowCount = null` says what it means — no baseline to compare against — where `_neverMatchingRowCount = -1` needed a comment to explain that -1 was chosen because real counts are non-negative. The row count is nullable end to end now (StreamEntry, SelectIfChangedRequest, the comparison), and null cannot collide with any real observation, so the guarantee holds by construction rather than by argument. This also retires a magic -1 that predated this work: the request type already documented "-1 if unknown", so the codebase ends with one fewer sentinel than it started with rather than one more. Behaviour unchanged: 141 tests pass, both regression tests 10/10, and the empty-result guard still fails 0/3 against a hash-only sentinel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hypothesis
Not a hypothesis — a bug, found by finally investigating the
write_coalescing_testfailure that had been dismissed as a flake (by me, three times in one session, while shepherding unrelated PRs).db.stream(sql)promises to emit current results immediately and re-emit when writes change them. It could do neither: a stream whose initial query was still in flight when a write landed went permanently silent, never emitting even its first value.The failure output said so plainly, once read:
emitted x— nothing at all. Not a stale value: no value.Mechanism
StreamEngine._createStreamstored the initial result's hash as the comparison baseline, then — if a write had dirtied the entry mid-flight — discarded those rows unemitted in favour of a catch-up re-query. But that re-query'sselectIfChangedcompares against the baseline taken from the very rows it replaced, finds them identical, and returnsrows == null("unchanged, nothing to emit").Initial emission suppressed as stale. Replacement suppressed as unchanged. Stream silent, permanently.
The race window is the initial query's duration — trivial on a 4-row table, reliable on a 40k-row one. Opening a stream and immediately writing (seeding a list on first paint) is ordinary usage.
Approach
Always emit the initial result — it is a valid observation and the documented contract — then re-query if the entry was dirtied:
No duplicate risk: the catch-up still runs through
selectIfChanged, which self-suppresses when nothing changed.Two earlier hypotheses were tried and discarded (both recorded in the writeup): a test-side registration race, and a stranded re-query queue. Neither moved the failure rate. Reading the actual failure text instead of theorising would have gone straight to the answer.
Results
New regression test seeds 40k rows so the write reliably races the initial query:
write_coalescing_test(the old "flake")Standalone reproduction,
mainvs this branch:Also corrects that test file's comment asserting the shape "can't flake under load" — a false confidence marker is worse than none, since it actively redirects investigation.
Outcome
Accepted — user-visible correctness fix. Recorded as exp 255 with claims
255.1(the emission guarantee) and255.2(a 1-in-3 reproduction is a bug, not a flake), so the knowledge graph carries both the mechanism and the methodology lesson.Unblocks #273, which was only ever failing on this.
Test plan
dart analyze --fatal-infosclean on changed lib + testswrite_coalescing_test: 8/8 (was 3/6)finalize_experiment.dart+check_knowledge_links.dartgreen