fix(replay): match replayed calls by position, not by value (audit theme 8) - #144
fix(replay): match replayed calls by position, not by value (audit theme 8)#144passcod wants to merge 6 commits into
Conversation
…ared rows do_signal scanned the whole committed log for an entry with the same resources and signal. Both halves of that are wrong. A second, identical rt.signal later in the same closure matched the first entry and was swallowed, so the signal was never delivered; and when the resolved instance set changed between passes — a replica added or retired — nothing matched, so a signal already delivered before the crash was delivered again. The log is positional, as do_exec already understood. ReplayContext::replay_step makes that the way to ask, and advancing the index is part of consuming the entry, so a caller cannot check without advancing or advance without checking. INSERT OR REPLACE is delete-then-insert: every column the statement does not name reverts to its default. save_current_operation predated the cancel_requested column, so the replay path re-saving the row silently dropped a cancellation the daemon had just read. The failure arrived with the migration, not with the statement. Every site is an ON CONFLICT DO UPDATE naming its own columns now, and a CI guard keeps the exception to action_log, whose positional overwrite is the replay contract. A queued schedule fire was stamped last_fired_at although it existed only in the scheduler's in-memory queue, so a restart lost the operation while the database said it had fired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
Code Coverage OverviewLanguages: TypeScript, Rust TypeScript / code-coverage/vitestThe overall coverage in commit 33efdd3 in the Rust / code-coverage/rustThe overall coverage in commit 33efdd3 in the Show a code coverage summary of the most impacted files.
Updated |
There was a problem hiding this comment.
Pull request overview
This PR closes audit theme 8 findings by tightening restart/replay correctness: replayed runtime calls are consumed positionally (per call site), and SQLite writes avoid INSERT OR REPLACE patterns that can reset independently-owned columns as schemas evolve.
Changes:
- Introduces
ReplayContext::replay_step(...)and portsrt.signalreplay behaviour from value-scanning to positional consumption. - Replaces multiple
INSERT OR REPLACEstatements withINSERT ... ON CONFLICT ... DO UPDATEto avoid resetting unrelated columns. - Adds spec + tests for the new replay/persistence rules and adds a CI guard (
check-insert-or-replace.sh) to prevent regressions; fixes schedule “last fired” stamping to only occur for accepted fires.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
etc/ci/check-insert-or-replace.sh |
New CI guard to block INSERT OR REPLACE outside an allowlist. |
docs/spec/runtime.md |
Adds spec requirements for positional replay matching and partial-row updates. |
crates/core/src/runtime/schedules.rs |
Stops stamping last_fired_at for queued schedule fires (only stamp accepted). |
crates/core/src/runtime/scaling.rs |
Switches scaling decision persistence to ON CONFLICT ... DO UPDATE. |
crates/core/src/runtime/history/tests.rs |
Adds regression test ensuring cancel flags survive operation re-save. |
crates/core/src/runtime/history.rs |
Fixes save_current_operation to upsert only owned columns (preserve cancel_requested). |
crates/core/src/runtime/desired.rs |
Switches dynamic resource inserts to ON CONFLICT ... DO UPDATE. |
crates/core/src/runtime/barrier/runtime.rs |
Updates rt.signal replay logic to use positional replay consumption. |
crates/core/src/runtime/barrier/replay/tests.rs |
Adds tests for positional replay semantics and mismatch detection. |
crates/core/src/runtime/barrier.rs |
Adds ReplayMismatch and implements ReplayContext::replay_step. |
crates/core/src/runtime/apps/secret_params.rs |
Switches secret param upsert to ON CONFLICT ... DO UPDATE. |
crates/core/src/runtime/apps/params.rs |
Switches param upsert to ON CONFLICT ... DO UPDATE. |
crates/core/src/runtime/apps.rs |
Switches registered_apps persistence to ON CONFLICT ... DO UPDATE. |
crates/core/src/oi/handler/apps.rs |
Switches OI-side registered_apps persistence to ON CONFLICT ... DO UPDATE. |
.github/workflows/rust.yml |
Adds CI step to run the new INSERT OR REPLACE guard. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| match g.replay_step(CallKind::Signal) { | ||
| Ok(Some(_)) => return Ok(()), | ||
| Ok(None) => {} | ||
| Err(mismatch) => return Err(Box::<EvalAltResult>::from(mismatch.to_string())), | ||
| } |
| if !self.is_replaying() { | ||
| return Ok(None); | ||
| } | ||
| let entry = self.committed[self.call_index].clone(); |
Review catch. Positional matching alone treats a script edited from SIGHUP to SIGTERM at the same position as already replayed, so the new signal is never delivered. The signal name is a literal in the script and so is stable across passes, unlike the resolved instance set — which legitimately varies when a replica is added or retired and must not be checked this way. replay_step takes the expected argument where the caller has one that is stable by construction, and ReplayMismatch distinguishes the two divergences. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
|
Both correct. One fixed in 2b1ea92, one I'm deliberately not doing blind. Signal name not validated — fixed. Right, and it's the mirror of the bug this PR is about: positional matching alone would treat a script edited from Under-consumption — real, but not a change I should make from here. You're right that nothing detects the opposite divergence: a pass that takes a different branch and makes fewer calls leaves committed entries unconsumed and completes without error. The obvious check is They aren't. An operation that errors mid-closure, one that is cancelled, and one that suspends on a new barrier before reaching the old log's end all legitimately leave entries unconsumed — and I can't tell from here which of those should be treated as divergence and which are normal. Getting that wrong turns a recoverable operation into a hard failure at exactly the moment recovery matters, which is worse than the gap. It also wants the So: worth doing, wants a decision about which termination paths must have consumed the log, and belongs with the harness rather than bolted on here. Happy to take it as a follow-up if you tell me how you'd want the error and cancel paths treated. Generated by Claude Code |
matches! on the error checks the classification; the message is what an
operator actually reads when an operation refuses to resume, and nothing
exercised it — assert!(..., "{err}") only formats on failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/core/src/runtime/barrier.rs:172
ReplayMismatch::Extra's Display message is grammatically incorrect ("making one of …") and reads oddly for a single expected value. This makes replay divergence errors harder to understand when they occur.
} => write!(
f,
"replay diverged at call {call_index}: the log records a {kind:?} of {found:?} \
but the script is making one of {expected:?}"
),
etc/ci/check-insert-or-replace.sh:35
- The CI check can miss real
INSERT OR REPLACEusages if the SQL string is split across lines (e.g. a line containing"INSERT OR REPLACEwithoutINTOwill currently be treated as prose because it contains"). That creates a bypass/false-negative in the enforcement script.
# Prose mentioning the statement (comments, test assertions) is not a use.
line="${hit#*:}"
line="${line#*:}"
[[ "$line" =~ (//|--|\") ]] && [[ ! "$line" =~ INSERT\ OR\ REPLACE\ INTO ]] && continue
skip=false
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (1)
etc/ci/check-insert-or-replace.sh:34
- The prose-skip heuristic can create false negatives: a real SQL string can contain
INSERT OR REPLACEon one line andINTOon the next (or have--in the SQL), which makes this line get skipped and the guard won’t catch a new unsafe statement. It’s safer to only skip obvious Rust comments and known test-assertion strings, rather than relying onINSERT OR REPLACE INTObeing on the same line.
# Prose mentioning the statement (comments, test assertions) is not a use.
line="${hit#*:}"
line="${line#*:}"
[[ "$line" =~ (//|--|\") ]] && [[ ! "$line" =~ INSERT\ OR\ REPLACE\ INTO ]] && continue
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (2)
etc/ci/check-insert-or-replace.sh:34
- The prose-skipping filter treats any line containing a double quote ("") as non-code unless it also contains
INSERT OR REPLACE INTO. That means a real use can evade the check if the SQL is constructed in parts (e.g.push_str("INSERT OR REPLACE")) or otherwise doesn’t includeINTOon the same line. Tighten the skip to only ignore actual comment lines and known test-assertion strings, so legitimate code hits are always flagged.
[[ "$line" =~ (//|--|\") ]] && [[ ! "$line" =~ INSERT\ OR\ REPLACE\ INTO ]] && continue
crates/core/src/runtime/barrier.rs:172
- The
ReplayMismatch::Extradisplay message is slightly confusing for operators: it prints the recorded value asSome("...")/Noneand says “making one of …”. Since this message is the primary explanation for why an operation refused to resume, it should read clearly and show the recorded argument without theOptionwrapper.
} => write!(
f,
"replay diverged at call {call_index}: the log records a {kind:?} of {found:?} \
but the script is making one of {expected:?}"
),
Skipping any line with a quote unless INTO was on that same line meant a statement whose INTO wrapped to the next line went unseen — a guard that only catches the formatting it was written against. Skip genuine comment lines only; the one assertion message that names the statement is now an explicit allowlist entry rather than an accident of the filter. Also print the recorded argument in a replay divergence rather than its Option wrapper: that message is the whole account an operator gets of why an operation refused to resume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
Both add a guard step to the lint job, so the two land side by side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
Closes cross-cutting theme 8 from the logic bug audit: restart/replay correctness around persisted state. See the pattern analysis.
The class
Seedling persists state so a crash can be recovered from. In every affected finding the write path and the restart read path were built and tested separately, and the bug lives exactly at their seam — none of them had a test that severs in-memory state between the write and the read.
Positional replay — H7
The action log is positional:
call_indexwalkscommittedin the order the closure makes its calls.do_execalways understood that.do_signaldid not — it scanned the whole log for any entry with the same(resources, signal). Both halves of that are wrong:rt.signallater in the same closure matched the first entry and was swallowed, so the signal was never delivered. "At most once" is per call site, not per value.ReplayContext::replay_step(expect)is the way to ask now: it returns the committed entry at this position orNonefor a first run, verifies the call kind, and advances the index as part of consuming the entry — so a caller cannot check without advancing or advance without checking. A log that does not correspond to the calls being made fails rather than guessing.INSERT OR REPLACEon rows with independently-owned columnsINSERT OR REPLACEis delete-then-insert: every column the statement does not name reverts to its default. That is safe only while one writer owns the whole row — and it stops being safe the moment a migration adds a column another writer owns.save_current_operationpredatedcancel_requested(added by v34) and listed columns explicitly, so it reverted the flag to 0. The replay path re-saves the row immediately after the daemon has read the cancel flag, silently dropping a cancellation while the operation it cancelled was still in flight.The failure arrived with the migration, not with the statement, so reviewing the statement alone could never have caught it. Every site is now
ON CONFLICT ... DO UPDATEnaming the columns that writer owns — including the five that list every column today and so have no live bug, because that is exactly the statecurrent_operationwas in before v34.Stamping an effect that does not exist yet
check_due_schedulescalledupsert_schedule_firedfor bothAcceptedandQueued. A queued fire exists only in the scheduler's in-memoryVecDeque, so a restart lost the operation while the database said it had fired — andr[schedule.catch-up]then had nothing to catch up on. Only accepted fires are stamped now. While a fire stays queued the next tick's re-fire is rejected asSameAppAlreadyQueuedand silently dropped, so leaving it unstamped cannot double-fire; after a restart the unstamped schedule fires again, which is the point.Findings closed
rt.signalreplay dedup is value-based, not positionalsave_current_operationsilently resets a persisted cancel requestEnforcement
r[barrier.replay.positional](a replayed call is matched to the entry at its own position, never to a similar-looking one elsewhere; at-most-once is per call site; a diverged log fails rather than guesses) andr[history.persist.partial-update](a writer must not reset columns it does not own, and must continue not to as columns are added).etc/ci/check-insert-or-replace.sh, allowlisting onlyaction_log— whose positional overwrite keyed on(operation_id, call_index)is the replay contract — and exempting migrations, which run once against a known schema.Not in scope, and the honest gap
The theme's fourth finding — dynamic resources preserved for a replay that is then abandoned — is not fixed here. Startup orphan cleanup deliberately skips
dynamic_resourcesrows matching the persisted operation, expectingreplay_interrupted_operationto adopt them; but every abort branch in that function just clears the row and returns, so nobody stops the preserved units or deletes their rows, and the reconciler ignores them by design. Fixing it means extracting those abort branches fromcrates/daemon/src/main.rsinto core behind an injected teardown, which is a real refactor of daemon startup rather than part of this one.That is also the finding that most wants the
RestartWorldharness the analysis describes — a harness that rebuilds all in-memory state between passes, keeping only the DB and the world. The existingrun_operationtests exercise suspension, not restart: they reuse the same engine, scope,Appand registry across passes, so no in-memory state is ever dropped. The tests here are unit-level onreplay_stepand the DB, which pin the two mechanical rules but not the operation-level restart behaviour. The harness is the element that generalises to the next persisted-state feature and is the natural follow-up.The other
do_*calls (do_write,do_start,do_stop,do_query,record_subaction_entry) andcheck_barrier'salready_satisfiedlookup are not yet ported toreplay_step, socommittedstays public for now — making it private is what would prevent a value-based scan being reintroduced, and it should follow once they are.Overlap with other themes
Independent — sits on
main. It touchesINSERT OR REPLACEinoi/handler/apps.rs, which theme 2 (#138) also edits nearby; expect a small merge.Generated by Claude Code