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, LazyLock,
8		atomic::{AtomicU64, Ordering},
9	},
10	time::Duration,
11};
12
13use anyhow::{Context, Result};
14use rivet_metrics::prometheus::{IntCounter, IntGauge};
15use serde::Serialize;
16#[cfg(target_arch = "wasm32")]
17use tokio::sync::oneshot;
18use tokio::sync::{
19	Mutex as AsyncMutex, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, OwnedSemaphorePermit, RwLock,
20	Semaphore, TryAcquireError,
21};
22use tokio_util::sync::CancellationToken;
23
24#[cfg(not(target_arch = "wasm32"))]
25use crate::runtime::RuntimeSpawner;
26use crate::telemetry::SqliteOperation;
27
28#[cfg(feature = "sqlite-local")]
29use super::profiling::{FINGERPRINT_FORMAT_VERSION, TransactionProfile};
30use super::{BindParam, ExecuteResult, QueryResult, SqliteDb, report_sqlite_worker_fatal};
31
32pub const DEFAULT_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(60);
33#[cfg(not(feature = "sqlite-local"))]
34const MAX_TRANSACTION_NAME_BYTES: usize = 128;
35pub const TRANSACTION_COORDINATOR_QUEUE_CAPACITY: usize = 128;
36pub(super) const TRANSACTION_TERMINAL_CAPACITY: usize = 1024;
37/// Maximum expired transaction keys each actor remembers so stale handles get
38/// an "expired" error. Older keys are evicted first.
39pub(super) const TRANSACTION_EXPIRED_CAPACITY: usize = 1024;
40#[cfg(feature = "sqlite-local")]
41static UNNAMED_TRANSACTION_WARNINGS: AtomicU64 = AtomicU64::new(0);
42
43#[derive(Clone)]
44pub struct SqliteTransaction {
45	db: SqliteDb,
46	key: String,
47}
48
49impl SqliteTransaction {
50	pub fn key(&self) -> &str {
51		&self.key
52	}
53
54	pub async fn exec(&self, sql: impl Into<String>) -> Result<QueryResult> {
55		self.db.transaction_exec(&self.key, sql.into()).await
56	}
57
58	pub async fn execute(
59		&self,
60		sql: impl Into<String>,
61		params: Option<Vec<BindParam>>,
62	) -> Result<ExecuteResult> {
63		self.db
64			.transaction_execute(&self.key, sql.into(), params)
65			.await
66	}
67
68	pub async fn commit(&self) -> Result<()> {
69		self.db.finish_transaction(&self.key, true).await
70	}
71
72	pub async fn rollback(&self) -> Result<()> {
73		self.db.finish_transaction(&self.key, false).await
74	}
75
76	pub async fn expire(&self) -> Result<()> {
77		self.db.expire_transaction(&self.key).await
78	}
79}
80
81pub(super) struct TransactionCoordinator {
82	pub(super) gate: Arc<RwLock<()>>,
83	pub(super) admission: Arc<Semaphore>,
84	pub(super) state: AsyncMutex<TransactionCoordinatorState>,
85	epoch: AtomicU64,
86	waiters: AtomicU64,
87	/// Coordinated begins currently parked behind a manual transaction.
88	pub(super) manual_transaction_waiters: AtomicU64,
89	/// Cancelled when the coordinator closes so begins parked behind a manual
90	/// transaction stop waiting.
91	closed_token: CancellationToken,
92}
93
94pub(super) struct TransactionCoordinatorState {
95	pub(super) active: Option<ActiveTransaction>,
96	pub(super) terminal: BTreeMap<String, TransactionTerminalState>,
97	pub(super) terminal_order: VecDeque<String>,
98	pub(super) poisoned: BTreeMap<String, ExpiredKey>,
99	pub(super) poisoned_order: VecDeque<String>,
100	pub(super) last_expired_timeout: Option<Duration>,
101	pub(super) closed: bool,
102}
103
104/// An expired transaction key, counted on the process-wide expired key gauge while it is kept.
105pub(super) struct ExpiredKey {
106	pub(super) timeout: Duration,
107	_metric: rivet_metrics::IntGaugeGuard,
108}
109
110/// Process-wide usage of the per-actor expired transaction key maps. The
111/// usage gauge is summed over every actor in the process.
112struct ExpiredKeyMetrics {
113	keys: IntGauge,
114	evicted_total: IntCounter,
115}
116
117static EXPIRED_KEY_METRICS: LazyLock<ExpiredKeyMetrics> = LazyLock::new(|| {
118	let keys = IntGauge::new(
119		"rivetkit_sqlite_transaction_expired_keys",
120		"expired SQLite transaction keys remembered across all actors in the process",
121	)
122	.expect("create rivetkit_sqlite_transaction_expired_keys gauge");
123	let capacity = IntGauge::new(
124		"rivetkit_sqlite_transaction_expired_keys_capacity",
125		"maximum expired SQLite transaction keys remembered per actor",
126	)
127	.expect("create rivetkit_sqlite_transaction_expired_keys_capacity gauge");
128	let evicted_total = IntCounter::new(
129		"rivetkit_sqlite_transaction_expired_keys_evicted_total",
130		"total expired SQLite transaction keys evicted because an actor reached the cap",
131	)
132	.expect("create rivetkit_sqlite_transaction_expired_keys_evicted_total counter");
133	capacity.set(TRANSACTION_EXPIRED_CAPACITY as i64);
134
135	for collector in [
136		Box::new(keys.clone()) as Box<dyn rivet_metrics::prometheus::core::Collector>,
137		Box::new(capacity),
138		Box::new(evicted_total.clone()),
139	] {
140		if let Err(error) = rivet_metrics::REGISTRY.register(collector) {
141			tracing::warn!(
142				?error,
143				"sqlite transaction metric registration failed, using existing collector"
144			);
145		}
146	}
147
148	ExpiredKeyMetrics {
149		keys,
150		evicted_total,
151	}
152});
153
154/// Process-wide visibility into coordinated transactions that wait for a
155/// manual transaction (`BEGIN` through ordinary statements) to finish.
156struct ManualTransactionWaitMetrics {
157	waiters: IntGauge,
158	timeouts_total: IntCounter,
159}
160
161static MANUAL_TRANSACTION_WAIT_METRICS: LazyLock<ManualTransactionWaitMetrics> = LazyLock::new(
162	|| {
163		let waiters = IntGauge::new(
164			"rivetkit_sqlite_manual_transaction_waiters",
165			"coordinated SQLite transactions waiting for a manual transaction to finish across all actors in the process",
166		)
167		.expect("create rivetkit_sqlite_manual_transaction_waiters gauge");
168		let timeouts_total = IntCounter::new(
169			"rivetkit_sqlite_manual_transaction_wait_timeouts_total",
170			"total coordinated SQLite transactions that timed out waiting for a manual transaction to finish",
171		)
172		.expect("create rivetkit_sqlite_manual_transaction_wait_timeouts_total counter");
173
174		for collector in [
175			Box::new(waiters.clone()) as Box<dyn rivet_metrics::prometheus::core::Collector>,
176			Box::new(timeouts_total.clone()),
177		] {
178			if let Err(error) = rivet_metrics::REGISTRY.register(collector) {
179				tracing::warn!(
180					?error,
181					"sqlite manual transaction metric registration failed, using existing collector"
182				);
183			}
184		}
185
186		ManualTransactionWaitMetrics {
187			waiters,
188			timeouts_total,
189		}
190	},
191);
192
193/// Counts one coordinated begin parked behind a manual transaction.
194struct ManualTransactionWaitGuard<'a> {
195	coordinator: &'a TransactionCoordinator,
196	_metric: rivet_metrics::IntGaugeGuard,
197}
198
199impl<'a> ManualTransactionWaitGuard<'a> {
200	fn new(db: &'a SqliteDb, timeout: Duration) -> Self {
201		let coordinator = db.transaction_coordinator.as_ref();
202		let previous = coordinator
203			.manual_transaction_waiters
204			.fetch_add(1, Ordering::AcqRel);
205		if previous == 0 {
206			tracing::warn!(
207				actor_id = db.actor_id.as_deref().unwrap_or("unknown"),
208				timeout_ms = duration_millis(timeout),
209				"sqlite transaction is waiting for a manual transaction to finish; a BEGIN issued through ordinary statements has not reached COMMIT or ROLLBACK, so actor state persistence and db.transaction() are delayed"
210			);
211		}
212		Self {
213			coordinator,
214			_metric: rivet_metrics::GaugeGuardExt::inc_guard(
215				&MANUAL_TRANSACTION_WAIT_METRICS.waiters,
216			),
217		}
218	}
219}
220
221impl Drop for ManualTransactionWaitGuard<'_> {
222	fn drop(&mut self) {
223		self.coordinator
224			.manual_transaction_waiters
225			.fetch_sub(1, Ordering::AcqRel);
226	}
227}
228
229pub(super) struct ActiveTransaction {
230	key: String,
231	timeout: Duration,
232	pub(super) expiring: bool,
233	connection_lost: bool,
234	remote_session: Option<u64>,
235	gate_guard: Option<OwnedRwLockWriteGuard<()>>,
236	operation: Arc<AsyncMutex<()>>,
237	timeout_task: Option<CancellationToken>,
238	#[cfg(feature = "sqlite-local")]
239	profile: Option<TransactionProfile>,
240}
241
242#[derive(Clone, Copy)]
243pub(super) enum TransactionTerminalState {
244	Committed,
245	RolledBack,
246	Expired(Duration),
247	ConnectionLost,
248}
249
250pub(super) struct RegularOperationGuard {
251	_gate: OwnedRwLockReadGuard<()>,
252	_permit: OwnedSemaphorePermit,
253}
254
255struct CoordinatorWaitGuard<'a> {
256	db: &'a SqliteDb,
257}
258
259impl<'a> CoordinatorWaitGuard<'a> {
260	#[cfg(feature = "sqlite-local")]
261	fn new(db: &'a SqliteDb) -> Option<Self> {
262		let metrics = db.vfs_metrics.as_ref()?;
263		let _depth = db
264			.transaction_coordinator
265			.waiters
266			.fetch_add(1, Ordering::AcqRel)
267			.saturating_add(1);
268		metrics.set_coordinator_queue_depth(_depth);
269		Some(Self { db })
270	}
271
272	#[cfg(not(feature = "sqlite-local"))]
273	fn new(_db: &'a SqliteDb) -> Option<Self> {
274		None
275	}
276}
277
278impl Drop for CoordinatorWaitGuard<'_> {
279	fn drop(&mut self) {
280		let _depth = self
281			.db
282			.transaction_coordinator
283			.waiters
284			.fetch_sub(1, Ordering::AcqRel)
285			.saturating_sub(1);
286		#[cfg(feature = "sqlite-local")]
287		if let Some(metrics) = &self.db.vfs_metrics {
288			metrics.set_coordinator_queue_depth(_depth);
289		}
290	}
291}
292
293impl Default for TransactionCoordinator {
294	fn default() -> Self {
295		Self {
296			gate: Arc::new(RwLock::new(())),
297			admission: Arc::new(Semaphore::new(TRANSACTION_COORDINATOR_QUEUE_CAPACITY)),
298			state: AsyncMutex::new(TransactionCoordinatorState {
299				active: None,
300				terminal: BTreeMap::new(),
301				terminal_order: VecDeque::new(),
302				poisoned: BTreeMap::new(),
303				poisoned_order: VecDeque::new(),
304				last_expired_timeout: None,
305				closed: false,
306			}),
307			epoch: AtomicU64::new(0),
308			waiters: AtomicU64::new(0),
309			manual_transaction_waiters: AtomicU64::new(0),
310			closed_token: CancellationToken::new(),
311		}
312	}
313}
314
315impl SqliteDb {
316	pub(super) fn try_transaction_admission(&self) -> Result<OwnedSemaphorePermit> {
317		match Arc::clone(&self.transaction_coordinator.admission).try_acquire_owned() {
318			Ok(permit) => Ok(permit),
319			Err(TryAcquireError::NoPermits) => Err(transaction_queue_full_error()),
320			Err(TryAcquireError::Closed) => Err(transaction_coordinator_closed_error()),
321		}
322	}
323
324	pub(super) async fn begin_regular_operation(&self) -> Result<RegularOperationGuard> {
325		// A statement outside a transaction must not run before the staged schema exists. The
326		// commit is boxed so every statement's future stays small.
327		if self.has_deferred_bootstrap() {
328			Box::pin(self.commit_deferred_bootstrap()).await?;
329		}
330		self.begin_admitted_operation().await
331	}
332
333	/// Admits a statement outside a transaction without committing a staged bootstrap first.
334	/// Only a caller that carries the staged statements itself may use it.
335	pub(super) async fn begin_admitted_operation(&self) -> Result<RegularOperationGuard> {
336		let epoch = self.transaction_coordinator.epoch.load(Ordering::Acquire);
337		let permit = self.try_transaction_admission()?;
338		let wait = CoordinatorWaitGuard::new(self);
339		let gate = Arc::clone(&self.transaction_coordinator.gate)
340			.read_owned()
341			.await;
342		drop(wait);
343		let state = self.transaction_coordinator.state.lock().await;
344		if state.closed {
345			return Err(transaction_coordinator_closed_error());
346		}
347		if self.transaction_coordinator.epoch.load(Ordering::Acquire) != epoch {
348			return Err(transaction_expired_error(
349				state
350					.last_expired_timeout
351					.unwrap_or(DEFAULT_TRANSACTION_TIMEOUT),
352			));
353		}
354		drop(state);
355		Ok(RegularOperationGuard {
356			_gate: gate,
357			_permit: permit,
358		})
359	}
360
361	pub async fn begin_transaction(&self, timeout: Option<Duration>) -> Result<SqliteTransaction> {
362		self.begin_named_transaction(None, timeout).await
363	}
364
365	pub async fn begin_named_transaction(
366		&self,
367		name: Option<&str>,
368		timeout: Option<Duration>,
369	) -> Result<SqliteTransaction> {
370		#[cfg(feature = "sqlite-local")]
371		let max_name_bytes = self.profiling.config.max_transaction_name_bytes;
372		#[cfg(not(feature = "sqlite-local"))]
373		let max_name_bytes = MAX_TRANSACTION_NAME_BYTES;
374		if let Some(name) = name {
375			if name.is_empty() {
376				return Err(transaction_invalid_argument_error(
377					"transaction name must not be empty",
378				));
379			}
380			if name.len() > max_name_bytes {
381				return Err(transaction_invalid_argument_error(
382					"transaction name exceeds the configured byte limit",
383				));
384			}
385		}
386		self.begin_transaction_with_key_and_name(
387			uuid::Uuid::new_v4().to_string(),
388			name.map(ToOwned::to_owned),
389			timeout,
390		)
391		.await
392	}
393
394	pub async fn begin_transaction_with_key(
395		&self,
396		key: impl Into<String>,
397		timeout: Option<Duration>,
398	) -> Result<SqliteTransaction> {
399		self.begin_transaction_with_key_and_name(key, None, timeout)
400			.await
401	}
402
403	async fn begin_transaction_with_key_and_name(
404		&self,
405		key: impl Into<String>,
406		name: Option<String>,
407		timeout: Option<Duration>,
408	) -> Result<SqliteTransaction> {
409		#[cfg(feature = "sqlite-local")]
410		let started_at =
411			(self.backend() == super::SqliteBackend::LocalNative).then(crate::time::Instant::now);
412		let key = key.into();
413		let timeout = timeout.unwrap_or(DEFAULT_TRANSACTION_TIMEOUT);
414		if key.is_empty() {
415			return Err(transaction_invalid_argument_error(
416				"transaction key must not be empty",
417			));
418		}
419		if timeout.is_zero() {
420			return Err(transaction_invalid_argument_error(
421				"transaction timeout must be greater than zero",
422			));
423		}
424
425		let db = self.clone();
426		let task = run_detached_transaction_task(
427			async move {
428				db.begin_transaction_profiled_inner(
429					key,
430					timeout,
431					name,
432					#[cfg(feature = "sqlite-local")]
433					started_at,
434				)
435				.await
436			},
437			"sqlite transaction begin task failed",
438		);
439		self.traced(SqliteOperation::TransactionBegin, task).await
440	}
441
442	
443
444	async fn begin_transaction_profiled_inner(
445		&self,
446		key: String,
447		timeout: Duration,
448		_name: Option<String>,
449		#[cfg(feature = "sqlite-local")] started_at: Option<crate::time::Instant>,
450	) -> Result<SqliteTransaction> {
451		#[cfg(feature = "sqlite-local")]
452		let transaction_wait_started_at = started_at.map(|_| crate::time::Instant::now());
453		let epoch = self.transaction_coordinator.epoch.load(Ordering::Acquire);
454		let permit = self.try_transaction_admission()?;
455		let wait = CoordinatorWaitGuard::new(self);
456		let gate_guard = self
457			.acquire_gate_outside_manual_transaction(timeout)
458			.await?;
459		drop(wait);
460		#[cfg(feature = "sqlite-local")]
461		let transaction_wait = transaction_wait_started_at.map(|started| started.elapsed());
462		{
463			let state = self.transaction_coordinator.state.lock().await;
464			if state.closed {
465				return Err(transaction_coordinator_closed_error());
466			}
467			if self.transaction_coordinator.epoch.load(Ordering::Acquire) != epoch {
468				return Err(transaction_expired_error(
469					state
470						.last_expired_timeout
471						.unwrap_or(DEFAULT_TRANSACTION_TIMEOUT),
472				));
473			}
474			if let Some(error) = transaction_known_state_error(&state, &key) {
475				return Err(error);
476			}
477		}
478
479		#[cfg(feature = "sqlite-local")]
480		let begin_started_at = started_at.map(|_| crate::time::Instant::now());
481		#[cfg(feature = "sqlite-local")]
482		let (begin_result, begin_profile) = if begin_started_at.is_some() {
483			let profiled = self
484				.execute_backend_profiled("BEGIN".to_owned(), None)
485				.await;
486			(
487				profiled.result.map(|result| (result, None)),
488				profiled.profile,
489			)
490		} else {
491			(
492				self.execute_backend_in_session("BEGIN".to_owned(), None, None)
493					.await,
494				None,
495			)
496		};
497		#[cfg(feature = "sqlite-local")]
498		let begin_duration = begin_started_at.map(|started| started.elapsed());
499		#[cfg(not(feature = "sqlite-local"))]
500		let begin_result = self
501			.execute_backend_in_session("BEGIN".to_owned(), None, None)
502			.await;
503		let (_, remote_session) = begin_result.map_err(map_transaction_connection_error)?;
504		// A successful BEGIN response and the coordinator state update are two
505		// separate async events. If the socket disconnected in that narrow gap,
506		// pegboard-envoy has already dropped the connection-owned database handle
507		// and rolled the transaction back. Never publish a handle for that stale
508		// transaction.
509		if let Some(session) = remote_session
510			&& self.handle()?.connection_session() != Some(session)
511		{
512			return Err(transaction_connection_lost_error());
513		}
514		let operation = Arc::new(AsyncMutex::new(()));
515		{
516			let mut state = self.transaction_coordinator.state.lock().await;
517			if state.closed {
518				drop(state);
519				if let Err(error) = self
520					.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
521					.await
522				{
523					tracing::error!(%error, "sqlite rollback after begin raced shutdown failed");
524				}
525				return Err(transaction_coordinator_closed_error());
526			}
527			state.active = Some(ActiveTransaction {
528				key: key.clone(),
529				timeout,
530				expiring: false,
531				connection_lost: false,
532				remote_session,
533				gate_guard: Some(gate_guard),
534				operation,
535				timeout_task: None,
536				#[cfg(feature = "sqlite-local")]
537				profile: started_at.map(|started_at| {
538					let mut profile = TransactionProfile::new(
539						_name,
540						started_at,
541						self.profiling.config.max_statements_per_transaction_trace,
542						self.profiling.selection.tracks_transactions(),
543					);
544					profile.transaction_wait_ns = transaction_wait
545						.unwrap_or_default()
546						.as_nanos()
547						.try_into()
548						.unwrap_or(u64::MAX);
549					let default_profile = sqlite_embedded::vfs::SqliteOperationProfile::default();
550					profile.record_control(
551						begin_profile.as_ref().unwrap_or(&default_profile),
552						begin_duration
553							.unwrap_or_default()
554							.as_nanos()
555							.try_into()
556							.unwrap_or(u64::MAX),
557						false,
558					);
559					profile
560				}),
561			});
562		}
563		drop(permit);
564
565		let timeout_task = CancellationToken::new();
566		let mut state = self.transaction_coordinator.state.lock().await;
567		if let Some(active) = state.active.as_mut().filter(|active| active.key == key) {
568			active.timeout_task = Some(timeout_task.clone());
569		} else {
570			timeout_task.cancel();
571		}
572		drop(state);
573		spawn_transaction_timeout(
574			self.clone(),
575			key.clone(),
576			timeout,
577			timeout_task,
578			remote_session,
579		);
580
581		// A staged schema rides the first transaction, ahead of the caller's statements.
582		if self.has_deferred_bootstrap() {
583			Box::pin(self.apply_deferred_bootstrap(&key)).await?;
584		}
585
586		Ok(SqliteTransaction {
587			db: self.clone(),
588			key,
589		})
590	}
591
592	/// Acquires the exclusive gate once the connection is in autocommit mode.
593	///
594	/// A manual transaction (`BEGIN` sent through ordinary statements) leaves
595	/// the connection outside autocommit without owning the gate. Starting a
596	/// coordinated transaction then would fail with "cannot start a transaction
597	/// within a transaction", or would fold coordinated writes into the manual
598	/// transaction so its `ROLLBACK` discarded them. The gate is released while
599	/// waiting because the manual transaction finishes through ordinary
600	/// statements that need the shared gate. The state is checked while holding
601	/// the gate, when no ordinary statement can change it.
602	///
603	/// The wait is bounded by the transaction `timeout` and ends early when the
604	/// coordinator closes. Backends that cannot report the connection state
605	/// never wait.
606	async fn acquire_gate_outside_manual_transaction(
607		&self,
608		timeout: Duration,
609	) -> Result<OwnedRwLockWriteGuard<()>> {
610		let deadline = crate::time::Instant::now() + timeout;
611		let mut wait_guard = None;
612		loop {
613			let gate_guard = Arc::clone(&self.transaction_coordinator.gate)
614				.write_owned()
615				.await;
616			let Some(mut explicit_transaction) = self.subscribe_explicit_transaction() else {
617				return Ok(gate_guard);
618			};
619			if !*explicit_transaction.borrow_and_update() {
620				return Ok(gate_guard);
621			}
622			drop(gate_guard);
623
624			if wait_guard.is_none() {
625				wait_guard = Some(ManualTransactionWaitGuard::new(self, timeout));
626			}
627			tokio::select! {
628				_ = explicit_transaction.wait_for(|open| !*open) => {}
629				_ = self.transaction_coordinator.closed_token.cancelled() => {
630					return Err(transaction_coordinator_closed_error());
631				}
632				_ = crate::time::sleep_until(deadline) => {
633					MANUAL_TRANSACTION_WAIT_METRICS.timeouts_total.inc();
634					return Err(manual_transaction_open_error(timeout));
635				}
636			}
637		}
638	}
639
640	async fn transaction_operation(&self, key: &str) -> Result<(Arc<AsyncMutex<()>>, Option<u64>)> {
641		let state = self.transaction_coordinator.state.lock().await;
642		if state.closed {
643			return Err(transaction_coordinator_closed_error());
644		}
645		if let Some(active) = state.active.as_ref().filter(|active| active.key == key) {
646			if active.expiring {
647				if active.connection_lost {
648					return Err(transaction_connection_lost_error());
649				}
650				return Err(transaction_expired_owner_error(key, active.timeout));
651			}
652			return Ok((Arc::clone(&active.operation), active.remote_session));
653		}
654		if let Some(error) = transaction_known_state_error(&state, key) {
655			return Err(error);
656		}
657		Err(transaction_unknown_error(key))
658	}
659
660	async fn ensure_active_transaction(&self, key: &str) -> Result<()> {
661		let state = self.transaction_coordinator.state.lock().await;
662		if state.closed {
663			return Err(transaction_coordinator_closed_error());
664		}
665		if let Some(active) = state.active.as_ref().filter(|active| active.key == key) {
666			if active.expiring {
667				if active.connection_lost {
668					return Err(transaction_connection_lost_error());
669				}
670				return Err(transaction_expired_owner_error(key, active.timeout));
671			}
672			return Ok(());
673		}
674		if let Some(error) = transaction_known_state_error(&state, key) {
675			return Err(error);
676		}
677		Err(transaction_unknown_error(key))
678	}
679
680	async fn transaction_exec(&self, key: &str, sql: String) -> Result<QueryResult> {
681		let db = self.clone();
682		let key = key.to_owned();
683		let task = run_detached_transaction_task(
684			async move { db.transaction_exec_inner(&key, sql).await },
685			"sqlite transaction exec task failed",
686		);
687		self.traced(SqliteOperation::TransactionExec, task).await
688	}
689
690	async fn transaction_exec_inner(&self, key: &str, sql: String) -> Result<QueryResult> {
691		#[cfg(feature = "sqlite-local")]
692		let started_at =
693			(self.backend() == super::SqliteBackend::LocalNative).then(crate::time::Instant::now);
694		let (operation, remote_session) = self.transaction_operation(key).await?;
695		let _operation = operation.lock().await;
696		#[cfg(feature = "sqlite-local")]
697		let transaction_wait = started_at.map(|started| started.elapsed());
698		self.ensure_active_transaction(key).await?;
699		#[cfg(feature = "sqlite-local")]
700		if let Some(started_at) = started_at {
701			let sql_for_profile = sql.clone();
702			let profiled = self.exec_backend_profiled(sql).await;
703			let result = profiled.result;
704			let profile = profiled.profile;
705			if let Some(observation) = self.observe_statement_profile(
706				&sql_for_profile,
707				started_at,
708				transaction_wait.unwrap_or_default(),
709				profile,
710				if result.is_ok() { "success" } else { "error" },
711				"explicit",
712			) {
713				self.record_transaction_statement(key, &observation).await;
714			}
715			return match result {
716				Ok(result) => Ok(result),
717				Err(error) => Err(self.handle_transaction_backend_error(key, error).await),
718			};
719		}
720		match self.exec_backend_in_session(sql, remote_session).await {
721			Ok((result, _)) => Ok(result),
722			Err(error) => Err(self.handle_transaction_backend_error(key, error).await),
723		}
724	}
725
726	async fn transaction_execute(
727		&self,
728		key: &str,
729		sql: String,
730		params: Option<Vec<BindParam>>,
731	) -> Result<ExecuteResult> {
732		let db = self.clone();
733		let key = key.to_owned();
734		let task = run_detached_transaction_task(
735			async move { db.transaction_execute_inner(&key, sql, params).await },
736			"sqlite transaction execute task failed",
737		);
738		self.traced(SqliteOperation::TransactionExecute, task).await
739	}
740
741	pub(super) async fn transaction_execute_inner(
742		&self,
743		key: &str,
744		sql: String,
745		params: Option<Vec<BindParam>>,
746	) -> Result<ExecuteResult> {
747		#[cfg(feature = "sqlite-local")]
748		let started_at =
749			(self.backend() == super::SqliteBackend::LocalNative).then(crate::time::Instant::now);
750		let (operation, remote_session) = self.transaction_operation(key).await?;
751		let _operation = operation.lock().await;
752		#[cfg(feature = "sqlite-local")]
753		let transaction_wait = started_at.map(|started| started.elapsed());
754		self.ensure_active_transaction(key).await?;
755		#[cfg(feature = "sqlite-local")]
756		if let Some(started_at) = started_at {
757			let sql_for_profile = sql.clone();
758			let profiled = self.execute_backend_profiled(sql, params).await;
759			let result = profiled.result;
760			let profile = profiled.profile;
761			if let Some(observation) = self.observe_statement_profile(
762				&sql_for_profile,
763				started_at,
764				transaction_wait.unwrap_or_default(),
765				profile,
766				if result.is_ok() { "success" } else { "error" },
767				"explicit",
768			) {
769				self.record_transaction_statement(key, &observation).await;
770			}
771			return match result {
772				Ok(result) => Ok(result),
773				Err(error) => Err(self.handle_transaction_backend_error(key, error).await),
774			};
775		}
776		match self
777			.execute_backend_in_session(sql, params, remote_session)
778			.await
779		{
780			Ok((result, _)) => Ok(result),
781			Err(error) => Err(self.handle_transaction_backend_error(key, error).await),
782		}
783	}
784
785	/// Runs framework-owned `statements` inside the transaction `key`. A local database runs
786	/// them as one worker command, so a busy pool delays the batch once instead of once per
787	/// statement. The remote backend sends them one at a time inside the transaction.
788	pub(super) async fn transaction_execute_statements_inner(
789		&self,
790		key: &str,
791		statements: Vec<super::SqliteBatchStatement>,
792	) -> Result<()> {
793		if self.backend() != super::SqliteBackend::LocalNative {
794			for statement in statements {
795				self.transaction_execute_inner(key, statement.sql, statement.params)
796					.await?;
797			}
798			return Ok(());
799		}
800
801		let (operation, _) = self.transaction_operation(key).await?;
802		let _operation = operation.lock().await;
803		self.ensure_active_transaction(key).await?;
804		match self.local_execute_batch(statements).await {
805			Ok(()) => Ok(()),
806			Err(error) => Err(self.handle_transaction_backend_error(key, error).await),
807		}
808	}
809
810	#[cfg(feature = "sqlite-local")]
811	async fn record_transaction_statement(
812		&self,
813		key: &str,
814		observation: &super::profiling::StatementObservation,
815	) {
816		let mut state = self.transaction_coordinator.state.lock().await;
817		if let Some(active) = state.active.as_mut().filter(|active| active.key == key) {
818			if let Some(profile) = &mut active.profile {
819				profile.record_statement(observation);
820			}
821		}
822	}
823
824	async fn finish_transaction(&self, key: &str, commit: bool) -> Result<()> {
825		let db = self.clone();
826		let key = key.to_owned();
827		let operation = if commit {
828			SqliteOperation::TransactionCommit
829		} else {
830			SqliteOperation::TransactionRollback
831		};
832		let task = run_detached_transaction_task(
833			async move { db.finish_transaction_inner(&key, commit).await },
834			"sqlite transaction finish task failed",
835		);
836		self.traced(operation, task).await
837	}
838
839	pub(super) async fn finish_transaction_inner(&self, key: &str, commit: bool) -> Result<()> {
840		let (operation, remote_session) = self.transaction_operation(key).await?;
841		let _operation = operation.lock().await;
842		self.ensure_active_transaction(key).await?;
843
844		let statement = if commit { "COMMIT" } else { "ROLLBACK" };
845		#[cfg(feature = "sqlite-local")]
846		let control_started_at =
847			(self.backend() == super::SqliteBackend::LocalNative).then(crate::time::Instant::now);
848		#[cfg(feature = "sqlite-local")]
849		let (result, control_profile) = if control_started_at.is_some() {
850			let profiled = self
851				.execute_backend_profiled(statement.to_owned(), None)
852				.await;
853			(
854				profiled.result.map(|result| (result, None)),
855				profiled.profile,
856			)
857		} else {
858			(
859				self.execute_backend_in_session(statement.to_owned(), None, remote_session)
860					.await,
861				None,
862			)
863		};
864		#[cfg(not(feature = "sqlite-local"))]
865		let result = self
866			.execute_backend_in_session(statement.to_owned(), None, remote_session)
867			.await;
868		#[cfg(feature = "sqlite-local")]
869		if let Some(control_started_at) = control_started_at {
870			self.record_transaction_control(
871				key,
872				control_profile.as_ref(),
873				control_started_at.elapsed(),
874				commit,
875			)
876			.await;
877		}
878		if let Err(primary) = result {
879			if is_remote_connection_error(&primary) {
880				self.release_transaction(key, TransactionTerminalState::ConnectionLost, false)
881					.await;
882				return Err(self.attach_actor(map_transaction_connection_error(primary)));
883			}
884			if commit {
885				let rollback_result = self
886					.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
887					.await;
888				let rollback_failed = rollback_result
889					.as_ref()
890					.is_err_and(|error| !is_no_active_transaction_error(error));
891				self.release_transaction(
892					key,
893					TransactionTerminalState::RolledBack,
894					rollback_failed,
895				)
896				.await;
897				if let Err(rollback) = rollback_result
898					&& rollback_failed
899				{
900					tracing::error!(%rollback, "sqlite rollback after failed commit failed");
901				}
902				return Err(self.attach_actor(primary));
903			}
904			if is_no_active_transaction_error(&primary) {
905				self.release_transaction(key, TransactionTerminalState::RolledBack, false)
906					.await;
907				return Ok(());
908			}
909
910			self.release_transaction(key, TransactionTerminalState::RolledBack, true)
911				.await;
912			return Err(self.attach_actor(primary));
913		}
914
915		if commit {
916			self.deferred_bootstrap_committed(key);
917		}
918		self.release_transaction(
919			key,
920			if commit {
921				TransactionTerminalState::Committed
922			} else {
923				TransactionTerminalState::RolledBack
924			},
925			false,
926		)
927		.await;
928		Ok(())
929	}
930
931	#[cfg(feature = "sqlite-local")]
932	async fn record_transaction_control(
933		&self,
934		key: &str,
935		profile: Option<&sqlite_embedded::vfs::SqliteOperationProfile>,
936		duration: Duration,
937		is_commit: bool,
938	) {
939		let mut state = self.transaction_coordinator.state.lock().await;
940		if let Some(active) = state.active.as_mut().filter(|active| active.key == key) {
941			if let Some(transaction_profile) = &mut active.profile {
942				let default_profile = sqlite_embedded::vfs::SqliteOperationProfile::default();
943				transaction_profile.record_control(
944					profile.unwrap_or(&default_profile),
945					duration.as_nanos().try_into().unwrap_or(u64::MAX),
946					is_commit,
947				);
948			}
949		}
950	}
951
952	async fn expire_transaction(&self, key: &str) -> Result<()> {
953		let db = self.clone();
954		let key = key.to_owned();
955		run_detached_transaction_task(
956			async move { db.expire_transaction_inner(&key).await },
957			"sqlite transaction expiry task failed",
958		)
959		.await
960	}
961
962	async fn expire_transaction_inner(&self, key: &str) -> Result<()> {
963		let (operation, timeout, remote_session) = {
964			let mut state = self.transaction_coordinator.state.lock().await;
965			if state.closed {
966				return Err(transaction_coordinator_closed_error());
967			}
968			if let Some(active) = state.active.as_mut().filter(|active| active.key == key) {
969				active.expiring = true;
970				(
971					Arc::clone(&active.operation),
972					active.timeout,
973					active.remote_session,
974				)
975			} else if let Some(error) = transaction_known_state_error(&state, key) {
976				return Err(error);
977			} else {
978				return Err(transaction_unknown_error(key));
979			}
980		};
981		let _operation = operation.lock().await;
982		{
983			let state = self.transaction_coordinator.state.lock().await;
984			if state.closed {
985				return Err(transaction_coordinator_closed_error());
986			}
987			if !state
988				.active
989				.as_ref()
990				.is_some_and(|active| active.key == key)
991			{
992				if let Some(error) = transaction_known_state_error(&state, key) {
993					return Err(error);
994				}
995				return Err(transaction_unknown_error(key));
996			}
997		}
998		// The deadline only wins after any operation that already owned the
999		// transaction settles. A commit or rollback that acquired the operation
1000		// lock first is allowed to finish without poisoning parked work.
1001		self.transaction_coordinator
1002			.epoch
1003			.fetch_add(1, Ordering::AcqRel);
1004		let rollback = self
1005			.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
1006			.await;
1007		let rollback_failed = rollback.as_ref().is_err_and(|error| {
1008			!is_no_active_transaction_error(error) && !is_remote_connection_error(error)
1009		});
1010		// Advance again before releasing the exclusive gate. Calls submitted both
1011		// before and during expiry cleanup must observe a changed epoch and fail;
1012		// only calls submitted after cleanup may proceed.
1013		self.transaction_coordinator
1014			.epoch
1015			.fetch_add(1, Ordering::AcqRel);
1016		self.release_transaction(
1017			key,
1018			TransactionTerminalState::Expired(timeout),
1019			rollback_failed,
1020		)
1021		.await;
1022		if let Err(error) = rollback
1023			&& rollback_failed
1024		{
1025			return Err(self.attach_actor(error));
1026		}
1027		Ok(())
1028	}
1029
1030	async fn connection_lost_transaction(&self, key: &str, expected_session: u64) -> Result<()> {
1031		let (operation, still_expected) = {
1032			let mut state = self.transaction_coordinator.state.lock().await;
1033			let Some(active) = state.active.as_mut().filter(|active| active.key == key) else {
1034				return Ok(());
1035			};
1036			let still_expected = active.remote_session == Some(expected_session);
1037			if still_expected {
1038				active.expiring = true;
1039				active.connection_lost = true;
1040			}
1041			(Arc::clone(&active.operation), still_expected)
1042		};
1043		if !still_expected {
1044			return Ok(());
1045		}
1046		let _operation = operation.lock().await;
1047
1048		// The remote database handle is scoped to the server-side WebSocket
1049		// connection. Disconnect closes it and SQLite rolls back its open
1050		// transaction. Sending ROLLBACK on a replacement connection would target a
1051		// different database session, so cleanup here is intentionally local: make
1052		// the handle terminal and release actor/socket work queued behind it.
1053		self.release_transaction(key, TransactionTerminalState::ConnectionLost, false)
1054			.await;
1055		Ok(())
1056	}
1057
1058	async fn handle_transaction_backend_error(
1059		&self,
1060		key: &str,
1061		error: anyhow::Error,
1062	) -> anyhow::Error {
1063		if is_remote_connection_error(&error) {
1064			self.release_transaction(key, TransactionTerminalState::ConnectionLost, false)
1065				.await;
1066		}
1067		self.attach_actor(map_transaction_connection_error(error))
1068	}
1069
1070	pub(super) async fn release_transaction(
1071		&self,
1072		key: &str,
1073		terminal: TransactionTerminalState,
1074		close: bool,
1075	) {
1076		let mut state = self.transaction_coordinator.state.lock().await;
1077		if !state
1078			.active
1079			.as_ref()
1080			.is_some_and(|active| active.key == key)
1081		{
1082			return;
1083		}
1084		let mut active = state.active.take().expect("active transaction was checked");
1085		if let Some(task) = active.timeout_task.take() {
1086			task.cancel();
1087		}
1088		if let TransactionTerminalState::Expired(timeout) = terminal {
1089			state.last_expired_timeout = Some(timeout);
1090		}
1091		insert_terminal_state(&mut state, key.to_owned(), terminal);
1092		state.closed |= close;
1093		if close {
1094			self.transaction_coordinator.admission.close();
1095			self.transaction_coordinator.closed_token.cancel();
1096			if let Ok(config) = self.runtime_config() {
1097				report_sqlite_worker_fatal(
1098					&self.worker_fatal_reported,
1099					config,
1100					"sqlite transaction rollback failed; coordinator closed".to_owned(),
1101				);
1102			}
1103		}
1104		drop(active.gate_guard.take());
1105		#[cfg(feature = "sqlite-local")]
1106		let profile = active.profile;
1107		drop(state);
1108
1109		#[cfg(feature = "sqlite-local")]
1110		if self.backend() == super::SqliteBackend::LocalNative
1111			&& let Some(profile) = profile
1112		{
1113			let (fingerprint, fingerprint_source) = profile.fingerprint();
1114			if self.profiling.selection.tracks_transactions() && profile.name.is_none() {
1115				let warning = UNNAMED_TRANSACTION_WARNINGS.fetch_add(1, Ordering::Relaxed);
1116				if warning == 0 || warning.is_power_of_two() {
1117					tracing::warn!(
1118						fingerprint,
1119						"SQLite transaction is unnamed. Add a static `name` to group transaction, storage, and commit performance across executions. Individual statement profiling remains available."
1120					);
1121				}
1122			}
1123			if let Some(metrics) = &self.vfs_metrics {
1124				let total_ns = profile
1125					.started_at
1126					.elapsed()
1127					.as_nanos()
1128					.try_into()
1129					.unwrap_or(u64::MAX);
1130				let application_time_ns = total_ns
1131					.saturating_sub(profile.transaction_wait_ns)
1132					.saturating_sub(profile.worker_wait_ns)
1133					.saturating_sub(profile.storage_ns)
1134					.saturating_sub(profile.local_work_ns);
1135				let metric = sqlite_embedded::vfs::SqliteTransactionMetric {
1136					fingerprint,
1137					fingerprint_source,
1138					shape_fingerprint: profile.shape_fingerprint(),
1139					statement_fingerprint_hashes: profile.statement_fingerprint_hashes,
1140					omitted_statement_fingerprints: profile.omitted_statement_fingerprints,
1141					storage_transport: "proxy",
1142					outcome: match terminal {
1143						TransactionTerminalState::Committed => "success",
1144						TransactionTerminalState::RolledBack => "rollback",
1145						TransactionTerminalState::Expired(_) => "expired",
1146						TransactionTerminalState::ConnectionLost => "connection_lost",
1147					},
1148					total_ns,
1149					transaction_wait_ns: profile.transaction_wait_ns,
1150					worker_wait_ns: profile.worker_wait_ns,
1151					storage_ns: profile.storage_ns,
1152					local_work_ns: profile.local_work_ns,
1153					application_time_ns,
1154					commit_ns: profile.commit_ns,
1155					get_pages_round_trips: profile.get_pages_round_trips,
1156					statement_count: profile.statement_count,
1157					dirty_pages: profile.dirty_pages,
1158					dirty_bytes: profile.dirty_bytes,
1159				};
1160				if metrics.observe_transaction_profile(&metric)
1161					&& self.profiling.mark_cataloged(&metric.fingerprint)
1162				{
1163					metrics.record_fingerprint_catalog(
1164						"transaction",
1165						&metric.fingerprint,
1166						profile.name.as_deref().unwrap_or(&metric.fingerprint),
1167						FINGERPRINT_FORMAT_VERSION,
1168					);
1169				}
1170				if metrics.transaction_diagnostic_selected(&metric) {
1171					let mut diagnostic = metric;
1172					(diagnostic.fingerprint, diagnostic.fingerprint_source) =
1173						profile.diagnostic_fingerprint();
1174					if self.profiling.mark_cataloged(&diagnostic.fingerprint) {
1175						metrics.record_fingerprint_catalog(
1176							"transaction",
1177							&diagnostic.fingerprint,
1178							profile.name.as_deref().unwrap_or(&diagnostic.fingerprint),
1179							FINGERPRINT_FORMAT_VERSION,
1180						);
1181					}
1182					metrics.emit_transaction_diagnostic_event(
1183						self.actor_id.as_deref().unwrap_or("unknown"),
1184						self.generation,
1185						&diagnostic,
1186					);
1187				}
1188			}
1189		}
1190	}
1191
1192	pub(super) async fn shutdown_transaction_coordinator(&self) -> OwnedRwLockWriteGuard<()> {
1193		let active_operation = {
1194			let mut state = self.transaction_coordinator.state.lock().await;
1195			state.closed = true;
1196			self.transaction_coordinator.closed_token.cancel();
1197			state.active.as_ref().map(|active| {
1198				(
1199					active.key.clone(),
1200					Arc::clone(&active.operation),
1201					active.remote_session,
1202				)
1203			})
1204		};
1205		self.transaction_coordinator.admission.close();
1206		if let Some((key, operation, remote_session)) = active_operation {
1207			let _operation = operation.lock().await;
1208			let still_active = {
1209				let state = self.transaction_coordinator.state.lock().await;
1210				state
1211					.active
1212					.as_ref()
1213					.is_some_and(|active| active.key == key)
1214			};
1215			if still_active {
1216				let rollback = self
1217					.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
1218					.await;
1219				let rollback_failed = rollback.as_ref().is_err_and(|error| {
1220					!is_no_active_transaction_error(error) && !is_remote_connection_error(error)
1221				});
1222				if let Err(error) = &rollback
1223					&& rollback_failed
1224				{
1225					tracing::error!(%error, "sqlite rollback during coordinator shutdown failed");
1226				}
1227				self.transaction_coordinator
1228					.epoch
1229					.fetch_add(1, Ordering::AcqRel);
1230				self.release_transaction(
1231					&key,
1232					TransactionTerminalState::RolledBack,
1233					rollback_failed,
1234				)
1235				.await;
1236			}
1237		}
1238		Arc::clone(&self.transaction_coordinator.gate)
1239			.write_owned()
1240			.await
1241	}
1242
1243	/// Rolls back a manual transaction left open at shutdown so the final state
1244	/// save can commit on its own. Returns whether a manual transaction was
1245	/// rolled back.
1246	///
1247	/// Holding the exclusive gate keeps ordinary statements from running while
1248	/// the connection state is checked and rolled back. A coordinated
1249	/// transaction cannot be active while this holds the gate.
1250	pub(crate) async fn rollback_manual_transaction(&self) -> Result<bool> {
1251		let Some(explicit_transaction) = self.subscribe_explicit_transaction() else {
1252			return Ok(false);
1253		};
1254		if !*explicit_transaction.borrow() {
1255			return Ok(false);
1256		}
1257		let _gate = Arc::clone(&self.transaction_coordinator.gate)
1258			.write_owned()
1259			.await;
1260		if !*explicit_transaction.borrow() {
1261			return Ok(false);
1262		}
1263
1264		tracing::warn!(
1265			actor_id = self.actor_id.as_deref().unwrap_or("unknown"),
1266			"rolling back manual sqlite transaction left open at shutdown; the action that issued BEGIN did not reach COMMIT or ROLLBACK within the grace period"
1267		);
1268		match self
1269			.execute_backend_in_session("ROLLBACK".to_owned(), None, None)
1270			.await
1271		{
1272			Ok(_) => Ok(true),
1273			Err(error) if is_no_active_transaction_error(&error) => Ok(false),
1274			Err(error) => Err(self
1275				.attach_actor(error)
1276				.context("roll back manual sqlite transaction at shutdown")),
1277		}
1278	}
1279}
1280
1281#[derive(rivet_error::RivetError, Debug, Serialize)]
1282#[error(
1283	"sqlite",
1284	"transaction_queue_full",
1285	"SQLite transaction queue is full.",
1286	"SQLite transaction coordinator queue is full. Limit is 128 operations."
1287)]
1288pub struct TransactionQueueFullError;
1289
1290impl fmt::Display for TransactionQueueFullError {
1291	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1292		f.write_str("sqlite transaction coordinator queue is full")
1293	}
1294}
1295
1296impl Error for TransactionQueueFullError {}
1297
1298#[derive(rivet_error::RivetError, Debug, Serialize)]
1299#[error(
1300	"sqlite",
1301	"manual_transaction_open",
1302	"SQLite manual transaction stayed open.",
1303	"Waited {timeout_ms} ms for a manual SQLite transaction to finish. A BEGIN issued through ordinary statements must reach COMMIT or ROLLBACK before actor state persistence or db.transaction() can start; prefer db.transaction() for multi-statement transactions."
1304)]
1305pub struct ManualTransactionOpenError {
1306	pub timeout_ms: u64,
1307}
1308
1309impl fmt::Display for ManualTransactionOpenError {
1310	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1311		write!(
1312			f,
1313			"waited {} ms for a manual sqlite transaction to finish; a BEGIN issued through ordinary statements must reach COMMIT or ROLLBACK before actor state persistence or db.transaction() can start",
1314			self.timeout_ms
1315		)
1316	}
1317}
1318
1319impl Error for ManualTransactionOpenError {}
1320
1321#[derive(rivet_error::RivetError, Debug, Serialize)]
1322#[error(
1323	"sqlite",
1324	"transaction_closed",
1325	"SQLite transaction coordinator is closed."
1326)]
1327pub struct TransactionCoordinatorClosedError;
1328
1329impl fmt::Display for TransactionCoordinatorClosedError {
1330	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1331		f.write_str("sqlite transaction coordinator is closed")
1332	}
1333}
1334
1335impl Error for TransactionCoordinatorClosedError {}
1336
1337#[derive(rivet_error::RivetError, Debug, Serialize)]
1338#[error(
1339	"sqlite",
1340	"transaction_invalid_argument",
1341	"Invalid SQLite transaction argument.",
1342	"{message}"
1343)]
1344pub struct TransactionInvalidArgumentError {
1345	pub message: &'static str,
1346}
1347
1348impl fmt::Display for TransactionInvalidArgumentError {
1349	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1350		f.write_str(self.message)
1351	}
1352}
1353
1354impl Error for TransactionInvalidArgumentError {}
1355
1356#[derive(rivet_error::RivetError, Debug, Serialize)]
1357#[error(
1358	"sqlite",
1359	"transaction_connection_lost",
1360	"SQLite transaction connection was lost.",
1361	"The Envoy connection that owned this SQLite transaction disconnected. The transaction was rolled back and cannot be resumed; start a new db.transaction()."
1362)]
1363pub struct TransactionConnectionLostError;
1364
1365impl fmt::Display for TransactionConnectionLostError {
1366	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1367		f.write_str(
1368			"the Envoy connection that owned this SQLite transaction disconnected; the transaction was rolled back and cannot be resumed",
1369		)
1370	}
1371}
1372
1373impl Error for TransactionConnectionLostError {}
1374
1375#[derive(rivet_error::RivetError, Debug, Serialize)]
1376#[error(
1377	"sqlite",
1378	"transaction_unknown",
1379	"Unknown SQLite transaction handle.",
1380	"Unknown SQLite transaction handle `{key}`."
1381)]
1382pub struct TransactionUnknownError {
1383	pub key: String,
1384}
1385
1386impl fmt::Display for TransactionUnknownError {
1387	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1388		write!(f, "unknown sqlite transaction handle `{}`", self.key)
1389	}
1390}
1391
1392impl Error for TransactionUnknownError {}
1393
1394#[derive(rivet_error::RivetError, Debug, Serialize)]
1395#[error(
1396	"sqlite",
1397	"transaction_terminal",
1398	"SQLite transaction handle is terminal.",
1399	"SQLite transaction handle `{key}` is already {state}."
1400)]
1401pub struct TransactionTerminalError {
1402	pub key: String,
1403	pub state: &'static str,
1404}
1405
1406impl fmt::Display for TransactionTerminalError {
1407	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1408		write!(
1409			f,
1410			"sqlite transaction handle `{}` is already {}",
1411			self.key, self.state
1412		)
1413	}
1414}
1415
1416impl Error for TransactionTerminalError {}
1417
1418#[derive(rivet_error::RivetError, Debug, Serialize)]
1419#[error(
1420	"sqlite",
1421	"transaction_expired",
1422	"SQLite transaction expired.",
1423	"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."
1424)]
1425pub struct TransactionExpiredError {
1426	pub timeout_ms: u64,
1427}
1428
1429impl fmt::Display for TransactionExpiredError {
1430	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1431		write!(
1432			f,
1433			"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",
1434			self.timeout_ms
1435		)
1436	}
1437}
1438
1439impl Error for TransactionExpiredError {}
1440
1441impl TransactionExpiredError {
1442	fn owner(_key: &str, timeout: Duration) -> Self {
1443		Self {
1444			timeout_ms: duration_millis(timeout),
1445		}
1446	}
1447
1448	fn parked(timeout: Duration) -> Self {
1449		Self {
1450			timeout_ms: duration_millis(timeout),
1451		}
1452	}
1453}
1454
1455fn duration_millis(duration: Duration) -> u64 {
1456	duration.as_millis().try_into().unwrap_or(u64::MAX)
1457}
1458
1459async fn sleep_transaction_timeout(mut timeout: Duration) {
1460	// Browser timers commonly cap one wait at signed 32-bit milliseconds.
1461	// Chunking preserves the caller's unbounded product-level timeout on wasm.
1462	const MAX_TIMER_WAIT: Duration = Duration::from_millis(2_000_000_000);
1463	while timeout > MAX_TIMER_WAIT {
1464		crate::time::sleep(MAX_TIMER_WAIT).await;
1465		timeout = timeout.saturating_sub(MAX_TIMER_WAIT);
1466	}
1467	crate::time::sleep(timeout).await;
1468}
1469
1470fn spawn_transaction_timeout(
1471	db: SqliteDb,
1472	key: String,
1473	timeout: Duration,
1474	cancel: CancellationToken,
1475	remote_session: Option<u64>,
1476) {
1477	let task = async move {
1478		let connection_lost = async {
1479			let Some(expected) = remote_session else {
1480				std::future::pending::<()>().await;
1481				return;
1482			};
1483			let Ok(handle) = db.handle() else {
1484				return;
1485			};
1486			let mut sessions = handle.subscribe_connection_session();
1487			loop {
1488				if *sessions.borrow_and_update() != expected {
1489					return;
1490				}
1491				if sessions.changed().await.is_err() {
1492					return;
1493				}
1494			}
1495		};
1496		tokio::pin!(connection_lost);
1497
1498		let expired = tokio::select! {
1499			_ = cancel.cancelled() => return,
1500			_ = sleep_transaction_timeout(timeout) => true,
1501			_ = &mut connection_lost => false,
1502		};
1503		let result = if expired {
1504			db.expire_transaction(&key).await
1505		} else {
1506			// This covers all disconnect timing windows. During BEGIN there is no
1507			// active coordinator entry yet and the BEGIN caller receives its own
1508			// indeterminate/session error. Between statements this branch is the only
1509			// signal, and it prevents the next statement from autocommitting after a
1510			// fast reconnect. During a statement or COMMIT, request tracking preserves
1511			// the operation's indeterminate result while this terminalizes the handle.
1512			db.connection_lost_transaction(&key, remote_session.expect("checked above"))
1513				.await
1514		};
1515		if let Err(error) = result {
1516			if error.downcast_ref::<TransactionTerminalError>().is_none() {
1517				tracing::error!(%error, "sqlite transaction terminal cleanup failed");
1518			}
1519		}
1520	};
1521
1522	#[cfg(target_arch = "wasm32")]
1523	wasm_bindgen_futures::spawn_local(task);
1524
1525	#[cfg(not(target_arch = "wasm32"))]
1526	RuntimeSpawner::spawn(task);
1527}
1528
1529#[cfg(not(target_arch = "wasm32"))]
1530pub(super) async fn run_detached_transaction_task<F, T>(
1531	future: F,
1532	context: &'static str,
1533) -> Result<T>
1534where
1535	F: Future<Output = Result<T>> + Send + 'static,
1536	T: Send + 'static,
1537{
1538	RuntimeSpawner::spawn(future).await.context(context)?
1539}
1540
1541#[cfg(target_arch = "wasm32")]
1542pub(super) async fn run_detached_transaction_task<F, T>(
1543	future: F,
1544	context: &'static str,
1545) -> Result<T>
1546where
1547	F: Future<Output = Result<T>> + 'static,
1548	T: 'static,
1549{
1550	let (response_tx, response_rx) = oneshot::channel();
1551	wasm_bindgen_futures::spawn_local(async move {
1552		let _ = response_tx.send(future.await);
1553	});
1554	response_rx.await.context(context)?
1555}
1556
1557pub(super) fn insert_terminal_state(
1558	state: &mut TransactionCoordinatorState,
1559	key: String,
1560	terminal: TransactionTerminalState,
1561) {
1562	// Expired keys are bounded separately so a burst of committed transactions
1563	// cannot evict them and turn a stale handle's "expired" error into
1564	// "unknown transaction".
1565	if let TransactionTerminalState::Expired(timeout) = terminal {
1566		let expired = ExpiredKey {
1567			timeout,
1568			_metric: rivet_metrics::GaugeGuardExt::inc_guard(&EXPIRED_KEY_METRICS.keys),
1569		};
1570		if state.poisoned.insert(key.clone(), expired).is_none() {
1571			state.poisoned_order.push_back(key);
1572			if state.poisoned_order.len() == TRANSACTION_EXPIRED_CAPACITY + 1 {
1573				tracing::warn!(
1574					capacity = TRANSACTION_EXPIRED_CAPACITY,
1575					"expired sqlite transaction keys reached capacity; transactions keep timing out, and the oldest stale handles will report unknown transaction"
1576				);
1577			}
1578		}
1579		while state.poisoned_order.len() > TRANSACTION_EXPIRED_CAPACITY {
1580			let Some(evicted_key) = state.poisoned_order.pop_front() else {
1581				break;
1582			};
1583			if state.poisoned.remove(&evicted_key).is_some() {
1584				EXPIRED_KEY_METRICS.evicted_total.inc();
1585			}
1586		}
1587		return;
1588	}
1589	if state.terminal.insert(key.clone(), terminal).is_none() {
1590		state.terminal_order.push_back(key);
1591	}
1592	while state.terminal_order.len() > TRANSACTION_TERMINAL_CAPACITY {
1593		if let Some(expired_key) = state.terminal_order.pop_front() {
1594			state.terminal.remove(&expired_key);
1595		}
1596	}
1597}
1598
1599fn transaction_known_state_error(
1600	state: &TransactionCoordinatorState,
1601	key: &str,
1602) -> Option<anyhow::Error> {
1603	if let Some(timeout) = state.poisoned.get(key).map(|expired| expired.timeout) {
1604		return Some(transaction_expired_owner_error(key, timeout));
1605	}
1606	state
1607		.terminal
1608		.get(key)
1609		.copied()
1610		.map(|terminal| transaction_terminal_error(key, terminal))
1611}
1612
1613fn is_no_active_transaction_error(error: &anyhow::Error) -> bool {
1614	// SQLite does not expose a dedicated result code for "cannot commit/rollback
1615	// - no transaction is active"; both native SQLite and the remote executor
1616	// surface SQLITE_ERROR plus this stable SQLite-generated text. Keep the
1617	// classifier isolated here. In particular, ON CONFLICT ROLLBACK can end a
1618	// transaction before our cleanup call, and treating that cleanup response as
1619	// fatal would permanently close the coordinator. The cross-runtime actor-db
1620	// suite exercises that real automatic-rollback path on native and Wasm.
1621	error.chain().any(|cause| {
1622		cause
1623			.to_string()
1624			.to_ascii_lowercase()
1625			.contains("no transaction is active")
1626	})
1627}
1628
1629fn transaction_queue_full_error() -> anyhow::Error {
1630	TransactionQueueFullError
1631		.build()
1632		.context(TransactionQueueFullError)
1633}
1634
1635fn manual_transaction_open_error(timeout: Duration) -> anyhow::Error {
1636	let error = || ManualTransactionOpenError {
1637		timeout_ms: duration_millis(timeout),
1638	};
1639	error().build().context(error())
1640}
1641
1642fn transaction_coordinator_closed_error() -> anyhow::Error {
1643	TransactionCoordinatorClosedError
1644		.build()
1645		.context(TransactionCoordinatorClosedError)
1646}
1647
1648fn transaction_unknown_error(key: &str) -> anyhow::Error {
1649	TransactionUnknownError {
1650		key: key.to_owned(),
1651	}
1652	.build()
1653	.context(TransactionUnknownError {
1654		key: key.to_owned(),
1655	})
1656}
1657
1658fn transaction_expired_error(timeout: Duration) -> anyhow::Error {
1659	TransactionExpiredError::parked(timeout)
1660		.build()
1661		.context(TransactionExpiredError::parked(timeout))
1662}
1663
1664fn transaction_expired_owner_error(key: &str, timeout: Duration) -> anyhow::Error {
1665	TransactionExpiredError::owner(key, timeout)
1666		.build()
1667		.context(TransactionExpiredError::owner(key, timeout))
1668}
1669
1670fn transaction_terminal_error(key: &str, terminal: TransactionTerminalState) -> anyhow::Error {
1671	match terminal {
1672		TransactionTerminalState::Expired(timeout) => transaction_expired_owner_error(key, timeout),
1673		TransactionTerminalState::Committed => transaction_terminal_state_error(key, "committed"),
1674		TransactionTerminalState::RolledBack => {
1675			transaction_terminal_state_error(key, "rolled back")
1676		}
1677		TransactionTerminalState::ConnectionLost => transaction_connection_lost_error(),
1678	}
1679}
1680
1681fn transaction_invalid_argument_error(message: &'static str) -> anyhow::Error {
1682	TransactionInvalidArgumentError { message }
1683		.build()
1684		.context(TransactionInvalidArgumentError { message })
1685}
1686
1687fn transaction_connection_lost_error() -> anyhow::Error {
1688	TransactionConnectionLostError
1689		.build()
1690		.context(TransactionConnectionLostError)
1691}
1692
1693fn is_remote_connection_error(error: &anyhow::Error) -> bool {
1694	error
1695		.downcast_ref::<super::RemoteSqliteConnectionSessionLostError>()
1696		.is_some()
1697		|| error
1698			.downcast_ref::<super::RemoteSqliteIndeterminateResultError>()
1699			.is_some()
1700}
1701
1702fn map_transaction_connection_error(error: anyhow::Error) -> anyhow::Error {
1703	if error
1704		.downcast_ref::<super::RemoteSqliteConnectionSessionLostError>()
1705		.is_some()
1706	{
1707		return transaction_connection_lost_error();
1708	}
1709	super::remote_request_error(error)
1710}
1711
1712fn transaction_terminal_state_error(key: &str, state: &'static str) -> anyhow::Error {
1713	TransactionTerminalError {
1714		key: key.to_owned(),
1715		state,
1716	}
1717	.build()
1718	.context(TransactionTerminalError {
1719		key: key.to_owned(),
1720		state,
1721	})
1722}