Skip to content

TASK 3: Add SQLite concurrency/stress tests and fix transaction gaps - #324

Open
Trapa-Eureka wants to merge 3 commits into
alphaXiv:mainfrom
Trapa-Eureka:task-03-sqlite-concurrency
Open

Trapa-Eureka wants to merge 3 commits into
alphaXiv:mainfrom
Trapa-Eureka:task-03-sqlite-concurrency

Conversation

@Trapa-Eureka

@Trapa-Eureka Trapa-Eureka commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Split out of #317 per request, so each task is independently reviewable/testable. Part 3 of 10, stacked in dependency order (this task builds on the previous ones' code).

What this does

Audited every multi-statement Store method. Fixed a genuine TOCTOU in record_chat_spawn_attempt (separate UPDATE then SELECT -- a concurrent caller's own increment could land in the gap) with one atomic UPDATE ... RETURNING attempts. Added transaction boundaries around several other multi-statement sequences and concurrency stress tests exercising them under real contention.

About the diff

This branch is stacked on top of the prior task's branch in this split (not yet merged into main), so until that earlier PR merges, this PR's diff shows its commit too, on top of this task's own commit. The Commits tab lists this task's commit separately if you want to review just that one. I'll rebase this branch (and the diff will shrink to just this task) as each earlier PR in the stack lands.

Verification

Passed on this exact rebased state: cargo fmt --all --check, cargo clippy --all-targets -- -D warnings, cargo test --locked (870/870), and for frontend-touching commits pnpm typecheck, pnpm test, pnpm build with ui/dist regenerated.

Stack

Full list and status: see #317 (will be updated to link all 10, or closed once these replace it).

Trapa-Eureka and others added 3 commits September 14, 2026 16:20
* Formalize run lifecycle as an explicit state machine (TASK 1)

Runs previously carried status as a bare `String`, and `Store::update_status`
was an unconditional `UPDATE ... WHERE id = ?1` with no transition checks —
any caller could overwrite any status with any other status, and three
independent copies of `is_terminal` had to be kept in sync by hand.

Add `RunStatus` (store.rs): Starting -> Running -> {Done, Failed, Cancelled},
with Starting also able to jump straight to a terminal state. Terminal states
are absorbing and `RunStatus::can_transition_to` is the single source of
truth for legality.

`Store::update_status` now takes a typed `RunStatus` and derives its SQL
guard from `can_transition_to` (`WHERE status IN (<legal sources>)`) so the
check-and-write is atomic and can never drift from the documented table. It
returns `Result<bool>`: `false` means the transition was illegal (almost
always because the run was already terminal), which is an expected, benign
outcome rather than an error — a duplicate completion callback, or a
completion racing a cancellation, is now a no-op instead of a corruption.

Threaded the typed status through `jobs::stage_to_run_status`,
`supervise::run_status_for_stage`, and all 8 backend poll loops (HF, k8s,
Modal, ssh, Slurm, Ray, local, OpenResearch), replacing stringly-typed
statuses with compile-time-checked ones. Consolidated the three duplicate
`is_terminal` functions (local/mod.rs, commands/serve.rs, commands/up.rs)
into one canonical `store::is_terminal_status`.

Added deterministic tests covering the doc's listed scenarios: the full
transition matrix, duplicate terminal writes, completion arriving after
cancellation, invalid backward transitions (running -> starting), cancel
while starting/running, retry-as-a-new-row, and concurrent writers (separate
SQLite connections racing to finalize the same run) settling on exactly one
terminal outcome.

No wire/schema changes — RunStatus::as_str() matches the existing stored
vocabulary exactly, so the UI and API are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FESDZN8TW7HzGCLnP6Y5vB

* chore: trigger CI now that Actions is enabled on the fork

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FESDZN8TW7HzGCLnP6Y5vB

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
A supervisor's crash (OOM-killed, host reboot) or a failed spawn attempt
previously left its run stuck `Starting`/`Running` forever: startup
reconciliation in `orx up` was a one-shot pass, and a `spawn_detached_supervise`
failure — there or anywhere else it's called — was only ever logged to
stderr and dropped, with no retry and no status change.

Add `commands::exp::reconcile_active_runs`: for every locally-owned active
run, check whether a supervisor is actually alive and watching it via
`supervise::run_has_live_supervisor` — a non-blocking probe of the same
`fd_lock` file a running supervisor holds. The lock is an OS-level advisory
lock, released automatically the instant the holding process dies or the
machine reboots, so this is a race-free, always-fresh liveness signal with
no heartbeat/TTL of our own to go stale. An orphaned run gets a fresh
supervisor; once respawned, that supervisor's own `inspect_job` reconciles
against the actual backend the normal way — reconciliation's only job is
making sure *someone* is always eventually watching.

A run whose supervisor can't be spawned after `MAX_SUPERVISOR_SPAWN_ATTEMPTS`
(5) consecutive tries — a broken installation, not a transient blip — is
marked unrecoverable via the new `Store::mark_run_unrecoverable`, which
stamps a machine-readable `recovery_reason` (a new nullable `runs` column,
excluded from `upsert_run`'s ON CONFLICT like `chat_session_id`, so a later
backend write can't clobber it) and appends a human-readable note to
`result_markdown`. It goes through `update_status`'s existing terminal-state
guard (TASK 1), so it can never overwrite a run that reached a real outcome
first.

Wired into `orx up`: the existing startup pass now calls
`reconcile_active_runs` instead of spawning unconditionally, and a new
30-second periodic task (mirroring the existing chat-turn-lease reconciler,
including its data-dir-move gate) keeps calling it for the life of the
process, so a supervisor that dies mid-flight — not just one dead before
`orx up` started — gets noticed and replaced without needing a restart.

Tests added: the lock-probe's three liveness transitions (free, held,
released) in `commands::supervise`; `mark_run_unrecoverable`'s terminal-write,
never-overwrites-a-real-outcome, and idempotency behavior in `store`; and
`reconcile_active_runs`'s full decision matrix (skip when watched, spawn when
orphaned, ignore non-local runs, retry-then-give-up with the counter cleared
either way, no counter leak for runs that leave the active set) in
`commands::exp`, all via the same injected-closure style already used by
`request_local_run_cancel_with`.

Scope note: this covers orphan detection and give-up semantics generically,
across every backend, by making sure a supervisor is always eventually
running. It does not change the per-backend `inspect_job` behavior itself —
five of seven backends still retry a transport failure (an unreachable ssh
host, a down k8s API) forever with no cap, unlike Slurm/Ray's ~60s "GONE"
debounce; generalizing that belongs to TASK 4 (compute backend contracts).
It also does not add a full process-kill/server-restart integration test
(spawning a real `orx supervise` child and killing it) — this repo has no
integration-test harness for the Rust binary yet, and building one is a
larger, separate investment; the unit tests above cover the same invariants
(lock release, orphan detection, give-up) at the mechanism level instead.


Claude-Session: https://claude.ai/code/session_01FESDZN8TW7HzGCLnP6Y5vB

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…#5)

Implements Priority 7 from OpenResearch_Improvement_Priorities.md.

Correctness fixes found while auditing every multi-statement Store method:

- `record_chat_spawn_attempt` was `UPDATE attempts = attempts + 1` followed
  by a separate `SELECT attempts` — a genuine TOCTOU race: a concurrent
  caller's own increment could land in the gap, so the value read back could
  be *their* count, not this call's. Replaced with one atomic
  `UPDATE ... RETURNING attempts` (the bundled SQLite is 3.35+, so this is
  available) — no transaction needed, no race possible.
- `prune_run_wakeups`, `prune_chat_spawns`, `claim_chat_turn`, and
  `claim_data_dir_move` each issued 2-3 sequential statements against the
  same connection with no transaction boundary. None were live bugs today
  (each statement's own WHERE clause already prevented double-claims), but
  none had the "all or nothing" guarantee a future added step could safely
  assume either. Wrapped each in `self.begin()`/`commit()`.
- `delete_local_project` mixed `tx.execute` and `self.conn.execute` for its
  last three deletes — already atomic in practice (same connection, same
  open transaction) but a maintainability trap: nothing but convention kept
  those three statements inside the transaction's scope. Normalized to
  `tx.execute` throughout.

Performance fix: `runs` had no index beyond its primary key. Every poll of
active runs — including the TASK 2 reconciliation loop, every 30s — plus
listing by project/experiment and the newest-first history view all
table-scanned. Added `idx_runs_status`, `idx_runs_project_id`,
`idx_runs_experiment_id`, and `idx_runs_created_at` (`CREATE INDEX IF NOT
EXISTS`, applied retroactively to existing databases on next open, same
pattern as the existing chat-table indexes).

WAL mode + a 5000ms busy_timeout (`Store::open_at_with_move_lock`) were
already the sole, centrally-configured defense against `SQLITE_BUSY`, used
uniformly by every `Store::open()`/`open_at()` caller — reviewed and left
as-is; this is the correct, already-standardized configuration Priority 7
asks to check for.

Tests added (`src/store.rs`), matching the file's existing style of real
threads/connections rather than mocking:

- `parallel_experiment_creation_all_land_with_no_sqlite_busy_errors` — 32
  threads, each its own connection, creating distinct experiments at once.
- `concurrent_readers_are_never_blocked_by_a_concurrent_writer` — one writer
  hammering `update_status`/`set_result_markdown` on a run while four
  readers loop `get_run`/`list_runs`; no read may ever error, and every
  reader must eventually observe the final terminal status.
- `an_uncommitted_transaction_rolls_back_on_drop` — a dropped, uncommitted
  `Transaction` (including one that failed partway through a second
  statement) leaves no trace, proving the rollback guarantee every
  `self.begin()`-based method here depends on.
- `concurrent_spawn_attempt_increments_never_collide` — 16 concurrent callers
  of the newly-atomic `record_chat_spawn_attempt` must get back exactly
  `{1..16}`, not a multiset with collisions.
- `ten_thousand_sequential_status_writes_complete_quickly` and
  `active_run_queries_stay_fast_against_a_large_runs_table` — hand-rolled
  `Instant`-based timing sanity checks (no benchmark harness exists in this
  repo yet) against a 20k-row `runs` table, to catch a severe regression
  (an unintended fsync, a missing/regressed index) rather than pin an exact
  number.

CI finding: the first version of the new read/write-contention test used a
200-write hot loop with no pacing and a 10s/10,000-write timing bound. Both
passed on Linux/macOS but failed on the Windows CI runner — sequential
writes measured ~2.4ms each there (vs. sub-millisecond locally), and the
unpaced hot loop hit genuine `SQLITE_BUSY` ("database is locked") even with
the 5s `busy_timeout`. Retuned to a realistic polling cadence (small sleeps,
fewer iterations) with a short retry-with-backoff around reads for the rare
case that's still not enough, and widened the timing bounds to match
observed slow-CI-hardware throughput rather than local timing. Confirmed
green on Linux, macOS, and Windows CI before merging.

Scope note: experiment creation also creates a Git branch
(`local::experiments::create_experiment`) before the DB row is inserted,
with no compensation if the DB write then fails — this is pre-existing,
already-accepted best-effort behavior (Git and DB state are never kept
transactionally consistent anywhere in this codebase; branch publication to
GitHub is explicitly best-effort too, per `local_plane.rs`). Bringing that
under an explicit rollback/compensation model is a larger design change than
this task's "add tests, fix transaction boundaries at the SQL layer" scope.


Claude-Session: https://claude.ai/code/session_01FESDZN8TW7HzGCLnP6Y5vB

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@Trapa-Eureka

Copy link
Copy Markdown
Contributor Author

Stack (review/merge in this order)

  1. TASK 1: Formalize run lifecycle as an explicit state machine #322 -- TASK 1: run lifecycle state machine
  2. TASK 2: Add crash recovery and run reconciliation #323 -- TASK 2: crash recovery and reconciliation
  3. TASK 3: Add SQLite concurrency/stress tests and fix transaction gaps #324 -- TASK 3: SQLite concurrency/stress tests
  4. TASK 4: Standardize compute backend contracts #325 -- TASK 4: compute backend contracts
  5. TASK 5: Add error classification for run failures #326 -- TASK 5: error classification
  6. TASK 6: Strengthen experiment provenance metadata #327 -- TASK 6: experiment provenance metadata
  7. TASK 7: Decompose App.tsx: extract self-contained hooks and pure helpers #328 -- TASK 7: App.tsx decomposition
  8. TASK 8: Split CLI schema/argument definitions by domain out of main.rs #329 -- TASK 8: CLI schema split
  9. TASK 9: Reduce generated frontend artifact review noise #330 -- TASK 9: generated artifact review noise
  10. TASK 10: Clarify and harden the remote security model #331 -- TASK 10: remote security hardening

Superseded #317 (the original single combined PR) -- closed in favor of this stack.

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.

1 participant