Skip to content

refactor(storage): poll async job bodies inside one writer job - #4244

Merged
ScriptedAlchemy merged 4 commits into
masterfrom
refactor/storage-job-transactions
Oct 18, 2026
Merged

ScriptedAlchemy merged 4 commits into
masterfrom
refactor/storage-job-transactions

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Why

Step 5 of the storage redesign (docs/plans/storage/README.md) deletes interactive exact-SQL transactions: a write must be one job that cannot .await, so it never holds the writer across a round trip. About 150 production sites still open interactive transactions, and they run roughly 500 async helpers written over the executor traits. Rewriting those helpers to sync code has no mechanical lever.

This PR adds the primitive that lets those sites become jobs without that rewrite. ExactSqlHandle::transact_async polls an async body once, with a no-op waker, inside one writer job. The body's statements run synchronously, so a body that awaits only them completes on that poll. A body that awaits anything else is rolled back with ExactSqlError::JobAwaitedForeignFuture. The design and its review are recorded in the README entry this PR adds.

Scope

  • ExactSqlJobTransaction is Send + Sync. It holds the writer task's transaction in a Mutex behind a private trait object, which keeps the type covariant, so existing callers such as WorkSqliteJobStorage compile unchanged. No new unsafe impl; the TaskTransaction safety contract now rests on the transaction being the only path to its connection and Connection: Send.
  • JobPolicy { Bounded, AuthorizedLongLease } and transact_with_policy. A long-lease job needs attached write authority and renews its lease after each successful statement, as the interactive long lease does.
  • ExactSqlJobTransaction::execute_batch.
  • transact is unchanged for its callers.

Stacked on #4242, which introduces TaskTransaction.

Verification

  • a_job_body_that_awaits_a_foreign_future_is_refused_and_rolled_back: the body inserts a row, then awaits a pool timer. Mutation: commit on that verdict; the test fails on the row count.
  • an_async_job_body_over_several_statements_commits_and_returns_its_value and a_long_lease_job_renews_its_lease_after_each_statement. A const assertion checks that the job transaction and a reference to it are Send + Sync.
  • Bazel: rusqlite-runtime unit_test (417) and rusqlite_suite (148), //crates/tracedecay unit_test (905), daemon_suite (77), mcp_suite (669) and session_suite (420) pass. Clippy and rustfmt_check_2024 pass on rusqlite-runtime.

Devin Review

@changeset-bot

changeset-bot Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 82228aa

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

@ScriptedAlchemy
ScriptedAlchemy changed the base branch from refactor/storage-shared-executor to master October 18, 2026 02:35
…b-transactions

# Conflicts:
#	crates/tracedecay-rusqlite-runtime/src/exact_sql/tests/async_dispatch.rs

@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 3 new potential issues.

Devin Review

Comment on lines +148 to +158
let deadline = *self.deadline.lock().unwrap_or_else(PoisonError::into_inner);
if clock::now() >= deadline {
return Err(ExactSqlError::TransactionExpired);
}
verify_write_authority(self.authority, request.intent())?;
execute_request(
self.transaction,
request,
true,
Some(Arc::clone(self.shutdown_requested)),
Some(self.deadline),
true,
None,
)
let result = self
.transaction
.execute(request, self.shutdown_requested, deadline);
if result.is_ok() && self.policy == JobPolicy::AuthorizedLongLease {
*self.deadline.lock().unwrap_or_else(PoisonError::into_inner) =
clock::now() + EXACT_SQL_TRANSACTION_LIMIT;

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.

🔴 Parallel statements inherit stale leases

When a long-lease job runs statements concurrently, dispatch captures a deadline before waiting for the transaction mutex. A waiting statement retains the old deadline after another statement renews it, so an active job can expire.

Learn more

A job transaction is now Sync, so separate threads can call its statements while the writer transaction serializes SQLite access through JobConnection. dispatch snapshots the deadline before acquiring that mutex. A second statement waiting for the first can use a deadline that the first already renewed. Its pre-lock expiry check also does not cover the waiting time.

Example: With a deadline at second 120, statement A starts at second 100 and finishes at second 125, renewing the lease to second 245. Statement B starts at second 110 and waits for A; it executes with deadline 120 and can be interrupted, despite the renewed lease.

Recommended fix: Serialize the lease check, SQLite execution, and lease renewal under the same lock, or read the current deadline only after acquiring the transaction mutex. Cover two concurrent job statements crossing a lease boundary in a test.

Devin Review


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

Comment on lines +156 to +158
if result.is_ok() && self.policy == JobPolicy::AuthorizedLongLease {
*self.deadline.lock().unwrap_or_else(PoisonError::into_inner) =
clock::now() + EXACT_SQL_TRANSACTION_LIMIT;

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.

🔴 Expired jobs regain their lease

When a statement finishes after its lease expires, dispatch renews the deadline on success without checking expiry. The job can then commit work the interactive transaction would roll back.

Learn more

The execution guard checks a deadline through a SQLite progress callback, not unconditionally after a statement; see with_exact_sql_guard. A fast statement can finish after the lease expires, especially when authority verification or scheduling consumes the remaining time. The interactive dispatch checks expiry after execution before renewing its lease. This new long-lease renewal does not, and run_writer_job does not check expiry at commit.

Example: A job has one second left in its lease. Its authority check takes two seconds, then SELECT 1 succeeds without a progress callback. The job renews its lease and commits, rather than expiring.

Recommended fix: Check the captured deadline after each bounded statement before renewal and reject and roll back on expiry. Check expiry before committing even when the body issues no final statement.

Devin Review


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

Comment on lines +562 to +575
pub async fn transact_async<R, E, F>(
&self,
policy: JobPolicy,
body: F,
) -> Result<Result<R, E>, ExactSqlError>
where
F: AsyncFn(&ExactSqlJobTransaction<'_>) -> Result<R, E> + Send + Sync + 'static,
R: Send + 'static,
E: Send + 'static,
{
self.run_job(policy, move |transaction| {
complete_now(body(transaction)).ok_or(ExactSqlError::JobAwaitedForeignFuture)
})
.await

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.

🟡 Async job API lacks a production caller

transact_async and the long-lease policy have only test callers. The repository requires production APIs to serve a real caller and user journey.

Learn more

The repository's engineering rules prohibit production ports or features without a real production caller and user journey. All current uses of transact_async and the new long-lease policy are test calls; production still uses transact and interactive transactions.

Example: A release containing this PR exposes transact_async, but no production Work or Workflow operation invokes it.

Recommended fix: Land the primitive with the first production migration, or include a production operation using it in this delivery slice.

Devin Review


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

@ScriptedAlchemy
ScriptedAlchemy merged commit 02b8e3f 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