Restore subquery shapes correctly after a server restart (materializer move replay)#4681
Restore subquery shapes correctly after a server restart (materializer move replay)#4681robacourt wants to merge 8 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## rob/subquery-restore-bug-6 #4681 +/- ##
==============================================================
- Coverage 60.05% 60.02% -0.03%
==============================================================
Files 397 397
Lines 43766 43766
Branches 12590 12588 -2
==============================================================
- Hits 26282 26269 -13
- Misses 17403 17416 +13
Partials 81 81
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Claude Code ReviewSummaryIteration 8 is a rebase-only update: the PR was replayed onto the current base ( What's Working Well
Issues FoundCritical (Must Fix)None. Important (Should Fix)None. Suggestions (Nice to Have)
Issue Conformance
Previous Review Status
Rebase-clean, no new changes, no regressions. The PR remains ready to merge. Review iteration: 8 | 2026-07-16 |
fdf08f4 to
aafa5c1
Compare
ce2d63d to
2262fc6
Compare
A deterministic `test_against_oracle` restart reproduction for optimized subquery shapes diverging from Postgres after a graceful server restart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l timeout The per-dependency moves-position is now staged when the move pipeline drains and only committed+persisted once the writer confirms the flush has passed the move's splice (and, at terminate, after the writer has been flushed). This keeps the persisted position from running ahead of durable storage across a restart, which could otherwise leave a subquery shape permanently missing rows. Also raise the oracle-restore test's long_poll_timeout: a very short one trips a separate post-restart long-poll readiness race (bug 3), unrelated to the subquery-restore behaviour under test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A nested optimized subquery (issues -> projects-subquery -> regions) produces a dependency shape that is itself an optimized subquery, so its log contains move-in/move-out control messages. On restart the dependency materializer's replay drops those (they decode to before_all()), so the outer shape can be left missing the issues of projects that moved via a control message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On restart a dependency materializer replays its log to catch up a behind
subscriber, re-emitting the moves it missed. Data-change log items carry their
offset in their headers, but control messages (subquery move-in/move-out events)
do not — so `decode_offset/1` collapsed them to `before_all()`, placing them at
or before every `from_lsn` and never re-emitting them. This only surfaces when a
materializer's own log contains control messages, i.e. a nested optimized
subquery whose dependency is itself an optimized subquery.
Add `Storage.get_log_stream_with_offsets/3`, which yields `{LogOffset, json}`
pairs from the authoritative storage framing (snapshot lines, which precede all
real offsets, are tagged `before_all()`). Materializer replay now reads with
offsets and positions every item — control messages included — by its real
transaction offset, so control-message moves after `from_lsn` are replayed.
Covered by a deterministic materializer replay unit test; the live `new_changes`
path is unchanged (it already tags batches by `range_end`).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A dependency materializer's replay grouped its log into batches by `tx_offset` (a transaction/LSN boundary) before capturing the seed and emitting the moves after `from_lsn`. That is wrong for nested optimized subqueries: dependency-driven moves (subquery move-in/out) have no WAL LSN of their own, so they are appended as successive `op_offset`s within a single `tx_offset`. Grouping by `tx_offset` collapsed an entire nested subquery's move history into one batch, so the seed was always the pre-log snapshot state and `from_lsn` was ignored — on restart the outer consumer was seeded as though rows were still present and the corrective move was redundancy-eliminated, leaving the outer shape diverged from Postgres. Chunk by the full `(tx_offset, op_offset)` offset instead — the same space `from_lsn`/`move_positions` live in — restoring the per-move granularity the seed cut and emit boundary need. Snapshot lines all share `before_all/0`, so they still group into a single pre-real-offsets batch. Only the replay path changes; the live `new_changes` path (and its same-batch move cancellation) is untouched. This makes the previously-failing nested optimized subquery restore test pass deterministically (30/30, was ~4% divergence), with single-level restore and the full materializer/consumer suites still green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replay keys batches by full offset, so a value that crosses the 0↔1 boundary several times inside a single source transaction is re-emitted as separate sequential payloads (one per op, in offset order) rather than a single netted one. That is safe — the case `cancel_matching_move_events/1` guards against is a single payload carrying both a move_in and a move_out for the same value; ordered single-value payloads are just the normal per-transaction flow. Lock that in. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The materializer had a `new_changes` clause that skipped ranges already applied (<= applied_offset), guarding against the source consumer re-notifying it with transactions the persistent slot replays after a restart. That dedup now happens at the source: the consumer skips already-applied transactions (at/below its restored latest_offset) before `apply_event`, so it never re-notifies the materializer for them — the guard is unreachable. Remove it and rely on the single source-level dedup. Confirmed by the optimized_refetch restart repro (which previously triggered the materializer's "Key already exists" crash) staying green across repeated runs, plus the materializer/consumer/subquery suites and the other restore tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2262fc6 to
2a1a1d1
Compare
Summary
Optimized streaming subquery shapes could diverge from Postgres after a graceful server restart — permanently losing or retaining stale rows — including the nested case where the inner subquery is itself an optimized subquery. This fixes that. A representative shape:
issues WHERE project_id IN (SELECT id FROM projects WHERE active = true).Why it diverges
A subquery shape is served by a materializer that tracks which rows the inner query selects and drives moves (move-in / move-out) into the outer shape as that set changes. A move is asynchronous and multi-stage: a move-in fires a Postgres query and only splices its rows into the shape log once that query returns.
A graceful restart can interrupt a move at any stage — in flight, queued behind another move, sitting unprocessed in the consumer's mailbox, or computed by the materializer but not yet emitted. When it does, the materializer rebuilds its in-memory view from disk on restart (so it "knows" the move happened), but the outer shape's storage never received it and nothing re-drives it. The shape is left permanently out of sync.
The approach: replay missed moves from a durable position
Rather than making each of those stages individually crash-safe — a fragile, case-by-case effort — this makes the materializer the single point that re-drives moves, so every timing class is handled by one mechanism.
Each emitted move is tagged with its source LSN, and each outer consumer persists, per dependency, the position up to which it has applied moves. On restart the consumer re-subscribes and the materializer replays exactly the moves after that position; the consumer re-applies them. Because the consumer just replays whatever it is handed, in-flight / queued / mailbox / not-yet-emitted all reduce to "the persisted position is behind" — covered by construction rather than case by case.
For that to be safe across a crash the persisted position must never run ahead of durable storage, so it is coupled to the writer flush: a move's position is staged when the move pipeline drains and only committed once the writer confirms the flush has passed that move's spliced rows (and unconditionally at terminate, after the writer is flushed).
Making the same mechanism correct for nested subqueries
When the dependency is itself an optimized subquery, its log contains move-in/move-out control messages, not just data rows — and two properties of those messages break the naive replay:
lsn/op_positionin their JSON headers; andtx_offset(onlyop_offsetincrements).Replay originally reconstructed each item's offset from its JSON headers and grouped items into batches by
tx_offset. Control messages therefore couldn't be positioned (no header offset) or separated (sharedtx_offset), so the replayed seed collapsed to the pre-log snapshot state and the corrective move was never re-emitted. Two changes fix this while leaving the mechanism above intact:Storage.get_log_stream_with_offsets/3) instead of re-parsing headers, so control messages are positioned correctly; and(tx_offset, op_offset)offset — the spacefrom_lsn/positions actually live in — so op-distinguished dependency moves are delimited per move.The commits
Each fix lands with the failing test that motivates it:
Testing
:oracle_restore_bug_1and:oracle_restore_optimized_refetch(single-level) — deterministic across repeated runs.:oracle_restore_nested_subquery— was ~4% divergent, now deterministic (80/80).🤖 Generated with Claude Code