fix: acquire SQLite write ownership before restore and deletion authority reads
This commit is contained in:
@@ -226,13 +226,41 @@ struct BusyGate {
|
|||||||
|
|
||||||
/// rusqlite's fn-pointer busy_handler cannot capture a fixture. SQLite's
|
/// rusqlite's fn-pointer busy_handler cannot capture a fixture. SQLite's
|
||||||
/// userdata API can, without process globals or changing production hooks.
|
/// userdata API can, without process globals or changing production hooks.
|
||||||
|
enum BusyDatabase {
|
||||||
|
Feature(crate::FeatureDatabase),
|
||||||
|
Store(Arc<SqliteWorkspaceStore>),
|
||||||
|
}
|
||||||
|
impl BusyDatabase {
|
||||||
|
fn access(&self, access: impl FnOnce(&Connection)) {
|
||||||
|
match self {
|
||||||
|
Self::Feature(database) => database
|
||||||
|
.with_connection(|conn| {
|
||||||
|
access(conn);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
Self::Store(store) => store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
access(conn);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
struct BusyObservation {
|
struct BusyObservation {
|
||||||
database: crate::FeatureDatabase,
|
database: BusyDatabase,
|
||||||
gate: Box<BusyGate>,
|
gate: Box<BusyGate>,
|
||||||
resume: Option<mpsc::Sender<()>>,
|
resume: Option<mpsc::Sender<()>>,
|
||||||
}
|
}
|
||||||
impl BusyObservation {
|
impl BusyObservation {
|
||||||
fn install(database: crate::FeatureDatabase) -> (Self, oneshot::Receiver<()>) {
|
fn install(database: crate::FeatureDatabase) -> (Self, oneshot::Receiver<()>) {
|
||||||
|
Self::install_on(BusyDatabase::Feature(database))
|
||||||
|
}
|
||||||
|
fn install_store(store: Arc<SqliteWorkspaceStore>) -> (Self, oneshot::Receiver<()>) {
|
||||||
|
Self::install_on(BusyDatabase::Store(store))
|
||||||
|
}
|
||||||
|
fn install_on(database: BusyDatabase) -> (Self, oneshot::Receiver<()>) {
|
||||||
unsafe extern "C" fn busy(context: *mut c_void, _: i32) -> i32 {
|
unsafe extern "C" fn busy(context: *mut c_void, _: i32) -> i32 {
|
||||||
// SAFETY: owned Box remains registered until Drop unregisters it,
|
// SAFETY: owned Box remains registered until Drop unregisters it,
|
||||||
// serialized by the managed connection mutex.
|
// serialized by the managed connection mutex.
|
||||||
@@ -249,8 +277,7 @@ impl BusyObservation {
|
|||||||
entered: Some(entered),
|
entered: Some(entered),
|
||||||
resume: permit,
|
resume: permit,
|
||||||
});
|
});
|
||||||
database
|
database.access(|conn| {
|
||||||
.with_connection(|conn| {
|
|
||||||
// SAFETY: handle belongs to this connection; the Box is stable.
|
// SAFETY: handle belongs to this connection; the Box is stable.
|
||||||
let code = unsafe {
|
let code = unsafe {
|
||||||
ffi::sqlite3_busy_handler(
|
ffi::sqlite3_busy_handler(
|
||||||
@@ -260,9 +287,7 @@ impl BusyObservation {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
assert_eq!(code, ffi::SQLITE_OK);
|
assert_eq!(code, ffi::SQLITE_OK);
|
||||||
Ok(())
|
});
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
(
|
(
|
||||||
Self {
|
Self {
|
||||||
database,
|
database,
|
||||||
@@ -281,15 +306,12 @@ impl BusyObservation {
|
|||||||
impl Drop for BusyObservation {
|
impl Drop for BusyObservation {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.resume();
|
self.resume();
|
||||||
self.database
|
self.database.access(|conn| {
|
||||||
.with_connection(|conn| {
|
|
||||||
// SAFETY: unregister before the boxed userdata is freed.
|
// SAFETY: unregister before the boxed userdata is freed.
|
||||||
unsafe {
|
unsafe {
|
||||||
ffi::sqlite3_busy_handler(conn.handle(), None, std::ptr::null_mut());
|
ffi::sqlite3_busy_handler(conn.handle(), None, std::ptr::null_mut());
|
||||||
}
|
}
|
||||||
Ok(())
|
});
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
let _ = &self.gate;
|
let _ = &self.gate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -689,3 +711,59 @@ async fn saturated_cancelled_host_jobs_keep_their_budget_but_not_other_fixture_o
|
|||||||
host.run(|| Ok::<_, Error>(())).await.unwrap();
|
host.run(|| Ok::<_, Error>(())).await.unwrap();
|
||||||
host.drain().await.unwrap();
|
host.drain().await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn restore_intent_pins_once_after_write_ownership_without_a_deferred_read_upgrade() {
|
||||||
|
let fixture = Fixture::new().await;
|
||||||
|
let store = fixture.api.config_store.clone();
|
||||||
|
let (mut busy, waiting) = BusyObservation::install_store(store.clone());
|
||||||
|
let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone());
|
||||||
|
let worker = RuntimeWorkerRef::new("restore-runtime", "restore-worker");
|
||||||
|
let pin_store = store.clone();
|
||||||
|
let pin_worker = worker.clone();
|
||||||
|
let mut pinned = Box::pin(fixture.executor.run(move || {
|
||||||
|
pin_store.pin_internal_worker_restore_intent(
|
||||||
|
WORKSPACE,
|
||||||
|
&pin_worker,
|
||||||
|
"restore-domain",
|
||||||
|
Some("observed-before-dispatch"),
|
||||||
|
Some("restore-request"),
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
tokio::select! {
|
||||||
|
result = &mut pinned => panic!("restore intent must wait for write ownership before reading, not fail a deferred read upgrade: {result:?}"),
|
||||||
|
arrived = observed(waiting) => arrived.unwrap(),
|
||||||
|
}
|
||||||
|
runtime_progress_while_locked(&lock).await;
|
||||||
|
assert!(futures::poll!(&mut pinned).is_pending());
|
||||||
|
lock.release();
|
||||||
|
busy.resume();
|
||||||
|
let request = observed(pinned).await.unwrap();
|
||||||
|
assert_eq!(request.request_id, "restore-request");
|
||||||
|
assert_eq!(
|
||||||
|
request.expected_observation_token,
|
||||||
|
"observed-before-dispatch"
|
||||||
|
);
|
||||||
|
fixture.executor.drain().await.unwrap();
|
||||||
|
let saved = store
|
||||||
|
.get_internal_worker_restore_intent(WORKSPACE, &worker, &request.request_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(saved.request_id, request.request_id);
|
||||||
|
assert_eq!(
|
||||||
|
saved.expected_observation_token,
|
||||||
|
request.expected_observation_token
|
||||||
|
);
|
||||||
|
let count: i64 = store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
Ok(conn.query_row(
|
||||||
|
"SELECT count(*) FROM worker_restore_intents WHERE workspace_id=?1 AND request_id=?2",
|
||||||
|
rusqlite::params![WORKSPACE, request.request_id], |row| row.get(0)
|
||||||
|
)?)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
count, 1,
|
||||||
|
"one admitted intent must persist exactly once, with no operation retry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2341,12 +2341,16 @@ impl SqliteWorkspaceStore {
|
|||||||
f(&mut conn)
|
f(&mut conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn with_transaction<T>(
|
pub(crate) fn with_immediate_transaction<T>(
|
||||||
&self,
|
&self,
|
||||||
f: impl FnOnce(&rusqlite::Transaction<'_>) -> Result<T>,
|
f: impl FnOnce(&rusqlite::Transaction<'_>) -> Result<T>,
|
||||||
) -> Result<T> {
|
) -> Result<T> {
|
||||||
self.with_conn_mut(|conn| {
|
self.with_conn_mut(|conn| {
|
||||||
let tx = conn.transaction()?;
|
// These callers read authority/journal state before mutating it.
|
||||||
|
// Acquire write ownership before the first read: a DEFERRED read
|
||||||
|
// upgrade against another connection's writer can return BUSY
|
||||||
|
// without invoking the busy handler. Never retry the domain mutation.
|
||||||
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
let value = f(&tx)?;
|
let value = f(&tx)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
@@ -2407,7 +2411,7 @@ impl SqliteWorkspaceStore {
|
|||||||
observed_token: Option<&str>,
|
observed_token: Option<&str>,
|
||||||
explicit_request_id: Option<&str>,
|
explicit_request_id: Option<&str>,
|
||||||
) -> Result<server_api::WorkerRestoreRequest> {
|
) -> Result<server_api::WorkerRestoreRequest> {
|
||||||
self.with_transaction(|tx| {
|
self.with_immediate_transaction(|tx| {
|
||||||
let existing: Option<(String, String)> = if let Some(id) = explicit_request_id {
|
let existing: Option<(String, String)> = if let Some(id) = explicit_request_id {
|
||||||
tx.query_row("SELECT request_id, expected_token FROM worker_restore_intents WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND request_id=?4",
|
tx.query_row("SELECT request_id, expected_token FROM worker_restore_intents WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND request_id=?4",
|
||||||
params![workspace_id, worker.runtime_id, worker.worker_id, id], |row| Ok((row.get(0)?, row.get(1)?))).optional()?
|
params![workspace_id, worker.runtime_id, worker.worker_id, id], |row| Ok((row.get(0)?, row.get(1)?))).optional()?
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
|||||||
"Workspace deletion request exceeds bounded field limits".to_string(),
|
"Workspace deletion request exceeds bounded field limits".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
self.with_transaction(|tx| {
|
self.with_immediate_transaction(|tx| {
|
||||||
if let Some(existing) = read_operation(tx, &request.operation_id)? {
|
if let Some(existing) = read_operation(tx, &request.operation_id)? {
|
||||||
if existing.actor_account_id != actor_account_id {
|
if existing.actor_account_id != actor_account_id {
|
||||||
return Err(Error::WorkspacePermissionDenied(
|
return Err(Error::WorkspacePermissionDenied(
|
||||||
@@ -369,7 +369,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
|||||||
child_operation_id: &str,
|
child_operation_id: &str,
|
||||||
) -> Result<WorkspaceDeletionOperationResponse> {
|
) -> Result<WorkspaceDeletionOperationResponse> {
|
||||||
validate_operation_id(child_operation_id)?;
|
validate_operation_id(child_operation_id)?;
|
||||||
self.with_transaction(|tx| {
|
self.with_immediate_transaction(|tx| {
|
||||||
let operation = read_operation(tx, operation_id)?
|
let operation = read_operation(tx, operation_id)?
|
||||||
.ok_or_else(|| Error::InvalidInput("Workspace deletion operation".to_string()))?
|
.ok_or_else(|| Error::InvalidInput("Workspace deletion operation".to_string()))?
|
||||||
.response;
|
.response;
|
||||||
@@ -411,7 +411,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
|||||||
failure_category: Option<&str>,
|
failure_category: Option<&str>,
|
||||||
) -> Result<WorkspaceDeletionOperationResponse> {
|
) -> Result<WorkspaceDeletionOperationResponse> {
|
||||||
validate_operation_projection(child_operation_ids, blockers)?;
|
validate_operation_projection(child_operation_ids, blockers)?;
|
||||||
self.with_transaction(|tx| {
|
self.with_immediate_transaction(|tx| {
|
||||||
let current = read_operation(tx, operation_id)?
|
let current = read_operation(tx, operation_id)?
|
||||||
.ok_or_else(|| Error::InvalidInput("Workspace deletion operation".to_string()))?
|
.ok_or_else(|| Error::InvalidInput("Workspace deletion operation".to_string()))?
|
||||||
.response;
|
.response;
|
||||||
@@ -462,7 +462,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
|||||||
&self,
|
&self,
|
||||||
operation_id: &str,
|
operation_id: &str,
|
||||||
) -> Result<WorkspaceDeletionOperationResponse> {
|
) -> Result<WorkspaceDeletionOperationResponse> {
|
||||||
self.with_transaction(|tx| {
|
self.with_immediate_transaction(|tx| {
|
||||||
tx.execute_batch("PRAGMA defer_foreign_keys = ON;")?;
|
tx.execute_batch("PRAGMA defer_foreign_keys = ON;")?;
|
||||||
let operation = read_operation(tx, operation_id)?
|
let operation = read_operation(tx, operation_id)?
|
||||||
.ok_or_else(|| Error::InvalidInput("Workspace deletion operation".to_string()))?;
|
.ok_or_else(|| Error::InvalidInput("Workspace deletion operation".to_string()))?;
|
||||||
@@ -1019,7 +1019,7 @@ mod tests {
|
|||||||
// Seed domain handling evidence under a transaction. This fixture
|
// Seed domain handling evidence under a transaction. This fixture
|
||||||
// exercises owner purge, not singleton authentication or delivery.
|
// exercises owner purge, not singleton authentication or delivery.
|
||||||
store
|
store
|
||||||
.with_transaction(|tx| {
|
.with_immediate_transaction(|tx| {
|
||||||
ticket::sqlite_update_ticket_assignment_tracking_in_transaction(
|
ticket::sqlite_update_ticket_assignment_tracking_in_transaction(
|
||||||
tx,
|
tx,
|
||||||
workspace,
|
workspace,
|
||||||
|
|||||||
Reference in New Issue
Block a user