Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 104 additions & 25 deletions crates/tracedecay-rusqlite-runtime/src/exact_sql/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use std::{
sync::{
Arc, Mutex,
Arc, Mutex, PoisonError,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
Expand All @@ -18,10 +18,10 @@ use super::clock;
use super::guard::{ExactSqlAuthority, with_exact_sql_guard};
use super::{
EXACT_SQL_TRANSACTION_IDLE_LIMIT, EXACT_SQL_TRANSACTION_LIMIT, ExactSqlAttachment,
ExactSqlCommitReceipt, ExactSqlError, ExactSqlRollbackReceipt, ExactSqlRows, ExactSqlStatement,
ExactSqlWriteAuthority, ExactSqlWriteIntent, ExecutionPolicy, MAX_EXACT_SQL_ATTACHMENTS,
SqlRequest, SqlResult, TransactionPolicy, attach_database, detach_database,
execute_query_unchecked, execute_request, read_across_wal_recovery,
ExactSqlBatchResult, ExactSqlCommitReceipt, ExactSqlError, ExactSqlRollbackReceipt,
ExactSqlRows, ExactSqlStatement, ExactSqlWriteAuthority, ExactSqlWriteIntent, ExecutionPolicy,
MAX_EXACT_SQL_ATTACHMENTS, SqlRequest, SqlResult, TransactionPolicy, attach_database,
detach_database, execute_query_unchecked, execute_request, read_across_wal_recovery,
request_across_wal_recovery, sqlite_error, verify_write_authority,
};
use crate::connection::ConnectionMode;
Expand Down Expand Up @@ -51,21 +51,72 @@ pub(crate) enum WriterCommand {
/// per statement. The job answers whether to commit.
Job {
job: WriterJob,
policy: JobPolicy,
reply: async_channel::Sender<Result<(), ExactSqlError>>,
authority: Option<Arc<dyn ExactSqlWriteAuthority>>,
},
}

pub(crate) type WriterJob = Box<dyn FnOnce(&ExactSqlJobTransaction<'_>) -> bool + Send>;
/// Answers whether to commit; an error rolls back and becomes the job's reply.
pub(crate) type WriterJob =
Box<dyn FnOnce(&ExactSqlJobTransaction<'_>) -> Result<bool, ExactSqlError> + Send>;

/// How long a write job may hold the writer. `Bounded` is one fixed lease.
/// `AuthorizedLongLease` renews the lease after every successful statement,
/// as the interactive long lease does, and needs attached write authority.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum JobPolicy {
Bounded,
AuthorizedLongLease,
}

/// The statements a write job may run inside its transaction, on the writer
/// thread. Each statement gets the same guard, authority check and deadline as
/// a statement of an interactive transaction.
pub struct ExactSqlJobTransaction<'transaction> {
transaction: &'transaction Transaction<'transaction>,
transaction: &'transaction (dyn JobConnection + 'transaction),
shutdown_requested: &'transaction Arc<AtomicBool>,
authority: Option<&'transaction dyn ExactSqlWriteAuthority>,
deadline: Instant,
policy: JobPolicy,
deadline: Mutex<Instant>,
}

const _: () = {
const fn shareable<T: Send + Sync>() {}
shareable::<ExactSqlJobTransaction<'static>>();
shareable::<&ExactSqlJobTransaction<'static>>();
};

/// The job's transaction behind a trait object. A `Mutex` field would name the
/// connection's lifetime and make [`ExactSqlJobTransaction`] invariant, and
/// callers such as `WorkSqliteJobStorage` shorten its lifetime.
trait JobConnection: Sync {
fn execute(
&self,
request: SqlRequest,
shutdown_requested: &Arc<AtomicBool>,
deadline: Instant,
) -> Result<SqlResult, ExactSqlError>;
}

impl JobConnection for Mutex<TaskTransaction<'_>> {
fn execute(
&self,
request: SqlRequest,
shutdown_requested: &Arc<AtomicBool>,
deadline: Instant,
) -> Result<SqlResult, ExactSqlError> {
let transaction = self.lock().unwrap_or_else(PoisonError::into_inner);
execute_request(
&transaction,
request,
true,
Some(Arc::clone(shutdown_requested)),
Some(deadline),
true,
None,
)
}
}

impl ExactSqlJobTransaction<'_> {
Expand All @@ -86,20 +137,27 @@ impl ExactSqlJobTransaction<'_> {
}
}

pub fn execute_batch(&self, sql: String) -> Result<ExactSqlBatchResult, ExactSqlError> {
match self.dispatch(SqlRequest::ExecuteBatch(sql))? {
SqlResult::BatchExecuted(result) => Ok(result),
_ => Err(ExactSqlError::TransactionClosed),
}
}

fn dispatch(&self, request: SqlRequest) -> Result<SqlResult, ExactSqlError> {
if clock::now() >= self.deadline {
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;
Comment on lines +148 to +158

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

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.

}
result
}
}

Expand All @@ -108,10 +166,16 @@ impl ExactSqlJobTransaction<'_> {
async fn run_writer_job(
connection: &mut Connection,
job: WriterJob,
policy: JobPolicy,
reply: &async_channel::Sender<Result<(), ExactSqlError>>,
authority: Option<&dyn ExactSqlWriteAuthority>,
shutdown_requested: &Arc<AtomicBool>,
) -> Result<(), ExactSqlError> {
if policy == JobPolicy::AuthorizedLongLease && authority.is_none() {
return Err(ExactSqlError::AuthorityDenied(
"long-lease job requires attached write authority".to_owned(),
));
}
verify_write_authority(authority, ExactSqlWriteIntent::BeginTransaction)?;
let before = connection.total_changes();
let transaction = begin_transaction_with_busy_retry(
Expand All @@ -122,24 +186,37 @@ async fn run_writer_job(
)
.await
.map_err(|error| sqlite_error("begin exact SQL job", error))?;
// SAFETY: `TaskTransaction::new`'s contract holds. The transaction
// reborrows `run_writer_job`'s `&mut Connection`, so nothing else reaches
// the connection. The job may use it from any thread, but only through
// this mutex.
let transaction = Mutex::new(unsafe { TaskTransaction::new(transaction) });
let _span = tracing::trace_span!("rusqlite.exact_sql.job").entered();
let commit = job(&ExactSqlJobTransaction {
let verdict = job(&ExactSqlJobTransaction {
transaction: &transaction,
shutdown_requested,
authority,
deadline: clock::now() + EXACT_SQL_TRANSACTION_LIMIT,
policy,
deadline: Mutex::new(clock::now() + EXACT_SQL_TRANSACTION_LIMIT),
});
let transaction = transaction
.into_inner()
.unwrap_or_else(PoisonError::into_inner)
.into_inner();
// An interrupted write statement already rolled the transaction back
// (see `discard_transaction`), so there is nothing left to commit.
if !commit || transaction.is_autocommit() || shutdown_requested.load(Ordering::Acquire) {
if !matches!(verdict, Ok(true))
|| transaction.is_autocommit()
|| shutdown_requested.load(Ordering::Acquire)
{
let rollback = discard_transaction(transaction, before);
if rollback.is_err() {
shutdown_requested.store(true, Ordering::Release);
}
return match (commit, rollback) {
(_, Err(error)) => Err(error),
(false, Ok(_)) => Ok(()),
(true, Ok(_)) => Err(ExactSqlError::TransactionClosed),
return match (verdict, rollback) {
(_, Err(error)) | (Err(error), Ok(_)) => Err(error),
(Ok(false), Ok(_)) => Ok(()),
(Ok(true), Ok(_)) => Err(ExactSqlError::TransactionClosed),
};
}
verify_write_authority(authority, ExactSqlWriteIntent::Commit)?;
Expand Down Expand Up @@ -506,12 +583,14 @@ pub(crate) async fn run_writer_command(
}
WriterCommand::Job {
job,
policy,
reply,
authority,
} => {
let result = run_writer_job(
connection,
job,
policy,
&reply,
authority.as_deref(),
shutdown_requested,
Expand Down
69 changes: 66 additions & 3 deletions crates/tracedecay-rusqlite-runtime/src/exact_sql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ pub(crate) use column::{
pub(crate) use json::{decode as decode_json, encode as encode_json};
pub use types::*;

pub use command::ExactSqlJobTransaction;
pub use command::{ExactSqlJobTransaction, JobPolicy};
pub(crate) use command::{WriterCommand, reject_writer_command, run_writer_command};

use command::{TransactionCommand, TransactionLeaseState};
Expand Down Expand Up @@ -534,20 +534,74 @@ impl ExactSqlHandle {
F: Fn(&ExactSqlJobTransaction<'_>) -> Result<R, E> + Send + Sync + 'static,
R: Send + 'static,
E: Send + 'static,
{
self.transact_with_policy(JobPolicy::Bounded, job).await
}

/// [`Self::transact`] under `policy`.
pub async fn transact_with_policy<R, E, F>(
&self,
policy: JobPolicy,
job: F,
) -> Result<Result<R, E>, ExactSqlError>
where
F: Fn(&ExactSqlJobTransaction<'_>) -> Result<R, E> + Send + Sync + 'static,
R: Send + 'static,
E: Send + 'static,
{
self.run_job(policy, move |transaction| Ok(job(transaction)))
.await
}

/// [`Self::transact_with_policy`] for an async body, so an async helper
/// over the job's statements runs unchanged. The body is polled once
/// inside the job: its statements are synchronous, so it completes on that
/// poll unless it awaited something else, which would hold the writer
/// across the wait. Such a job rolls back with
/// [`ExactSqlError::JobAwaitedForeignFuture`].
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
Comment on lines +562 to +575

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.

}

async fn run_job<R, E, F>(
&self,
policy: JobPolicy,
job: F,
) -> Result<Result<R, E>, ExactSqlError>
where
F: Fn(&ExactSqlJobTransaction<'_>) -> Result<Result<R, E>, ExactSqlError>
+ Send
+ Sync
+ 'static,
R: Send + 'static,
E: Send + 'static,
{
let (outcome, verdict) = async_channel::bounded(1);
let (reply, response) = async_channel::bounded(1);
let job: command::WriterJob = Box::new(move |transaction| {
let result = job(transaction);
let result = job(transaction)?;
let commit = result.is_ok();
let _ = outcome.try_send(result);
commit
Ok(commit)
});
self.writer
.as_ref()
.ok_or(ExactSqlError::WriterUnavailable)?
.try_send(WriterCommand::Job {
job,
policy,
reply,
authority: self.write_authority.clone(),
})
Expand Down Expand Up @@ -1296,6 +1350,15 @@ fn verify_write_authority(
}
}

/// One poll; `None` means the future is pending.
fn complete_now<F: Future>(future: F) -> Option<F::Output> {
match std::pin::pin!(future).poll(&mut std::task::Context::from_waker(std::task::Waker::noop()))
{
std::task::Poll::Ready(output) => Some(output),
std::task::Poll::Pending => None,
}
}

fn validate_request(request: &SqlRequest) -> Result<(), ExactSqlError> {
match request {
SqlRequest::Validate(statement)
Expand Down
Loading
Loading