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