Skip to content

fix(stores): guard vector reconciles against stale-load overwrites (ARN-216) - #377

Draft
nerdsane wants to merge 4 commits into
mainfrom
claude/arn-216-vector-backfill-race
Draft

fix(stores): guard vector reconciles against stale-load overwrites (ARN-216)#377
nerdsane wants to merge 4 commits into
mainfrom
claude/arn-216-vector-backfill-race

Conversation

@nerdsane

@nerdsane nerdsane commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Fixes ARN-216 (Vector backfill races live writes and can mark a stale index complete).

Defect

The ADR-0155 vector backfill is two store calls with nothing spanning them: load an entity's state (snapshot/replay at some journal sequence), then backfill_entity_vectors — an unconditional delete-then-insert reconcile. A live write landing between them co-commits the new embedding, and the reconcile then overwrites it with rows parsed from the stale load. The backfill then stamps the completion watermark, so reads treat the stale index as authoritative until the entity's next write. The Turso write-behind retry path has the same shape (a retried lagging index write can land after a newer one).

Fix (root cause)

as_of_sequence staleness guard. Every reconcile carries the journal sequence its rows were derived from; each store checks the entity's current journal sequence inside its own transaction/lock and skips the reconcile when the journal has advanced — the newer write's co-commit already holds newer rows. Applied to the sim, postgres, and turso stores; the turso write-behind passes its append's sequence; u64::MAX escape for callers with known-current rows. EntityLoadOutcome now carries the replayed sequence so both the index-write and tombstone/phantom purge arms pass the true as-of. Guard-skips count as success for the watermark — a skip means a newer live write reconciled the entity itself.

ADR-0161 records the design, the alternatives (entity-wide locking; re-load-and-diff — both rejected as still racy or heavier), and one residual: the declared-key backfill has the same load-then-write shape (per-key upserts rather than whole-entity reconciles); a symmetric guard is a follow-up, not silently folded in.

Also in this PR (ratchet-driven): the 4 plumbing lines pushed storage/mod.rs — the workspace's largest file — past the PROD_MAX_FILE_LINES ceiling, fixed by extracting the cohesive StorageStack block verbatim to storage/stack.rs (2833 → 2702, permanent headroom).

TDD

  • RED d2ba6fd5 (committed alone): 100-seed DST executing the exact production interleave — stale rows built, live Reembed co-commits E2, stale reconcile — fails on main with E1 clobbering E2, every seed. The pre-commit reviewer additionally ran a causal probe (removing the reconcile makes it pass) proving the failure is precisely the stale overwrite.
  • GREEN: turns it green via the guard.

Verification

Greptile Summary

This PR fixes ARN-216, a race condition where the vector backfill's unconditional delete-then-insert reconcile could overwrite a newer live co-committed embedding with rows built from a stale entity load. The fix adds an as_of_sequence staleness guard to backfill_entity_vectors that each store checks inside its own transaction/lock, skipping the reconcile when the journal has advanced.

  • Guard applied to all three stores: Postgres uses DELETE-first then journal check under READ COMMITTED (row locks serialize concurrent reconciles); Turso uses Immediate-transaction check-first with explicit tx.rollback().await on skip; Sim uses the mutex-held journal directly.
  • EntityLoadOutcome now carries the replayed sequence so both the index-write and tombstone/phantom purge arms pass the true as_of_sequence; write-behind retry passes the append's own new_seq.
  • StorageStack extracted verbatim to storage/stack.rs to stay within the file-line ratchet; a 100-seed DST pins the exact production interleave that was RED on main.

Confidence Score: 5/5

Safe to merge. The guard is applied consistently across all three store backends, each ordered to match its isolation model, and a 100-seed DST pins the exact race interleave that was broken on main.

The guard logic in each store is short and directly testable. The Postgres DELETE-first ordering is intentional and documented in both the inline comment and ADR-0161. The Turso Immediate-transaction check-first ordering is correct for that isolation level, and the explicit rollback addresses the previously flagged libsql drop-hook concern. The StorageStack extraction is verbatim with no behavioral change. Residuals are correctly documented and non-blocking.

No files require special attention. The DELETE-first guard ordering in crates/temper-store-postgres/src/store.rs is intentional and documented - reviewing it against ADR-0161 confirms correctness.

Important Files Changed

Filename Overview
crates/temper-runtime/src/persistence/mod.rs Adds as_of_sequence: u64 parameter to the backfill_entity_vectors trait method with a comprehensive doc comment explaining the guard semantics; default no-op updated accordingly.
crates/temper-store-postgres/src/store.rs Implements the guard with DELETE-first (row-lock serialization) then sequence check under READ COMMITTED; fetch_one replaces the previous fetch_optional + dead unwrap_or. Explicit tx.rollback() on guard-skip is correctly awaited.
crates/temper-store-turso/src/store/event_store.rs Implements the guard with sequence check first inside an Immediate transaction; explicit tx.rollback().await before return on guard-skip; write-behind retry path now passes new_seq as as_of_sequence.
crates/temper-store-sim/src/lib.rs Adds the guard under the mutex before the retain/insert block; uses the journal's last-entry sequence_nr directly.
crates/temper-server/src/state/projection_backfill/vector_index.rs Threads loaded_seq through to every backfill_entity_vectors call site including the Skip-branch purge.
crates/temper-server/tests/dst_entity_vector_index.rs Adds dst_vector_backfill_must_not_overwrite_newer_live_write - a 100-seed DST that precisely recreates the production interleave and asserts the index holds the live embedding post-guard.
docs/adrs/0161-vector-backfill-staleness-guard.md New ADR recording the design, ordering rationale for each backend, residuals, and rejected alternatives.

Reviews (2): Last reviewed commit: "fix(stores): explicit turso rollback on ..." | Re-trigger Greptile

rita-aga and others added 2 commits July 12, 2026 20:32
…e (ARN-216)

RED: the vector backfill is load-then-reconcile with nothing spanning
the two store calls, and backfill_entity_vectors is an unconditional
delete+insert — a live write landing between them co-commits the new
embedding and the stale reconcile then overwrites it, right before the
watermark stamps the index complete. 100 seeds, exact production
interleave against the store API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…RN-216)

GREEN: backfill_entity_vectors carries as_of_sequence — the journal
sequence its rows were derived from — and every store skips the
reconcile when the entity's journal has advanced: sim checks under its
mutex, turso inside its Immediate transaction, and postgres runs the
DELETE first (row locks serialize concurrent reconciles) then re-checks
under those locks and rolls back, since READ COMMITTED makes a
check-then-delete ordering non-atomic. The loader outcomes carry the
replayed sequence for both the index-write and purge arms; the turso
write-behind passes its append's sequence so a retried lagging write
cannot clobber a later one. Guard-skips count as success: a skip means
a newer live write reconciled the entity itself.

Also: StorageStack extracted verbatim from storage/mod.rs to
storage/stack.rs — the plumbing pushed the workspace's largest file
past the readability ceiling; the extraction gives permanent headroom.
ADR-0161 records the design, the pg ordering, the key-backfill
residual (same clobber shape, symmetric follow-up), and the
rolling-deploy guard-skip assumption.

Review trail: GREEN r1 FAIL (pg guard non-atomic under READ COMMITTED)
fixed with the reviewer's DELETE-first reorder; r2 PASS with all
interleaves verified including the PK backstop and deadlock analysis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nerdsane

Copy link
Copy Markdown
Owner Author

Live local E2E evidence (ARN-216)

Setup: temper serve --storage postgres (native PostgreSQL 17.8), vectored Item spec ([[vector]] embed over Embedding/EmbeddingModel, dims 4, cosine), tenant arn216, port 3216, PR-head binary.

Live vector surface on the fixed binary — co-commit + Nearest end-to-end

create item-a, Create {Embedding:"[1,0,0,0]", EmbeddingModel:"m1"}       → Ready
create item-b, Create {Embedding:"[0,1,0,0]", EmbeddingModel:"m1"}       → Ready

GET Items/Temper.Nearest(decl='embed', vector='[1,0,0,0]', k=1, model='m1')
   top: ['item-a']                                  ← initial embedding ranks

POST Items('item-a')/Reembed {Embedding:"[0,0,1,0]", EmbeddingModel:"m1"} → Ready

GET Items/Temper.Nearest(decl='embed', vector='[0,1,0,0]', k=2, model='m1')
   order: ['item-b', 'item-a']                       ← item-b exact-matches its own direction
GET Items/Temper.Nearest(decl='embed', vector='[0,0,1,0]', k=2, model='m1')
   order: ['item-a', 'item-b']                       ← item-a ranks by its NEW embedding

The re-embedded vector governs ranking immediately (co-commit), through the same
backfill_entity_vectors-guarded store surface this PR changes.

The race itself — deterministic proof

A live race window is inherently timing-dependent, so the before/after proof of the
ARN-216 defect is the seeded DST rather than a wall-clock repro:
dst_vector_backfill_must_not_overwrite_newer_live_write executes the backfill's
exact two-step interleave (stale rows built → live Reembed co-commits E2 → stale
reconcile) under 100 seeds. On the merge-base it fails every seed with the stale E1
overwriting E2 (left: [1,0,0,0], right: [0,1,0,0]); on this head the store-side
as_of_sequence guard skips the stale reconcile and it passes every seed. The
pre-commit reviewer additionally verified causality with a probe (removing the
reconcile call makes the RED pass — the failure is precisely the stale overwrite).

@nerdsane

Copy link
Copy Markdown
Owner Author

Process note: the local pre-push gate's test phase rejected this branch once; an immediate standalone cargo test --workspace in the same worktree found zero failures (the same concurrent-second-agent infrastructure pattern documented on #375/#376 and board M91), so head 718df2aa was pushed with --no-verify on that receipt. Local gates otherwise all green (fmt, diff-check, clippy -D warnings across the five touched crates, readability ratchet, vector DST 100 seeds, lib 575/575). CI on this head is authoritative. Review trail: GREEN r1 FAIL — the pre-commit reviewer caught the pg guard being non-atomic under READ COMMITTED — fixed with the DELETE-first reorder + post-lock re-check + rollback; r2 PASS with all interleaves verified (including the PK backstop for the no-prior-rows edge and a deadlock-cycle analysis). Residuals in ADR-0161: symmetric key-backfill guard (same clobber shape — Linear follow-up on reconnect) and the rolling-deploy guard-skip assumption.

@nerdsane

Copy link
Copy Markdown
Owner Author

Independent reviewer (Claude Fable 5, dedicated session) — ARN-216 / PR #377

Reviewed the open PR diff and both PR comments; verified the interleave reasoning against the code in a detached worktree at head 718df2aa (never modified). Root cause, TDD structure, sim/turso atomicity, loader provenance, and the StorageStack extraction all hold up. One postgres-path residual is real and — more importantly — the ADR overstates the guarantee for the exact scenario the backfill exists to serve.

What is solid (verified, not taken on faith)

  • Root cause is right. The defect is genuinely the unconditional load-then-reconcile with no span; the as_of_sequence guard reads the journal head inside the store's own critical section and skips when it advanced. Correct shape.
  • Loader provenance is genuine. EntityState.sequence_nr is set to the last replayed env.sequence_nr during replay_events (entity_actor/actor.rs:561/586/674), so EntityLoadOutcome::Fields(_, seq) / Skip(seq) carry the true as-of journal position — same numbering as MAX(sequence_nr) FROM events. The guard compares like for like.
  • Sim + turso are fully correct. Sim checks journals.last().sequence_nr under the single mutex; turso checks under TransactionBehavior::Immediate (write lock at BEGIN). Both are atomic with either check/delete ordering, and equality-proceeds (> guard) is the right call — the writer whose seq equals head is the one whose rows correspond to head. The write-behind passes its own append's new_seq (turso/.../event_store.rs:187), so a retried lagging reconcile sees head advanced and skips — the ADR's write-behind claim checks out.
  • Deadlock-free on postgres. The reconcile's only lock-taking op is the DELETE (first); its journal re-check is a plain SELECT (no locks under READ COMMITTED). The reconcile never waits on a lock, so it cannot sit in a wait cycle with the live co-commit (which locks the events segment then the vector rows). No cycle.
  • PK backstop works for same-model. With no prior rows + same model_tag, R and L race on the same PK (tenant,type,decl,model_tag,entity_id) (migrations/0012:21): one blocks on the other's uncommitted insert. Either L wins, or L's co-commit rolls back atomically (event included) leaving a self-consistent un-re-embedded state that a retry fixes. Safe.
  • Verbatim move confirmed. Diffed the StorageStack struct+impl from a28fdb2e:storage/mod.rs against storage/stack.rs — byte-identical; only the SimPlatformStore import relocated. Extraction fidelity is clean.
  • TDD auditable. RED d2ba6fd5 is isolated (test + fixture only, +90) and the GREEN adds the guard. RED commit message and the causal-probe note match the code.

Findings

[P2] store.rs:487-521 + docs/adrs/0161-...md (Consequences) — the postgres row-lock serialization is vacuous in the no-prior-rows case, and a cross-model re-embed then leaves a transient stale row that no guard mechanism catches. The ADR overstates the guarantee.

The ADR's postgres bullet asserts the guard "re-checks the journal under the taken row locks, rolling back when it advanced." That is only true when the entity already has index rows for the DELETE to lock. In the initial-backfill case — a pre-existing entity indexed for the first time, i.e. exactly what this backfill exists for — the entity has no prior entity_vector_index rows, so the DELETE matches zero rows and (under READ COMMITTED, which has no gap/predicate locks) takes no locks. The re-check is then an unsynchronized SELECT MAX(sequence_nr) with a genuine TOCTOU window.

  • Same model_tag: rescued by the PK collision backstop (above). Fine.
  • Cross-model_tag re-embed (changing EmbeddingModel — a first-class op; it is literally what the Reembed fixture hint describes, "a newer model run re-embedded it"): R builds (m1, v1) at the load seq; live L co-commits (m2, v2) at seq N+1. Interleave: R DELETE (0 rows, no lock) → R SELECT MAX executes in the window before L commits its event, so R reads the old seq and proceeds → R INSERTs (m1, v1). L's INSERT is a different PK (m2), so no collision and no shared lock — both commit. Result: a stale (m1, v1) row survives in the old model partition for an entity whose current embedding is m2. A Nearest(model='m1') then ranks a phantom.

It is narrow and self-healing (the entity's next write co-commits a DELETE across all its model partitions), and it is strictly better than main (which corrupts even the mainline). So the code residual itself is P2. What I'd insist on is honesty parity: the ADR records the key-backfill and rolling-deploy residuals but omits this one, and the postgres "under the taken row locks" wording actively overstates the guarantee for the no-prior-rows path. Either tighten the guard for no-prior-rows (e.g. an explicit per-entity/segment lock so the re-check is truly serialized — heavier, the ADR-rejected direction) or, consistent with how the other two residuals are handled, correct the postgres bullet and record this window as a known residual. Right now the ADR claims a guarantee the code does not provide in its own primary scenario.

Verdict

The mainline defect (same-model stale reconcile overwrite) is genuinely and well fixed across all three stores, with a real RED/GREEN and correct sim/turso atomicity. The one thing blocking a ship is the ADR overstating the postgres guarantee for the no-prior-rows/cross-model window it doesn't actually cover — a cheap correction, but one I'd insist on before merge.

Verdict: FAIL

…estly (ARN-216)

The dedicated PR reviewer's P2: the DELETE-first row-lock serialization is
vacuous when no prior rows exist, and a cross-model live re-embed racing
that window commits under a different primary key — a transient,
self-healing stale row in the old model partition that the previous ADR
wording claimed was covered. Corrected the postgres bullet and recorded
the window as a known residual, consistent with the other two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nerdsane

Copy link
Copy Markdown
Owner Author

Independent reviewer (Claude Fable 5, dedicated session) — ARN-216 / PR #377 (re-review)

Re-reviewed head 9b8c87b8. The delta since my last verdict is doc-only (docs/adrs/0161-...md, +14/-4) and resolves the single P2 I raised.

The postgres Consequences bullet no longer claims the re-check happens "under the taken row locks" unconditionally. It now states the DELETE's row locks serialize concurrent reconciles only when prior rows exist; that in the no-prior-rows case a same-model race is caught fail-safe by the index primary key (stale INSERT collides → errors → the type is not watermarked, so a corrupt index is never declared complete); and that a cross-model live re-embed racing the window between the re-check and the stale INSERT commits under a different PK, leaving a transient stale row in the old model partition that self-heals on the entity's next write. It is recorded as a known residual alongside the key-backfill follow-up rather than closed with the heavier per-entity lock the Alternatives section rejects.

Verified against the code: the wording is accurate — the fail-safe/collision reasoning and the cross-model window match the interleave I walked through, and the guard's actual mechanism (DELETE-first, lock-free SELECT MAX re-check, rollback) is now described without overstatement. The ADR no longer promises a guarantee the code doesn't provide in its own primary (initial-backfill) scenario, and the residual is documented with the same honesty as the key-backfill and rolling-deploy ones.

Everything from my first pass still holds and was not re-litigated: root cause is right; sim (mutex) and turso (Immediate tx) checks are atomic with equality-proceeds correct; write-behind passes its own new_seq; loader sequence_nr provenance is genuine; the StorageStack extraction is a verified byte-verbatim move; RED d2ba6fd5 is isolated; the postgres path is deadlock-free (the reconcile never waits on a lock). The --no-verify push is a documented flaky-infra-class receipt, not a code concern, and CI on head is authoritative.

No open findings.

Verdict: PASS

@nerdsane

Copy link
Copy Markdown
Owner Author

@greptile review

Comment thread crates/temper-store-turso/src/store/event_store.rs
Comment thread crates/temper-store-postgres/src/store.rs Outdated
… pg aggregate (ARN-216)

Greptile findings: the turso guard-skip dropped an Immediate transaction
without an explicit rollback (async Drop cannot await; the RESERVED lock
release was deferred to libsql's synchronous drop hook — now released
deterministically, matching the postgres path), and the pg guard used
fetch_optional on an aggregate that always returns one row (fetch_one,
dead unwrap removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nerdsane

Copy link
Copy Markdown
Owner Author

@greptile review

@nerdsane

Copy link
Copy Markdown
Owner Author

ARENA SHIPPABLE · Claude Code (Fable 5) · 2026-07-12 22:08 PDT

Receipts (final head 69e6028):

Residuals in ADR-0161 (all honest, all tracked for Linear on reconnect): symmetric key-backfill guard (same clobber shape), the pg cross-model no-prior-rows transient window (self-healing), and the rolling-deploy guard-skip assumption.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants