Skip to main content

rivetkit_core/actor/sqlite/
tx.rs

1use std::{
2	collections::{BTreeMap, VecDeque},
3	error::Error,
4	fmt,
5	future::Future,
6	sync::{
7		Arc,
8		atomic::{AtomicU64, Ordering},
9	},
10	time::Duration,
11};
12
13use anyhow::{Context, Result};
14use serde::Serialize;
15#[cfg(target_arch = "wasm32")]
16use tokio::sync::oneshot;
17use tokio::sync::{
18	Mutex as AsyncMutex, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, OwnedSemaphorePermit, RwLock,
19	Semaphore, TryAcquireError,
20};
21use tokio_util::sync::CancellationToken;
22
23#[cfg(not(target_arch = "wasm32"))]
24use crate::runtime::RuntimeSpawner;
25
26#[cfg(feature = "sqlite-local")]
27use super::profiling::{FINGERPRINT_FORMAT_VERSION, TransactionProfile};
28use super::{BindParam, ExecuteResult, QueryResult, SqliteDb, report_sqlite_worker_fatal};
29
30pub const DEFAULT_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(60);
31#[cfg(not(feature = "sqlite-local"))]
32const MAX_TRANSACTION_NAME_BYTES: usize = 128;
33pub const TRANSACTION_COORDINATOR_QUEUE_CAPACITY: usize = 128;
34pub(super) const TRANSACTION_TERMINAL_CAPACITY: usize = 1024;
35#[cfg(feature = "sqlite-local")]
36static UNNAMED_TRANSACTION_WARNINGS: AtomicU64 = AtomicU64::new(0);
37
38#[derive(Clone)]
39pub struct SqliteTransaction {
40	db: SqliteDb,
41	key: String,
42}
43
44impl SqliteTransaction {
45	pub fn key(&self) -> &str {
46		&self.key
47	}
48
49	pub async fn exec(&self, sql: impl Into<String>) -> Result<QueryResult> {
50		self.db.transaction_exec(&self.key, sql.into()).await
51	}
52
53	pub async fn execute(
54		&self,
55		sql: impl Into<String>,
56		params: Option<Vec<BindParam>>,
57	) -> Result<ExecuteResult> {
58		self.db
59			.transaction_execute(&self.key, sql.into(), params)
60			.await
61	}
62
63	pub async fn commit(&self) -> Result<()> {
64		self.db.finish_transaction(&self.key, true).await
65	}
66
67	pub async fn rollback(&self) -> Result<()> {
68		self.db.finish_transaction(&self.key, false).await
69	}
70
71	pub async fn expire(&self) -> Result<()> {
72		self.db.expire_transaction(&self.key).await
73	}
74}
75
76pub(super) struct TransactionCoordinator {
77	pub(super) gate: Arc<RwLock<()>>,
78	pub(super) admission: Arc<Semaphore>,
79	pub(super) state: AsyncMutex<TransactionCoordinatorState>,
80	epoch: AtomicU64,
81	waiters: AtomicU64,
82}
83
84pub(super) struct TransactionCoordinatorState {
85	pub(super) active: Option<ActiveTransaction>,
86	pub(super) terminal: BTreeMap<String, TransactionTerminalState>,
87	pub(super) terminal_order: VecDeque<String>,
88	pub(super) poisoned: BTreeMap<String, Duration>,
89	pub(super) last_expired_timeout: Option<Duration>,
90	pub(super) closed: bool,
91}
92
93pub(super) struct ActiveTransaction {
94	key: String,
95	timeout: Duration,
96	pub(super) expiring: bool,
97	connection_lost: bool,
98	remote_session: Option<u64>,
99	gate_guard: Option<OwnedRwLockWriteGuard<()>>,
100	operation: Arc<AsyncMutex<()>>,
101	timeout_task: Option<CancellationToken>,
102	#[cfg(feature = "sqlite-local")]
103	profile: Option<TransactionProfile>,
104}
105
106#[derive(Clone, Copy)]
107pub(super) enum TransactionTerminalState {
108	Committed,
109	RolledBack,
110	Expired(Duration),
111	ConnectionLost,
112}
113
114pub(super) struct RegularOperationGuard {
115	_gate: OwnedRwLockReadGuard<()>,
116	_permit: OwnedSemaphorePermit,
117}
118
119struct CoordinatorWaitGuard<'a> {
120	db: &'a SqliteDb,
121}
122
123impl<'a> CoordinatorWaitGuard<'a> {
124	#[cfg(feature = "sqlite-local")]
125	fn new(db: &'a SqliteDb) -> Option<Self> {
126		if !db.profiling.config.enabled {
127			return None;
128		}
129		let metrics = db.vfs_metrics.as_ref()?;
130		let _depth = db
131			.transaction_coordinator
132			.waiters
133			.fetch_add(1, Ordering::AcqRel)
134			.saturating_add(1);
135		metrics.set_coordinator_queue_depth(_depth);
136		Some(Self { db })
137	}
138
139	#[cfg(not(feature = "sqlite-local"))]
140	fn new(_db: &'a SqliteDb) -> Option<Self> {
141		None
142	}
143}
144
145impl Drop for CoordinatorWaitGuard<'_> {
146	fn drop(&mut self) {
147		let _depth = self
148			.db
149			.transaction_coordinator
150			.waiters
151			.fetch_sub(1, Ordering::AcqRel)
152			.saturating_sub(1);
153		#[cfg(feature = "sqlite-local")]
154		if let Some(metrics) = &self.db.vfs_metrics {
155			metrics.set_coordinator_queue_depth(_depth);
156		}
157	}
158}
159
160impl Default for TransactionCoordinator {
161	fn default() -> Self {
162		Self {
163			gate: Arc::new(RwLock::new(())),
164			admission: Arc::new(Semaphore::new(TRANSACTION_COORDINATOR_QUEUE_CAPACITY)),
165			state: AsyncMutex::new(TransactionCoordinatorState {
166				active: None,
167				terminal: BTreeMap::new(),
168				terminal_order: VecDeque::new(),
169				poisoned: BTreeMap::new(),
170				last_expired_timeout: None,
171				closed: false,
172			}),
173			epoch: AtomicU64::new(0),
174			waiters: AtomicU64::new(0),
175		}
176	}
177}
178
179impl SqliteDb {
180	pub(super) fn try_transaction_admission(&self) -> Result<OwnedSemaphorePermit> {
181		match Arc::clone(&self.transaction_coordinator.admission).try_acquire_owned() {
182			Ok(permit) => Ok(permit),
183			Err(TryAcquireError::NoPermits) => Err(transaction_queue_full_error()),
184			Err(TryAcquireError::Closed) => Err(transaction_coordinator_closed_error()),
185		}
186	}
187
188	pub(super) async fn begin_regular_operation(&self) -> Result<RegularOperationGuard> {
189		let epoch = self.transaction_coordinator.epoch.load(Ordering::Acquire);
190		let permit = self.try_transaction_admission()?;
191		let wait = CoordinatorWaitGuard::new(self);
192		let gate = Arc::clone(&self.transaction_coordinator.gate)
193			.read_owned()
194			.await;
195		drop(wait);
196		let state = self.transaction_coordinator.state.lock().await;
197		if state.closed {
198			return Err(transaction_coordinator_closed_error());
199		}
200		if self.transaction_coordinator.epoch.load(Ordering::Acquire) != epoch {
201			return Err(transaction_expired_error(
202				state
203					.last_expired_timeout
204					.unwrap_or(DEFAULT_TRANSACTION_TIMEOUT),
205			));
206		}
207		drop(state);
208		Ok(RegularOperationGuard {
209			_gate: gate,
210			_permit: permit,
211		})
212	}
213
214	pub async fn begin_transaction(&self, timeout: Option<Duration>) -> Result<SqliteTransaction> {
215		self.begin_named_transaction(None, timeout).await
216	}
217
218	pub async fn begin_named_transaction(
219		&self,
220		name: Option<&str>,
221		timeout: Option<Duration>,
222	) -> Result<SqliteTransaction> {
223		#[cfg(feature = "sqlite-local")]
224		let max_name_bytes = self.profiling.config.max_transaction_name_bytes;
225		#[cfg(not(feature = "sqlite-local"))]
226		let max_name_bytes = MAX_TRANSACTION_NAME_BYTES;
227		if let Some(name) = name {
228			if name.is_empty() {
229				return Err(transaction_invalid_argument_error(
230					"transaction name must not be empty",
231				));
232			}
233			if name.len() > max_name_bytes {
234				return Err(transaction_invalid_argument_error(
235					"transaction name exceeds the configured byte limit",
236				));
237			}
238		}
239		self.begin_transaction_with_key_and_name(
240			uuid::Uuid::new_v4().to_string(),
241			name.map(ToOwned::to_owned),
242			timeout,
243		)
244		.await
245	}
246
247	pub async fn begin_transaction_with_key(
248		&self,
249		key: impl Into<String>,
250		timeout: Option<Duration>,
251	) -> Result<SqliteTransaction> {
252		self.begin_transaction_with_key_and_name(key, None, timeout)
253			.await
254	}
255
256	async fn begin_transaction_with_key_and_name(
257		&self,
258		key: impl Into<String>,
259		name: Option<String>,
260		timeout: Option<Duration>,
261	) -> Result<SqliteTransaction> {
262		#[cfg(feature = "sqlite-local")]
263		let started_at = (self.profiling.config.enabled
264			&& self.backend() == super::SqliteBackend::LocalNative)
265			.then(crate::time::Instant::now);
266		let key = key.into();
267		let timeout = timeout.unwrap_or(DEFAULT_TRANSACTION_TIMEOUT);
268		if key.is_empty() {
269			return Err(transaction_invalid_argument_error(
270				"transaction key must not be empty",
271			));
272		}
273		if timeout.is_zero() {
274			return Err(transaction_invalid_argument_error(
275				"transaction timeout must be greater than zero",
276			));
277		}
278
279		let db = self.clone();
280		run_detached_transaction_task(
281			async move {
282				db.begin_transaction_profiled_inner(
283					key,
284					timeout,
285					name,
286					#[cfg(feature = "sqlite-local")]
287					started_at,
288				)
289				.await
290			},
291			"sqlite transaction begin task failed",
292		)
293		.await
294	}
295
296	#[cfg(test)]
297	pub(super) async fn begin_transaction_inner(
298		&self,
299		key: String,
300		timeout: Duration,
301	) -> Result<SqliteTransaction> {
302		self.begin_transaction_profiled_inner(
303			key,
304			timeout,
305			None,
306			#[cfg(feature = "sqlite-local")]
307			(self.profiling.config.enabled && self.backend() == super::SqliteBackend::LocalNative)
308				.then(crate::time::Instant::now),
309		)
310		.await
311	}
312
313	async fn begin_transaction_profiled_inner(
314		&self,
315		key: String,
316		timeout: Duration,
317		_name: Option<String>,
318		#[cfg(feature = "sqlite-local")] started_at: Option<crate::time::Instant>,
319	) -> Result<SqliteTransaction> {
320		#[cfg(feature = "sqlite-local")]
321		let transaction_wait_started_at = started_at.map(|_| crate::time::Instant::now());
322		let epoch = self.transaction_coordinator.epoch.load(Ordering::Acquire);
323		let permit = self.try_transaction_admission()?;
324		let wait = CoordinatorWaitGuard::new(self);
325		let gate_guard = Arc::clone(&self.transaction_coordinator.gate)
326			.write_owned()
327			.await;
328		drop(wait);
329		#[cfg(feature = "sqlite-local")]
330		let transaction_wait = transaction_wait_started_at.map(|started| started.elapsed());
331		{
332			let state = self.transaction_coordinator.state.lock().await;
333			if state.closed {
334				return Err(transaction_coordinator_closed_error());
335			}
336			if self.transaction_coordinator.epoch.load(Ordering::Acquire) != epoch {
337				return Err(transaction_expired_error(
338					state
339						.last_expired_timeout
340						.unwrap_or(DEFAULT_TRANSACTION_TIMEOUT),
341				));
342			}
343			if let Some(error) = transaction_known_state_error(&state, &key) {
344				return Err(error);
345			}
346		}
347
348		#[cfg(feature = "sqlite-local")]
349		let begin_started_at = started_at.map(|_| crate::time::Instant::now());
350		#[cfg(feature = "sqlite-local")]
351		let (begin_result, begin_profile) = if begin_started_at.is_some() {
352			let profiled = self
353				.execute_backend_profiled("BEGIN".to_owned(), None)
354				.await;
355			(
356				profiled.result.map(|result| (result, None)),
357				profiled.profile,
358			)
359		} else {
360			(
361				self.execute_backend_in_session("BEGIN".to_owned(), None, None)
362					.await,
363				None,
364			)
365		};
366		#[cfg(feature = "sqlite-local")]
367		let begin_duration = begin_started_at.map(|started| started.elapsed());
368		#[cfg(not(feature = "sqlite-local"))]
369		let begin_result = self
370			.execute_backend_in_session("BEGIN".to_owned(), None, None)
371			.await;
372		let (_, remote_session) = begin_result.map_err(map_transaction_connection_error)?;
373		// A successful BEGIN response and the coordinator state update are two
374		// separate async events. If the socket disconnected in that narrow gap,
375		// pegboard-envoy has already dropped the connection-owned database handle
376		// and rolled the transaction back. Never publish a handle for that stale
377		// transaction.
378		if let Some(session) = remote_session
379			&& self.handle()?.connection_session() != Some(session)
380		{
381			return Err(transaction_connection_lost_error());
382		}
383		let operation = Arc::new(AsyncMutex::new(()));
384		{
385			let mut state = self.transaction_coordinator.state.lock().await;
386			if state.closed {
387				drop(state);
388				if let Err(error) = self
389					.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
390					.await
391				{
392					tracing::error!(%error, "sqlite rollback after begin raced shutdown failed");
393				}
394				return Err(transaction_coordinator_closed_error());
395			}
396			state.active = Some(ActiveTransaction {
397				key: key.clone(),
398				timeout,
399				expiring: false,
400				connection_lost: false,
401				remote_session,
402				gate_guard: Some(gate_guard),
403				operation,
404				timeout_task: None,
405				#[cfg(feature = "sqlite-local")]
406				profile: started_at.map(|started_at| {
407					let mut profile = TransactionProfile::new(
408						_name,
409						started_at,
410						self.profiling.config.max_statements_per_transaction_trace,
411					);
412					profile.transaction_wait_ns = transaction_wait
413						.unwrap_or_default()
414						.as_nanos()
415						.try_into()
416						.unwrap_or(u64::MAX);
417					let default_profile = depot_client::vfs::SqliteOperationProfile::default();
418					profile.record_control(
419						begin_profile.as_ref().unwrap_or(&default_profile),
420						begin_duration
421							.unwrap_or_default()
422							.as_nanos()
423							.try_into()
424							.unwrap_or(u64::MAX),
425						false,
426					);
427					profile
428				}),
429			});
430		}
431		drop(permit);
432
433		let timeout_task = CancellationToken::new();
434		let mut state = self.transaction_coordinator.state.lock().await;
435		if let Some(active) = state.active.as_mut().filter(|active| active.key == key) {
436			active.timeout_task = Some(timeout_task.clone());
437		} else {
438			timeout_task.cancel();
439		}
440		drop(state);
441		spawn_transaction_timeout(
442			self.clone(),
443			key.clone(),
444			timeout,
445			timeout_task,
446			remote_session,
447		);
448
449		Ok(SqliteTransaction {
450			db: self.clone(),
451			key,
452		})
453	}
454
455	async fn transaction_operation(&self, key: &str) -> Result<(Arc<AsyncMutex<()>>, Option<u64>)> {
456		let state = self.transaction_coordinator.state.lock().await;
457		if state.closed {
458			return Err(transaction_coordinator_closed_error());
459		}
460		if let Some(active) = state.active.as_ref().filter(|active| active.key == key) {
461			if active.expiring {
462				if active.connection_lost {
463					return Err(transaction_connection_lost_error());
464				}
465				return Err(transaction_expired_owner_error(key, active.timeout));
466			}
467			return Ok((Arc::clone(&active.operation), active.remote_session));
468		}
469		if let Some(error) = transaction_known_state_error(&state, key) {
470			return Err(error);
471		}
472		Err(transaction_unknown_error(key))
473	}
474
475	async fn ensure_active_transaction(&self, key: &str) -> Result<()> {
476		let state = self.transaction_coordinator.state.lock().await;
477		if state.closed {
478			return Err(transaction_coordinator_closed_error());
479		}
480		if let Some(active) = state.active.as_ref().filter(|active| active.key == key) {
481			if active.expiring {
482				if active.connection_lost {
483					return Err(transaction_connection_lost_error());
484				}
485				return Err(transaction_expired_owner_error(key, active.timeout));
486			}
487			return Ok(());
488		}
489		if let Some(error) = transaction_known_state_error(&state, key) {
490			return Err(error);
491		}
492		Err(transaction_unknown_error(key))
493	}
494
495	async fn transaction_exec(&self, key: &str, sql: String) -> Result<QueryResult> {
496		let db = self.clone();
497		let key = key.to_owned();
498		run_detached_transaction_task(
499			async move { db.transaction_exec_inner(&key, sql).await },
500			"sqlite transaction exec task failed",
501		)
502		.await
503	}
504
505	async fn transaction_exec_inner(&self, key: &str, sql: String) -> Result<QueryResult> {
506		#[cfg(feature = "sqlite-local")]
507		let started_at = (self.profiling.config.enabled
508			&& self.backend() == super::SqliteBackend::LocalNative)
509			.then(crate::time::Instant::now);
510		let (operation, remote_session) = self.transaction_operation(key).await?;
511		let _operation = operation.lock().await;
512		#[cfg(feature = "sqlite-local")]
513		let transaction_wait = started_at.map(|started| started.elapsed());
514		self.ensure_active_transaction(key).await?;
515		#[cfg(feature = "sqlite-local")]
516		if let Some(started_at) = started_at {
517			let sql_for_profile = sql.clone();
518			let profiled = self.exec_backend_profiled(sql).await;
519			let result = profiled.result;
520			let profile = profiled.profile;
521			if let Some(observation) = self.observe_statement_profile(
522				&sql_for_profile,
523				started_at,
524				transaction_wait.unwrap_or_default(),
525				profile,
526				if result.is_ok() { "success" } else { "error" },
527				"explicit",
528			) {
529				self.record_transaction_statement(key, &observation).await;
530			}
531			return match result {
532				Ok(result) => Ok(result),
533				Err(error) => Err(self.handle_transaction_backend_error(key, error).await),
534			};
535		}
536		match self.exec_backend_in_session(sql, remote_session).await {
537			Ok((result, _)) => Ok(result),
538			Err(error) => Err(self.handle_transaction_backend_error(key, error).await),
539		}
540	}
541
542	async fn transaction_execute(
543		&self,
544		key: &str,
545		sql: String,
546		params: Option<Vec<BindParam>>,
547	) -> Result<ExecuteResult> {
548		let db = self.clone();
549		let key = key.to_owned();
550		run_detached_transaction_task(
551			async move { db.transaction_execute_inner(&key, sql, params).await },
552			"sqlite transaction execute task failed",
553		)
554		.await
555	}
556
557	async fn transaction_execute_inner(
558		&self,
559		key: &str,
560		sql: String,
561		params: Option<Vec<BindParam>>,
562	) -> Result<ExecuteResult> {
563		#[cfg(feature = "sqlite-local")]
564		let started_at = (self.profiling.config.enabled
565			&& self.backend() == super::SqliteBackend::LocalNative)
566			.then(crate::time::Instant::now);
567		let (operation, remote_session) = self.transaction_operation(key).await?;
568		let _operation = operation.lock().await;
569		#[cfg(feature = "sqlite-local")]
570		let transaction_wait = started_at.map(|started| started.elapsed());
571		self.ensure_active_transaction(key).await?;
572		#[cfg(feature = "sqlite-local")]
573		if let Some(started_at) = started_at {
574			let sql_for_profile = sql.clone();
575			let profiled = self.execute_backend_profiled(sql, params).await;
576			let result = profiled.result;
577			let profile = profiled.profile;
578			if let Some(observation) = self.observe_statement_profile(
579				&sql_for_profile,
580				started_at,
581				transaction_wait.unwrap_or_default(),
582				profile,
583				if result.is_ok() { "success" } else { "error" },
584				"explicit",
585			) {
586				self.record_transaction_statement(key, &observation).await;
587			}
588			return match result {
589				Ok(result) => Ok(result),
590				Err(error) => Err(self.handle_transaction_backend_error(key, error).await),
591			};
592		}
593		match self
594			.execute_backend_in_session(sql, params, remote_session)
595			.await
596		{
597			Ok((result, _)) => Ok(result),
598			Err(error) => Err(self.handle_transaction_backend_error(key, error).await),
599		}
600	}
601
602	#[cfg(feature = "sqlite-local")]
603	async fn record_transaction_statement(
604		&self,
605		key: &str,
606		observation: &super::profiling::StatementObservation,
607	) {
608		let mut state = self.transaction_coordinator.state.lock().await;
609		if let Some(active) = state.active.as_mut().filter(|active| active.key == key) {
610			if let Some(profile) = &mut active.profile {
611				profile.record_statement(observation);
612			}
613		}
614	}
615
616	async fn finish_transaction(&self, key: &str, commit: bool) -> Result<()> {
617		let db = self.clone();
618		let key = key.to_owned();
619		run_detached_transaction_task(
620			async move { db.finish_transaction_inner(&key, commit).await },
621			"sqlite transaction finish task failed",
622		)
623		.await
624	}
625
626	async fn finish_transaction_inner(&self, key: &str, commit: bool) -> Result<()> {
627		let (operation, remote_session) = self.transaction_operation(key).await?;
628		let _operation = operation.lock().await;
629		self.ensure_active_transaction(key).await?;
630
631		let statement = if commit { "COMMIT" } else { "ROLLBACK" };
632		#[cfg(feature = "sqlite-local")]
633		let control_started_at = (self.profiling.config.enabled
634			&& self.backend() == super::SqliteBackend::LocalNative)
635			.then(crate::time::Instant::now);
636		#[cfg(feature = "sqlite-local")]
637		let (result, control_profile) = if control_started_at.is_some() {
638			let profiled = self
639				.execute_backend_profiled(statement.to_owned(), None)
640				.await;
641			(
642				profiled.result.map(|result| (result, None)),
643				profiled.profile,
644			)
645		} else {
646			(
647				self.execute_backend_in_session(statement.to_owned(), None, remote_session)
648					.await,
649				None,
650			)
651		};
652		#[cfg(not(feature = "sqlite-local"))]
653		let result = self
654			.execute_backend_in_session(statement.to_owned(), None, remote_session)
655			.await;
656		#[cfg(feature = "sqlite-local")]
657		if let Some(control_started_at) = control_started_at {
658			self.record_transaction_control(
659				key,
660				control_profile.as_ref(),
661				control_started_at.elapsed(),
662				commit,
663			)
664			.await;
665		}
666		if let Err(primary) = result {
667			if is_remote_connection_error(&primary) {
668				self.release_transaction(key, TransactionTerminalState::ConnectionLost, false)
669					.await;
670				return Err(self.attach_actor(map_transaction_connection_error(primary)));
671			}
672			if commit {
673				let rollback_result = self
674					.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
675					.await;
676				let rollback_failed = rollback_result
677					.as_ref()
678					.is_err_and(|error| !is_no_active_transaction_error(error));
679				self.release_transaction(
680					key,
681					TransactionTerminalState::RolledBack,
682					rollback_failed,
683				)
684				.await;
685				if let Err(rollback) = rollback_result
686					&& rollback_failed
687				{
688					tracing::error!(%rollback, "sqlite rollback after failed commit failed");
689				}
690				return Err(self.attach_actor(primary));
691			}
692			if is_no_active_transaction_error(&primary) {
693				self.release_transaction(key, TransactionTerminalState::RolledBack, false)
694					.await;
695				return Ok(());
696			}
697
698			self.release_transaction(key, TransactionTerminalState::RolledBack, true)
699				.await;
700			return Err(self.attach_actor(primary));
701		}
702
703		self.release_transaction(
704			key,
705			if commit {
706				TransactionTerminalState::Committed
707			} else {
708				TransactionTerminalState::RolledBack
709			},
710			false,
711		)
712		.await;
713		Ok(())
714	}
715
716	#[cfg(feature = "sqlite-local")]
717	async fn record_transaction_control(
718		&self,
719		key: &str,
720		profile: Option<&depot_client::vfs::SqliteOperationProfile>,
721		duration: Duration,
722		is_commit: bool,
723	) {
724		let mut state = self.transaction_coordinator.state.lock().await;
725		if let Some(active) = state.active.as_mut().filter(|active| active.key == key) {
726			if let Some(transaction_profile) = &mut active.profile {
727				let default_profile = depot_client::vfs::SqliteOperationProfile::default();
728				transaction_profile.record_control(
729					profile.unwrap_or(&default_profile),
730					duration.as_nanos().try_into().unwrap_or(u64::MAX),
731					is_commit,
732				);
733			}
734		}
735	}
736
737	async fn expire_transaction(&self, key: &str) -> Result<()> {
738		let db = self.clone();
739		let key = key.to_owned();
740		run_detached_transaction_task(
741			async move { db.expire_transaction_inner(&key).await },
742			"sqlite transaction expiry task failed",
743		)
744		.await
745	}
746
747	async fn expire_transaction_inner(&self, key: &str) -> Result<()> {
748		let (operation, timeout, remote_session) = {
749			let mut state = self.transaction_coordinator.state.lock().await;
750			if state.closed {
751				return Err(transaction_coordinator_closed_error());
752			}
753			if let Some(active) = state.active.as_mut().filter(|active| active.key == key) {
754				active.expiring = true;
755				(
756					Arc::clone(&active.operation),
757					active.timeout,
758					active.remote_session,
759				)
760			} else if let Some(error) = transaction_known_state_error(&state, key) {
761				return Err(error);
762			} else {
763				return Err(transaction_unknown_error(key));
764			}
765		};
766		let _operation = operation.lock().await;
767		{
768			let state = self.transaction_coordinator.state.lock().await;
769			if state.closed {
770				return Err(transaction_coordinator_closed_error());
771			}
772			if !state
773				.active
774				.as_ref()
775				.is_some_and(|active| active.key == key)
776			{
777				if let Some(error) = transaction_known_state_error(&state, key) {
778					return Err(error);
779				}
780				return Err(transaction_unknown_error(key));
781			}
782		}
783		// The deadline only wins after any operation that already owned the
784		// transaction settles. A commit or rollback that acquired the operation
785		// lock first is allowed to finish without poisoning parked work.
786		self.transaction_coordinator
787			.epoch
788			.fetch_add(1, Ordering::AcqRel);
789		let rollback = self
790			.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
791			.await;
792		let rollback_failed = rollback.as_ref().is_err_and(|error| {
793			!is_no_active_transaction_error(error) && !is_remote_connection_error(error)
794		});
795		// Advance again before releasing the exclusive gate. Calls submitted both
796		// before and during expiry cleanup must observe a changed epoch and fail;
797		// only calls submitted after cleanup may proceed.
798		self.transaction_coordinator
799			.epoch
800			.fetch_add(1, Ordering::AcqRel);
801		self.release_transaction(
802			key,
803			TransactionTerminalState::Expired(timeout),
804			rollback_failed,
805		)
806		.await;
807		if let Err(error) = rollback
808			&& rollback_failed
809		{
810			return Err(self.attach_actor(error));
811		}
812		Ok(())
813	}
814
815	async fn connection_lost_transaction(&self, key: &str, expected_session: u64) -> Result<()> {
816		let (operation, still_expected) = {
817			let mut state = self.transaction_coordinator.state.lock().await;
818			let Some(active) = state.active.as_mut().filter(|active| active.key == key) else {
819				return Ok(());
820			};
821			let still_expected = active.remote_session == Some(expected_session);
822			if still_expected {
823				active.expiring = true;
824				active.connection_lost = true;
825			}
826			(Arc::clone(&active.operation), still_expected)
827		};
828		if !still_expected {
829			return Ok(());
830		}
831		let _operation = operation.lock().await;
832
833		// The remote database handle is scoped to the server-side WebSocket
834		// connection. Disconnect closes it and SQLite rolls back its open
835		// transaction. Sending ROLLBACK on a replacement connection would target a
836		// different database session, so cleanup here is intentionally local: make
837		// the handle terminal and release actor/socket work queued behind it.
838		self.release_transaction(key, TransactionTerminalState::ConnectionLost, false)
839			.await;
840		Ok(())
841	}
842
843	async fn handle_transaction_backend_error(
844		&self,
845		key: &str,
846		error: anyhow::Error,
847	) -> anyhow::Error {
848		if is_remote_connection_error(&error) {
849			self.release_transaction(key, TransactionTerminalState::ConnectionLost, false)
850				.await;
851		}
852		self.attach_actor(map_transaction_connection_error(error))
853	}
854
855	pub(super) async fn release_transaction(
856		&self,
857		key: &str,
858		terminal: TransactionTerminalState,
859		close: bool,
860	) {
861		let mut state = self.transaction_coordinator.state.lock().await;
862		if !state
863			.active
864			.as_ref()
865			.is_some_and(|active| active.key == key)
866		{
867			return;
868		}
869		let mut active = state.active.take().expect("active transaction was checked");
870		if let Some(task) = active.timeout_task.take() {
871			task.cancel();
872		}
873		if let TransactionTerminalState::Expired(timeout) = terminal {
874			state.last_expired_timeout = Some(timeout);
875		}
876		insert_terminal_state(&mut state, key.to_owned(), terminal);
877		state.closed |= close;
878		if close {
879			self.transaction_coordinator.admission.close();
880			if let Ok(config) = self.runtime_config() {
881				report_sqlite_worker_fatal(
882					&self.worker_fatal_reported,
883					config,
884					"sqlite transaction rollback failed; coordinator closed".to_owned(),
885				);
886			}
887		}
888		drop(active.gate_guard.take());
889		#[cfg(feature = "sqlite-local")]
890		let profile = active.profile;
891		drop(state);
892
893		#[cfg(feature = "sqlite-local")]
894		if self.backend() == super::SqliteBackend::LocalNative
895			&& let Some(profile) = profile
896		{
897			let (fingerprint, fingerprint_source) = profile.fingerprint();
898			if profile.name.is_none() {
899				let warning = UNNAMED_TRANSACTION_WARNINGS.fetch_add(1, Ordering::Relaxed);
900				if warning == 0 || warning.is_power_of_two() {
901					tracing::warn!(
902						fingerprint,
903						"SQLite transaction is unnamed. Add a static `name` to group transaction, storage, and commit performance across executions. Individual statement profiling remains available."
904					);
905				}
906			}
907			if let Some(metrics) = &self.vfs_metrics {
908				let total_ns = profile
909					.started_at
910					.elapsed()
911					.as_nanos()
912					.try_into()
913					.unwrap_or(u64::MAX);
914				let application_time_ns = total_ns
915					.saturating_sub(profile.transaction_wait_ns)
916					.saturating_sub(profile.worker_wait_ns)
917					.saturating_sub(profile.storage_ns)
918					.saturating_sub(profile.local_work_ns);
919				let metric = depot_client::vfs::SqliteTransactionMetric {
920					fingerprint,
921					fingerprint_source,
922					shape_fingerprint: profile.shape_fingerprint(),
923					statement_fingerprint_hashes: profile.statement_fingerprint_hashes,
924					omitted_statement_fingerprints: profile.omitted_statement_fingerprints,
925					storage_transport: "proxy",
926					outcome: match terminal {
927						TransactionTerminalState::Committed => "success",
928						TransactionTerminalState::RolledBack => "rollback",
929						TransactionTerminalState::Expired(_) => "expired",
930						TransactionTerminalState::ConnectionLost => "connection_lost",
931					},
932					total_ns,
933					transaction_wait_ns: profile.transaction_wait_ns,
934					worker_wait_ns: profile.worker_wait_ns,
935					storage_ns: profile.storage_ns,
936					local_work_ns: profile.local_work_ns,
937					application_time_ns,
938					commit_ns: profile.commit_ns,
939					get_pages_round_trips: profile.get_pages_round_trips,
940					statement_count: profile.statement_count,
941					dirty_pages: profile.dirty_pages,
942					dirty_bytes: profile.dirty_bytes,
943				};
944				if metrics.observe_transaction_profile(&metric)
945					&& self.profiling.mark_cataloged(&metric.fingerprint)
946				{
947					metrics.record_fingerprint_catalog(
948						"transaction",
949						&metric.fingerprint,
950						profile.name.as_deref().unwrap_or(&metric.fingerprint),
951						FINGERPRINT_FORMAT_VERSION,
952					);
953				}
954				metrics.emit_transaction_diagnostic_event(
955					self.actor_id.as_deref().unwrap_or("unknown"),
956					self.generation,
957					&metric,
958				);
959			}
960		}
961	}
962
963	pub(super) async fn shutdown_transaction_coordinator(&self) -> OwnedRwLockWriteGuard<()> {
964		let active_operation = {
965			let mut state = self.transaction_coordinator.state.lock().await;
966			state.closed = true;
967			state.active.as_ref().map(|active| {
968				(
969					active.key.clone(),
970					Arc::clone(&active.operation),
971					active.remote_session,
972				)
973			})
974		};
975		self.transaction_coordinator.admission.close();
976		if let Some((key, operation, remote_session)) = active_operation {
977			let _operation = operation.lock().await;
978			let still_active = {
979				let state = self.transaction_coordinator.state.lock().await;
980				state
981					.active
982					.as_ref()
983					.is_some_and(|active| active.key == key)
984			};
985			if still_active {
986				let rollback = self
987					.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
988					.await;
989				let rollback_failed = rollback.as_ref().is_err_and(|error| {
990					!is_no_active_transaction_error(error) && !is_remote_connection_error(error)
991				});
992				if let Err(error) = &rollback
993					&& rollback_failed
994				{
995					tracing::error!(%error, "sqlite rollback during coordinator shutdown failed");
996				}
997				self.transaction_coordinator
998					.epoch
999					.fetch_add(1, Ordering::AcqRel);
1000				self.release_transaction(
1001					&key,
1002					TransactionTerminalState::RolledBack,
1003					rollback_failed,
1004				)
1005				.await;
1006			}
1007		}
1008		Arc::clone(&self.transaction_coordinator.gate)
1009			.write_owned()
1010			.await
1011	}
1012}
1013
1014#[derive(rivet_error::RivetError, Debug, Serialize)]
1015#[error(
1016	"sqlite",
1017	"transaction_queue_full",
1018	"SQLite transaction queue is full.",
1019	"SQLite transaction coordinator queue is full. Limit is 128 operations."
1020)]
1021pub struct TransactionQueueFullError;
1022
1023impl fmt::Display for TransactionQueueFullError {
1024	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1025		f.write_str("sqlite transaction coordinator queue is full")
1026	}
1027}
1028
1029impl Error for TransactionQueueFullError {}
1030
1031#[derive(rivet_error::RivetError, Debug, Serialize)]
1032#[error(
1033	"sqlite",
1034	"transaction_closed",
1035	"SQLite transaction coordinator is closed."
1036)]
1037pub struct TransactionCoordinatorClosedError;
1038
1039impl fmt::Display for TransactionCoordinatorClosedError {
1040	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1041		f.write_str("sqlite transaction coordinator is closed")
1042	}
1043}
1044
1045impl Error for TransactionCoordinatorClosedError {}
1046
1047#[derive(rivet_error::RivetError, Debug, Serialize)]
1048#[error(
1049	"sqlite",
1050	"transaction_invalid_argument",
1051	"Invalid SQLite transaction argument.",
1052	"{message}"
1053)]
1054pub struct TransactionInvalidArgumentError {
1055	pub message: &'static str,
1056}
1057
1058impl fmt::Display for TransactionInvalidArgumentError {
1059	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1060		f.write_str(self.message)
1061	}
1062}
1063
1064impl Error for TransactionInvalidArgumentError {}
1065
1066#[derive(rivet_error::RivetError, Debug, Serialize)]
1067#[error(
1068	"sqlite",
1069	"transaction_connection_lost",
1070	"SQLite transaction connection was lost.",
1071	"The Envoy connection that owned this SQLite transaction disconnected. The transaction was rolled back and cannot be resumed; start a new db.transaction()."
1072)]
1073pub struct TransactionConnectionLostError;
1074
1075impl fmt::Display for TransactionConnectionLostError {
1076	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1077		f.write_str(
1078			"the Envoy connection that owned this SQLite transaction disconnected; the transaction was rolled back and cannot be resumed",
1079		)
1080	}
1081}
1082
1083impl Error for TransactionConnectionLostError {}
1084
1085#[derive(rivet_error::RivetError, Debug, Serialize)]
1086#[error(
1087	"sqlite",
1088	"transaction_unknown",
1089	"Unknown SQLite transaction handle.",
1090	"Unknown SQLite transaction handle `{key}`."
1091)]
1092pub struct TransactionUnknownError {
1093	pub key: String,
1094}
1095
1096impl fmt::Display for TransactionUnknownError {
1097	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1098		write!(f, "unknown sqlite transaction handle `{}`", self.key)
1099	}
1100}
1101
1102impl Error for TransactionUnknownError {}
1103
1104#[derive(rivet_error::RivetError, Debug, Serialize)]
1105#[error(
1106	"sqlite",
1107	"transaction_terminal",
1108	"SQLite transaction handle is terminal.",
1109	"SQLite transaction handle `{key}` is already {state}."
1110)]
1111pub struct TransactionTerminalError {
1112	pub key: String,
1113	pub state: &'static str,
1114}
1115
1116impl fmt::Display for TransactionTerminalError {
1117	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1118		write!(
1119			f,
1120			"sqlite transaction handle `{}` is already {}",
1121			self.key, self.state
1122		)
1123	}
1124}
1125
1126impl Error for TransactionTerminalError {}
1127
1128#[derive(rivet_error::RivetError, Debug, Serialize)]
1129#[error(
1130	"sqlite",
1131	"transaction_expired",
1132	"SQLite transaction expired.",
1133	"SQLite transaction expired after {timeout_ms} ms and was rolled back; this timeout is a deadlock-safety backstop. Increase the db.transaction() `timeout` option if the transaction legitimately needs longer, and check for a nested transaction or use of the outer `db` instead of `tx` inside the callback."
1134)]
1135pub struct TransactionExpiredError {
1136	pub timeout_ms: u64,
1137}
1138
1139impl fmt::Display for TransactionExpiredError {
1140	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1141		write!(
1142			f,
1143			"sqlite transaction expired after {} ms and was rolled back; this timeout is a deadlock-safety backstop. Increase the db.transaction() `timeout` option if the transaction legitimately needs longer, and check for a nested transaction or use of the outer `db` instead of `tx` inside the callback",
1144			self.timeout_ms
1145		)
1146	}
1147}
1148
1149impl Error for TransactionExpiredError {}
1150
1151impl TransactionExpiredError {
1152	fn owner(_key: &str, timeout: Duration) -> Self {
1153		Self {
1154			timeout_ms: duration_millis(timeout),
1155		}
1156	}
1157
1158	fn parked(timeout: Duration) -> Self {
1159		Self {
1160			timeout_ms: duration_millis(timeout),
1161		}
1162	}
1163}
1164
1165fn duration_millis(duration: Duration) -> u64 {
1166	duration.as_millis().try_into().unwrap_or(u64::MAX)
1167}
1168
1169async fn sleep_transaction_timeout(mut timeout: Duration) {
1170	// Browser timers commonly cap one wait at signed 32-bit milliseconds.
1171	// Chunking preserves the caller's unbounded product-level timeout on wasm.
1172	const MAX_TIMER_WAIT: Duration = Duration::from_millis(2_000_000_000);
1173	while timeout > MAX_TIMER_WAIT {
1174		crate::time::sleep(MAX_TIMER_WAIT).await;
1175		timeout = timeout.saturating_sub(MAX_TIMER_WAIT);
1176	}
1177	crate::time::sleep(timeout).await;
1178}
1179
1180fn spawn_transaction_timeout(
1181	db: SqliteDb,
1182	key: String,
1183	timeout: Duration,
1184	cancel: CancellationToken,
1185	remote_session: Option<u64>,
1186) {
1187	let task = async move {
1188		let connection_lost = async {
1189			let Some(expected) = remote_session else {
1190				std::future::pending::<()>().await;
1191				return;
1192			};
1193			let Ok(handle) = db.handle() else {
1194				return;
1195			};
1196			let mut sessions = handle.subscribe_connection_session();
1197			loop {
1198				if *sessions.borrow_and_update() != expected {
1199					return;
1200				}
1201				if sessions.changed().await.is_err() {
1202					return;
1203				}
1204			}
1205		};
1206		tokio::pin!(connection_lost);
1207
1208		let expired = tokio::select! {
1209			_ = cancel.cancelled() => return,
1210			_ = sleep_transaction_timeout(timeout) => true,
1211			_ = &mut connection_lost => false,
1212		};
1213		let result = if expired {
1214			db.expire_transaction(&key).await
1215		} else {
1216			// This covers all disconnect timing windows. During BEGIN there is no
1217			// active coordinator entry yet and the BEGIN caller receives its own
1218			// indeterminate/session error. Between statements this branch is the only
1219			// signal, and it prevents the next statement from autocommitting after a
1220			// fast reconnect. During a statement or COMMIT, request tracking preserves
1221			// the operation's indeterminate result while this terminalizes the handle.
1222			db.connection_lost_transaction(&key, remote_session.expect("checked above"))
1223				.await
1224		};
1225		if let Err(error) = result {
1226			if error.downcast_ref::<TransactionTerminalError>().is_none() {
1227				tracing::error!(%error, "sqlite transaction terminal cleanup failed");
1228			}
1229		}
1230	};
1231
1232	#[cfg(target_arch = "wasm32")]
1233	wasm_bindgen_futures::spawn_local(task);
1234
1235	#[cfg(not(target_arch = "wasm32"))]
1236	RuntimeSpawner::spawn(task);
1237}
1238
1239#[cfg(not(target_arch = "wasm32"))]
1240pub(super) async fn run_detached_transaction_task<F, T>(
1241	future: F,
1242	context: &'static str,
1243) -> Result<T>
1244where
1245	F: Future<Output = Result<T>> + Send + 'static,
1246	T: Send + 'static,
1247{
1248	RuntimeSpawner::spawn(future).await.context(context)?
1249}
1250
1251#[cfg(target_arch = "wasm32")]
1252pub(super) async fn run_detached_transaction_task<F, T>(
1253	future: F,
1254	context: &'static str,
1255) -> Result<T>
1256where
1257	F: Future<Output = Result<T>> + 'static,
1258	T: 'static,
1259{
1260	let (response_tx, response_rx) = oneshot::channel();
1261	wasm_bindgen_futures::spawn_local(async move {
1262		let _ = response_tx.send(future.await);
1263	});
1264	response_rx.await.context(context)?
1265}
1266
1267pub(super) fn insert_terminal_state(
1268	state: &mut TransactionCoordinatorState,
1269	key: String,
1270	terminal: TransactionTerminalState,
1271) {
1272	if let TransactionTerminalState::Expired(timeout) = terminal {
1273		state.poisoned.insert(key, timeout);
1274		return;
1275	}
1276	if state.terminal.insert(key.clone(), terminal).is_none() {
1277		state.terminal_order.push_back(key);
1278	}
1279	while state.terminal_order.len() > TRANSACTION_TERMINAL_CAPACITY {
1280		if let Some(expired_key) = state.terminal_order.pop_front() {
1281			state.terminal.remove(&expired_key);
1282		}
1283	}
1284}
1285
1286fn transaction_known_state_error(
1287	state: &TransactionCoordinatorState,
1288	key: &str,
1289) -> Option<anyhow::Error> {
1290	if let Some(timeout) = state.poisoned.get(key).copied() {
1291		return Some(transaction_expired_owner_error(key, timeout));
1292	}
1293	state
1294		.terminal
1295		.get(key)
1296		.copied()
1297		.map(|terminal| transaction_terminal_error(key, terminal))
1298}
1299
1300fn is_no_active_transaction_error(error: &anyhow::Error) -> bool {
1301	// SQLite does not expose a dedicated result code for "cannot commit/rollback
1302	// - no transaction is active"; both native SQLite and the remote executor
1303	// surface SQLITE_ERROR plus this stable SQLite-generated text. Keep the
1304	// classifier isolated here. In particular, ON CONFLICT ROLLBACK can end a
1305	// transaction before our cleanup call, and treating that cleanup response as
1306	// fatal would permanently close the coordinator. The cross-runtime actor-db
1307	// suite exercises that real automatic-rollback path on native and Wasm.
1308	error.chain().any(|cause| {
1309		cause
1310			.to_string()
1311			.to_ascii_lowercase()
1312			.contains("no transaction is active")
1313	})
1314}
1315
1316fn transaction_queue_full_error() -> anyhow::Error {
1317	TransactionQueueFullError
1318		.build()
1319		.context(TransactionQueueFullError)
1320}
1321
1322fn transaction_coordinator_closed_error() -> anyhow::Error {
1323	TransactionCoordinatorClosedError
1324		.build()
1325		.context(TransactionCoordinatorClosedError)
1326}
1327
1328fn transaction_unknown_error(key: &str) -> anyhow::Error {
1329	TransactionUnknownError {
1330		key: key.to_owned(),
1331	}
1332	.build()
1333	.context(TransactionUnknownError {
1334		key: key.to_owned(),
1335	})
1336}
1337
1338fn transaction_expired_error(timeout: Duration) -> anyhow::Error {
1339	TransactionExpiredError::parked(timeout)
1340		.build()
1341		.context(TransactionExpiredError::parked(timeout))
1342}
1343
1344fn transaction_expired_owner_error(key: &str, timeout: Duration) -> anyhow::Error {
1345	TransactionExpiredError::owner(key, timeout)
1346		.build()
1347		.context(TransactionExpiredError::owner(key, timeout))
1348}
1349
1350fn transaction_terminal_error(key: &str, terminal: TransactionTerminalState) -> anyhow::Error {
1351	match terminal {
1352		TransactionTerminalState::Expired(timeout) => transaction_expired_owner_error(key, timeout),
1353		TransactionTerminalState::Committed => transaction_terminal_state_error(key, "committed"),
1354		TransactionTerminalState::RolledBack => {
1355			transaction_terminal_state_error(key, "rolled back")
1356		}
1357		TransactionTerminalState::ConnectionLost => transaction_connection_lost_error(),
1358	}
1359}
1360
1361fn transaction_invalid_argument_error(message: &'static str) -> anyhow::Error {
1362	TransactionInvalidArgumentError { message }
1363		.build()
1364		.context(TransactionInvalidArgumentError { message })
1365}
1366
1367fn transaction_connection_lost_error() -> anyhow::Error {
1368	TransactionConnectionLostError
1369		.build()
1370		.context(TransactionConnectionLostError)
1371}
1372
1373fn is_remote_connection_error(error: &anyhow::Error) -> bool {
1374	error
1375		.downcast_ref::<super::RemoteSqliteConnectionSessionLostError>()
1376		.is_some()
1377		|| error
1378			.downcast_ref::<super::RemoteSqliteIndeterminateResultError>()
1379			.is_some()
1380}
1381
1382fn map_transaction_connection_error(error: anyhow::Error) -> anyhow::Error {
1383	if error
1384		.downcast_ref::<super::RemoteSqliteConnectionSessionLostError>()
1385		.is_some()
1386	{
1387		return transaction_connection_lost_error();
1388	}
1389	super::remote_request_error(error)
1390}
1391
1392fn transaction_terminal_state_error(key: &str, state: &'static str) -> anyhow::Error {
1393	TransactionTerminalError {
1394		key: key.to_owned(),
1395		state,
1396	}
1397	.build()
1398	.context(TransactionTerminalError {
1399		key: key.to_owned(),
1400		state,
1401	})
1402}