fix(stores): guard vector backfill against stale overwrites (ARN-216) - #395
fix(stores): guard vector backfill against stale overwrites (ARN-216)#395rita-aga wants to merge 3 commits into
Conversation
…RN-216) RED: backfill is load-then-reconcile with nothing spanning the two store calls, and backfill_entity_vectors is an unconditional delete+insert. A live write between them co-commits the new embedding; the stale reconcile then overwrites it before the watermark stamps the index complete. 100 seeds, exact production interleave against the store API.
Sequence-monotonic vector reconcile + ADR-0173. Completes GREEN after RED DST.
|
@greptile review |
ARENA SHIPPABLE · Grok · 2026-07-14 11:57 PDTPR: #395 Checklist
Summaryvector backfill staleness |
| .await | ||
| { | ||
| EntityLoadOutcome::Fields(fields) => { | ||
| EntityLoadOutcome::Fields(fields, _loaded_seq) => { |
There was a problem hiding this comment.
Key backfill staleness guard not wired up
_loaded_seq is intentionally discarded here, leaving the key-index backfill with the exact same load-then-write race this PR fixes for vectors. The ADR (section "Consequences") documents this as a known residual: a stale upsert landing after a concurrent live key write will clobber the newer mapping, and the completion watermark will then treat the stale entry as authoritative. The impact is worse than the vector case — a wrong key mapping can route entity lookups to the wrong entity (or produce a false-absence hit) until the entity's next write corrects it. The ADR says a "Linear issue on reconnect" is tracked for the symmetric follow-up; confirming that issue exists and is prioritized before watermarking any type with key declarations would reduce the window this is exploitable.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/state/projection_backfill/key_index.rs
Line: 144
Comment:
**Key backfill staleness guard not wired up**
`_loaded_seq` is intentionally discarded here, leaving the key-index backfill with the exact same load-then-write race this PR fixes for vectors. The ADR (section "Consequences") documents this as a known residual: a stale upsert landing after a concurrent live key write will clobber the newer mapping, and the completion watermark will then treat the stale entry as authoritative. The impact is worse than the vector case — a wrong key mapping can route entity lookups to the wrong entity (or produce a false-absence hit) until the entity's next write corrects it. The ADR says a "Linear issue on reconnect" is tracked for the symmetric follow-up; confirming that issue exists and is prioritized before watermarking any type with key declarations would reduce the window this is exploitable.
How can I resolve this? If you propose a fix, please make it concise.
Independent code review — PR #395 (ARN-216 / ADR-0173)Scope reviewed: full diff ( Plan alignment
No problematic deviations. What was done well
FindingsCritical (must fix)None. Important (should fix)None that block this PR’s stated contract. Documented residuals (already in ADR; not regressions introduced silently):
Suggestions (nice to have)
Architecture / quality notes
CI (at review time)Compile & Lint, Integrity & DST Patterns, Spec Verification, DST/Platform (core/boot/consistency/random), Instrumentation Hygiene, Verification Contract: pass. Full Verdict: PASS |
|
@greptile review |
|
@greptile review |
|
Addressed: non-wrapping |
Grok arena ARN-216. ADR-0173. RED tests on branch. GREEN may still be incomplete in worktree. No merge.
Greptile Summary
This PR introduces a staleness guard on
backfill_entity_vectors(ADR-0173 / ARN-216): each reconcile now carries the journal sequence its rows were derived from, and each store implementation skips the write when the entity's journal has advanced past that sequence, preventing a stale backfill load from clobbering a newer live co-commit. TheStorageStacktype is also extracted into its ownstack.rsmodule in line with the 500-line file-split rule.EntityLoadOutcome::FieldsandSkipnow carry the replaysequence_nr; the vector-index backfill threads it through asas_of_sequence; all three store implementations (Postgres, Turso, Sim) enforce the skip inside their existing reconcile transaction/lock, making the check atomic.u64::try_from(current_seq).unwrap_or(0)and a DELETE-first ordering to acquire row locks before re-reading the journal under READ COMMITTED.dst_vector_backfill_must_not_overwrite_newer_live_write) exercises the exact two-step production interleave against the Sim store; the PR description notes test suite status as "RED" on this branch, so merge should be blocked until green.Confidence Score: 4/5
The core guard logic is sound across all three store implementations, but the Turso store retains an unchecked
i64 as u64cast in the staleness comparison (flagged in a prior review round and not yet addressed), and the test harness has two structural issues that could silently weaken the determinism guarantees the seed loop is meant to provide.The Postgres implementation correctly fixed the cast (
u64::try_from) and the guard ordering is well-argued in the ADR. The Turso store'scurrent_seq as u64remains — a negative value (unlikely but unguarded) wraps to a very largeu64and would make the guard fire on every reconcile, silently dropping all backfill writes. Until that and the two test-harness issues are addressed, the change is not quite ready to merge; the PR description's own "No merge" instruction is consistent with this.crates/temper-store-turso/src/store/event_store.rs(unchecked cast at line 248) andcrates/temper-server/tests/dst_entity_vector_index.rs(actor-system re-use and undrained probe actor across seed iterations).Important Files Changed
as_of_sequence: u64to thebackfill_entity_vectorstrait method with a clear doc comment; default no-op implementation correctly discards the new parameter.EntityLoadOutcome::FieldsandSkipto carry the replay sequence number; both arms correctly propagatestate.sequence_nrfrom the loaded entity state.loaded_seqfrom bothFieldsandSkipoutcomes through tobackfill_entity_vectors, guarding both the index-write and the tombstone-purge arms._loaded_seq, leaving the key-index backfill unguarded — the same load-then-write race this PR fixes for vectors remains open here (ADR-0173 documents it as a known residual).u64::try_from(current_seq).unwrap_or(0)correctly. Existing ADR-documented residuals remain (cross-model race window, misleading row-lock comment).current_seq as u64(unchecked truncation of an i64) rather thanu64::try_from. The write-behind retry loop correctly threadsnew_seq.sequence_nrusing the correct{tenant}:{entity_type}:{entity_id}key.as_of_sequencethrough the tenant router to the underlying store — no logic change.as_of_sequencethroughDynEventStoreandBoxedEventStoreadapter layers;StorageStackextracted tostack.rsverbatim, respecting the 500-line file-split rule.StorageStackextraction; correctly re-imports the#[cfg(feature = "sim")]gatedSimPlatformStorethat was also moved.dst_vector_backfill_must_not_overwrite_newer_live_write— a 100-seed DST correctly simulating the two-step race; probe actor acquisition ofstale_seqand the subsequent liveReembeddispatch correctly model the production interleave againstSimEventStore.Reembedself-loop transition onReady→Readyto support the new DST; parameters matchCreateso the same embedding-model pair can be updated.u64::MAX(force-reconcile sentinel) where direct reconciles are known-current — idiomatic use of the new API.Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant BF as Backfill Worker participant S as Entity Store participant EV as events table participant VI as entity_vector_index Note over BF,VI: Happy path — no concurrent live write BF->>S: "load_entity_current_fields(entity_id) → Fields(fields, seq=S1)" S->>EV: "snapshot/replay → sequence_nr=S1" BF->>S: "backfill_entity_vectors(..., as_of_sequence=S1)" S->>EV: SELECT MAX(sequence_nr) → S1 Note over S: S1 > S1 = false → proceed S->>VI: DELETE rows for entity S->>VI: INSERT new rows from load S-->>BF: Ok(()) Note over BF,VI: Race — live write lands between load and reconcile BF->>S: "load_entity_current_fields(entity_id) → Fields(fields, seq=S1)" Note over S,EV: Live write appends event + co-commits vectors at seq=S2 S->>EV: "append event → sequence_nr=S2" S->>VI: "write-behind: backfill_entity_vectors(..., as_of_sequence=S2)" BF->>S: "backfill_entity_vectors(..., as_of_sequence=S1)" S->>EV: SELECT MAX(sequence_nr) → S2 Note over S: S2 > S1 = true → SKIP (guard fires) S-->>BF: Ok(()) [guard skipped stale write]%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant BF as Backfill Worker participant S as Entity Store participant EV as events table participant VI as entity_vector_index Note over BF,VI: Happy path — no concurrent live write BF->>S: "load_entity_current_fields(entity_id) → Fields(fields, seq=S1)" S->>EV: "snapshot/replay → sequence_nr=S1" BF->>S: "backfill_entity_vectors(..., as_of_sequence=S1)" S->>EV: SELECT MAX(sequence_nr) → S1 Note over S: S1 > S1 = false → proceed S->>VI: DELETE rows for entity S->>VI: INSERT new rows from load S-->>BF: Ok(()) Note over BF,VI: Race — live write lands between load and reconcile BF->>S: "load_entity_current_fields(entity_id) → Fields(fields, seq=S1)" Note over S,EV: Live write appends event + co-commits vectors at seq=S2 S->>EV: "append event → sequence_nr=S2" S->>VI: "write-behind: backfill_entity_vectors(..., as_of_sequence=S2)" BF->>S: "backfill_entity_vectors(..., as_of_sequence=S1)" S->>EV: SELECT MAX(sequence_nr) → S2 Note over S: S2 > S1 = true → SKIP (guard fires) S-->>BF: Ok(()) [guard skipped stale write]Reviews (3): Last reviewed commit: "fix(store-postgres): non-wrapping i64 to..." | Re-trigger Greptile