Skip to content

refactor(storage): run store writers and readers on one fixed pool - #4242

Merged
ScriptedAlchemy merged 17 commits into
masterfrom
refactor/storage-shared-executor
Oct 18, 2026
Merged

ScriptedAlchemy merged 17 commits into
masterfrom
refactor/storage-shared-executor

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Why

Every attached SQLite file owned one writer thread and up to three reader threads, so 30 mounted projects cost about 244 threads at rest. Step 7 of the storage redesign (docs/plans/storage/README.md) runs every store on one fixed pool, so the thread count no longer grows with the number of files. This is the pool slice of that step. The fenced TRUNCATE, idle close, unlink probe and ProfileLost follow in later slices.

Scope

  • storage_pool (new) runs one asupersync multi-thread runtime of tracedecay-store-N threads with no blocking pool. N is the host's available parallelism clamped to 6..=16; TRACEDECAY_STORE_THREADS overrides it (clamped to 2..=16).
  • The writer Worker and each reader worker are pool tasks. Idle waits, batch dwell, snapshot polls, busy-begin retries and WAL-recovery prepare retries are awaits on the pool timer. Deferred snapshot ends run as pool tasks instead of -reader-return and -reader-retire threads.
  • A synchronous store wait issued from a pool thread refuses with the existing unavailable errors in every build profile: writer start, reply waits, reader acquire and snapshot calls, task join, drain, and shutdown_and_join. Work the pool refuses to spawn is adopted by the task that is polling, so it is never dropped.
  • native_runtime::handle, cx and on_native_thread treat a pool thread as not native.
  • FairQueue, batching, the ledger, the checkpoint controller and the step 6 fence are unchanged.

Tradeoffs

The design's default of 6 threads failed measurement. At N=6 the //crates/tracedecay:unit_test binary, which opens many stores at once in one process, timed out cold opens in 3 of 5 runs and ran 24% slower than master. At N=16 it passed 3 of 3. No thread is reserved for foreground classes yet, because today's files mix classes (Work shares sessions.db with observation ingest); the reservation lands with the step 8 partition.

Blast Radius

Every store open, read and write in the daemon and CLI now runs on the pool. A long statement holds a pool thread for its duration, so N concurrent long statements delay other stores until a thread frees.

Verification

  • sixty_four_attached_stores_run_on_the_fixed_storage_pool: 64 attached stores add exactly N threads, all of them pool threads. Mutation: one writer on an unnamed std::thread fails it.
  • Refusal and adoption tests for each sync wait on a pool thread, and a_writer_query_rides_out_a_file_another_connection_briefly_locks for the WAL-recovery retry. Each fix was removed in turn and its test failed.
  • //crates/tracedecay:unit_test --runs_per_test=3 --nocache_test_results: master 177 s avg, this head 171 s avg, 3 of 3 pass.
  • Bazel after merging master: rusqlite-runtime unit_test and rusqlite_suite, application unit_test and application_suite, runtime-core unit_test and runtime_core_suite, store-runtime and daemon-service unit_test, //crates/tracedecay unit_test, daemon_suite, mcp_suite and session_suite all pass. Clippy (--config=clippy) and rustfmt_check_2024 pass on both touched crates.

Devin Review

Every attached SQLite file owned a writer thread and up to a budget of
reader threads, about 244 threads for 30 projects at rest. Writers and
readers are now tasks on one storage runtime of N tracedecay-store
threads (TRACEDECAY_STORE_THREADS, default 6, clamped 2..16).

The writer loop's channel wait, batch dwell and interactive exact-SQL
transaction wait, the reader's command and snapshot waits, the deferred
snapshot-end return and retire, and the requested checkpoint's wait for
released snapshots all await instead of parking a thread. Synchronous
waits that callers use refuse on a pool thread in debug builds, and a
task keeps its own lock-work, VM and test-clock thread-locals.
An async_channel receiver keeps its queued messages after it drops
while any sender lives, unlike the std receivers the worker threads
used. A caller whose command was still queued when a transaction
expired or a snapshot ended then waited forever on a reply that
would never come. Dropping the receiving end now discards them.
…age-shared-executor

# Conflicts:
#	crates/tracedecay-rusqlite-runtime/src/reader/worker.rs
Busy-begin retries, WAL-recovery read retries and the writer's checkpoint
timer now wait on the pool timer instead of sleeping the thread, and an
early timer re-arms against the Instant deadline. Synchronous store waits
(writer start, task join, writer replies, reader acquire/begin/spawn,
wait_until) refuse on a pool thread in every build instead of parking it.
native_runtime no longer mistakes a pool thread for a native one.
TaskTransaction::new is unsafe with its contract at each call site, and a
panicking poll restores the task's thread-locals.
The proof now runs alone in a child process and fails if 63 more stores
grow the process by more than four threads, so an unnamed per-store
thread fails it too. A second test shows a pool task's synchronous write
and read are refused, not parked, and the write never runs.
…ared-executor

# Conflicts:
#	crates/tracedecay-application/tests/application_suite/work_leaf_jobs.rs
Six threads capped SQL parallelism for a process with many stores
opening at once: the tracedecay unit_test binary timed out cold opens in
three of five runs. The default is now the available parallelism
clamped to 6..=16; TRACEDECAY_STORE_THREADS still overrides it.
Also records the step 7 pool decisions in decisions.tsv.
@changeset-bot

changeset-bot Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 5413cf0

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +45 to +50
pub fn store_threads() -> usize {
match std::env::var(STORE_THREADS_ENV)
.ok()
.and_then(|value| value.trim().parse::<usize>().ok())
{
Some(threads) => threads.clamp(*STORE_THREAD_RANGE.start(), *STORE_THREAD_RANGE.end()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pool size reports the wrong count

When TRACEDECAY_STORE_THREADS changes after startup, store_threads() reports the new override instead of the running pool size. POOL retains the thread count chosen on first initialization.

Learn more

The pool is created once in pool, using store_threads() at creation time. Subsequent calls read the current environment again, but do not resize that pool. A caller using this public function to inspect pool capacity can therefore receive a false count.

Example: Start with TRACEDECAY_STORE_THREADS=2, open a store, then set the variable to 16. The pool still has two workers, while store_threads() returns 16.

Recommended fix: Cache the selected size alongside POOL, and make the public query return that selected size after initialization. Keep an internal configuration reader for the initial build if needed.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +134 to +138
Err(_) => Err(slot
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take()
.expect("a refused task never ran its work")),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pool task refusal can panic

If a refused task's slot is empty, spawn() panics instead of returning a typed failure. The new reader retry also uses expect for its transaction, violating the repository's production-failure rule.

Learn more

Repository guidance forbids new production expect calls and requires typed failures. A rejected spawn currently asserts that the saved future remains in its slot. The new reader retry makes a similar assertion about its active transaction at read_across_wal_recovery. These are internal invariants, but an invariant failure must not turn a storage failure into a process-level panic.

Example: If task rejection occurs after the saved future was taken, the spawn path panics before it can report that the runtime is unavailable.

Recommended fix: Make rejection and transaction lookup return typed errors, or structure the ownership so the invalid state is unrepresentable without adding an expect.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@ScriptedAlchemy
ScriptedAlchemy merged commit 5a24ae8 into master Oct 18, 2026
12 of 20 checks passed
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