Repository navigation
refactor(storage): poll async job bodies inside one writer job #4244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2278760
7c04dbc
40213e7
82228aa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,7 @@ | |
|
|
||
| use std::{ | ||
| sync::{ | ||
| Arc, Mutex, | ||
| Arc, Mutex, PoisonError, | ||
| atomic::{AtomicBool, Ordering}, | ||
| }, | ||
| time::{Duration, Instant}, | ||
|
|
@@ -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; | ||
|
|
@@ -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<'_> { | ||
|
|
@@ -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
+156
to
+158
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Learn moreThe 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 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. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
| result | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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( | ||
|
|
@@ -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)?; | ||
|
|
@@ -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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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}; | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Async job API lacks a production caller
Learn moreThe 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 Example: A release containing this PR exposes Recommended fix: Land the primitive with the first production migration, or include a production operation using it in this delivery slice. 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(), | ||
| }) | ||
|
|
@@ -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) | ||
|
|
||
There was a problem hiding this comment.
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,
dispatchcaptures 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.dispatchsnapshots 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.
Was this helpful? React with 👍 or 👎 to provide feedback.