Skip to content

Fix: stream could emit nothing when a write raced its initial query (+ exp 256: never emit stale) - #274

Merged
danReynolds merged 5 commits into
mainfrom
exp-255-stream-initial-emission
Jul 29, 2026
Merged

Fix: stream could emit nothing when a write raced its initial query (+ exp 256: never emit stale)#274
danReynolds merged 5 commits into
mainfrom
exp-255-stream-initial-emission

Conversation

@danReynolds

Copy link
Copy Markdown
Owner

Hypothesis

Not a hypothesis — a bug, found by finally investigating the write_coalescing_test failure 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:

Which: emitted x Stream closed.
       which never did emit an event that <8>

emitted x — nothing at all. Not a stale value: no value.

Mechanism

StreamEngine._createStream stored 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's selectIfChanged compares against the baseline taken from the very rows it replaced, finds them identical, and returns rows == 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:

entry.emit(initialRows);

if (entry.dirty) {
  _requeryQueue.add(entry);
  _flushQueue();
}

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:

without fix with fix
new regression test 0 / 5 pass 6 / 6 pass
write_coalescing_test (the old "flake") 3 / 6 pass 8 / 8 pass

Standalone reproduction, main vs this branch:

main:        ✗ stream emitted NOTHING in 4s (permanently silent)
this branch: ✓ emissions: [40008] → final 40008

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) and 255.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-infos clean on changed lib + tests
  • Regression test: 0/5 without fix, 6/6 with
  • write_coalescing_test: 8/8 (was 3/6)
  • 140 tests pass across stream, write-coalescing, trigger-cascade, database, transaction suites
  • finalize_experiment.dart + check_knowledge_links.dart green

danReynolds and others added 2 commits July 28, 2026 13:00
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>
@danReynolds danReynolds added type: correctness Correctness guard / public-API audit; no performance claim approved Experiment succeeded: a kept win or a passing guard labels Jul 28, 2026
…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>
@danReynolds danReynolds changed the title Fix: a stream could emit nothing when a write raced its initial query Fix: stream could emit nothing when a write raced its initial query (+ exp 256: never emit stale) Jul 28, 2026
@danReynolds

Copy link
Copy Markdown
Owner Author

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:

shape metric A emit-then-correct B suppress-stale
racing · 20k time to correct (p50) 8.1 ms 9.5 ms
racing · 20k stale frames per stream 1.00 0.00
racing · 60k time to correct (p50) 18.5 ms 18.9 ms
racing · 60k stale frames per stream 1.00 0.00
no race · 20k (control) correct / emissions 13.3 ms / 2.00 13.3 ms / 2.00

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 _requery propagates errors to subscribers, so a stream always resolves to a value or an error.

Exp 255's writeup and claim are restated for the shipped semantics; 255.1 now refines 256.1 in the knowledge graph.

danReynolds and others added 2 commits July 28, 2026 14:17
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>
@danReynolds
danReynolds merged commit 9683db8 into main Jul 29, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Experiment succeeded: a kept win or a passing guard type: correctness Correctness guard / public-API audit; no performance claim

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant