Skip to main content

rivetkit_core/actor/sqlite/
tx.rs

1use std::{
2	collections::{BTreeMap, VecDeque},
3	error::Error,
4	fmt,
5	future::Future,
6	sync::{
7		Arc,
8		atomic::{AtomicU64, Ordering},
9	},
10	time::Duration,
11};
12
13use anyhow::{Context, Result};
14use serde::Serialize;
15#[cfg(target_arch = "wasm32")]
16use tokio::sync::oneshot;
17use tokio::sync::{
18	Mutex as AsyncMutex, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, OwnedSemaphorePermit, RwLock,
19	Semaphore, TryAcquireError,
20};
21use tokio_util::sync::CancellationToken;
22
23#[cfg(not(target_arch = "wasm32"))]
24use crate::runtime::RuntimeSpawner;
25
26use super::{BindParam, ExecuteResult, QueryResult, SqliteDb, report_sqlite_worker_fatal};
27
28pub const DEFAULT_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(60);
29pub const TRANSACTION_COORDINATOR_QUEUE_CAPACITY: usize = 128;
30pub(super) const TRANSACTION_TERMINAL_CAPACITY: usize = 1024;
31
32#[derive(Clone)]
33pub struct SqliteTransaction {
34	db: SqliteDb,
35	key: String,
36}
37
38impl SqliteTransaction {
39	pub fn key(&self) -> &str {
40		&self.key
41	}
42
43	pub async fn exec(&self, sql: impl Into<String>) -> Result<QueryResult> {
44		self.db.transaction_exec(&self.key, sql.into()).await
45	}
46
47	pub async fn execute(
48		&self,
49		sql: impl Into<String>,
50		params: Option<Vec<BindParam>>,
51	) -> Result<ExecuteResult> {
52		self.db
53			.transaction_execute(&self.key, sql.into(), params)
54			.await
55	}
56
57	pub async fn commit(&self) -> Result<()> {
58		self.db.finish_transaction(&self.key, true).await
59	}
60
61	pub async fn rollback(&self) -> Result<()> {
62		self.db.finish_transaction(&self.key, false).await
63	}
64
65	pub async fn expire(&self) -> Result<()> {
66		self.db.expire_transaction(&self.key).await
67	}
68}
69
70pub(super) struct TransactionCoordinator {
71	pub(super) gate: Arc<RwLock<()>>,
72	pub(super) admission: Arc<Semaphore>,
73	pub(super) state: AsyncMutex<TransactionCoordinatorState>,
74	epoch: AtomicU64,
75}
76
77pub(super) struct TransactionCoordinatorState {
78	pub(super) active: Option<ActiveTransaction>,
79	pub(super) terminal: BTreeMap<String, TransactionTerminalState>,
80	pub(super) terminal_order: VecDeque<String>,
81	pub(super) poisoned: BTreeMap<String, Duration>,
82	pub(super) last_expired_timeout: Option<Duration>,
83	pub(super) closed: bool,
84}
85
86pub(super) struct ActiveTransaction {
87	key: String,
88	timeout: Duration,
89	pub(super) expiring: bool,
90	connection_lost: bool,
91	remote_session: Option<u64>,
92	gate_guard: Option<OwnedRwLockWriteGuard<()>>,
93	operation: Arc<AsyncMutex<()>>,
94	timeout_task: Option<CancellationToken>,
95}
96
97#[derive(Clone, Copy)]
98pub(super) enum TransactionTerminalState {
99	Committed,
100	RolledBack,
101	Expired(Duration),
102	ConnectionLost,
103}
104
105pub(super) struct RegularOperationGuard {
106	_gate: OwnedRwLockReadGuard<()>,
107	_permit: OwnedSemaphorePermit,
108}
109
110impl Default for TransactionCoordinator {
111	fn default() -> Self {
112		Self {
113			gate: Arc::new(RwLock::new(())),
114			admission: Arc::new(Semaphore::new(TRANSACTION_COORDINATOR_QUEUE_CAPACITY)),
115			state: AsyncMutex::new(TransactionCoordinatorState {
116				active: None,
117				terminal: BTreeMap::new(),
118				terminal_order: VecDeque::new(),
119				poisoned: BTreeMap::new(),
120				last_expired_timeout: None,
121				closed: false,
122			}),
123			epoch: AtomicU64::new(0),
124		}
125	}
126}
127
128impl SqliteDb {
129	pub(super) fn try_transaction_admission(&self) -> Result<OwnedSemaphorePermit> {
130		match Arc::clone(&self.transaction_coordinator.admission).try_acquire_owned() {
131			Ok(permit) => Ok(permit),
132			Err(TryAcquireError::NoPermits) => Err(transaction_queue_full_error()),
133			Err(TryAcquireError::Closed) => Err(transaction_coordinator_closed_error()),
134		}
135	}
136
137	pub(super) async fn begin_regular_operation(&self) -> Result<RegularOperationGuard> {
138		let epoch = self.transaction_coordinator.epoch.load(Ordering::Acquire);
139		let permit = self.try_transaction_admission()?;
140		let gate = Arc::clone(&self.transaction_coordinator.gate)
141			.read_owned()
142			.await;
143		let state = self.transaction_coordinator.state.lock().await;
144		if state.closed {
145			return Err(transaction_coordinator_closed_error());
146		}
147		if self.transaction_coordinator.epoch.load(Ordering::Acquire) != epoch {
148			return Err(transaction_expired_error(
149				state
150					.last_expired_timeout
151					.unwrap_or(DEFAULT_TRANSACTION_TIMEOUT),
152			));
153		}
154		drop(state);
155		Ok(RegularOperationGuard {
156			_gate: gate,
157			_permit: permit,
158		})
159	}
160
161	pub async fn begin_transaction(&self, timeout: Option<Duration>) -> Result<SqliteTransaction> {
162		self.begin_transaction_with_key(uuid::Uuid::new_v4().to_string(), timeout)
163			.await
164	}
165
166	pub async fn begin_transaction_with_key(
167		&self,
168		key: impl Into<String>,
169		timeout: Option<Duration>,
170	) -> Result<SqliteTransaction> {
171		let key = key.into();
172		let timeout = timeout.unwrap_or(DEFAULT_TRANSACTION_TIMEOUT);
173		if key.is_empty() {
174			return Err(transaction_invalid_argument_error(
175				"transaction key must not be empty",
176			));
177		}
178		if timeout.is_zero() {
179			return Err(transaction_invalid_argument_error(
180				"transaction timeout must be greater than zero",
181			));
182		}
183
184		let db = self.clone();
185		run_detached_transaction_task(
186			async move { db.begin_transaction_inner(key, timeout).await },
187			"sqlite transaction begin task failed",
188		)
189		.await
190	}
191
192	pub(super) async fn begin_transaction_inner(
193		&self,
194		key: String,
195		timeout: Duration,
196	) -> Result<SqliteTransaction> {
197		let epoch = self.transaction_coordinator.epoch.load(Ordering::Acquire);
198		let permit = self.try_transaction_admission()?;
199		let gate_guard = Arc::clone(&self.transaction_coordinator.gate)
200			.write_owned()
201			.await;
202		{
203			let state = self.transaction_coordinator.state.lock().await;
204			if state.closed {
205				return Err(transaction_coordinator_closed_error());
206			}
207			if self.transaction_coordinator.epoch.load(Ordering::Acquire) != epoch {
208				return Err(transaction_expired_error(
209					state
210						.last_expired_timeout
211						.unwrap_or(DEFAULT_TRANSACTION_TIMEOUT),
212				));
213			}
214			if let Some(error) = transaction_known_state_error(&state, &key) {
215				return Err(error);
216			}
217		}
218
219		let (_, remote_session) = self
220			.execute_backend_in_session("BEGIN".to_owned(), None, None)
221			.await
222			.map_err(map_transaction_connection_error)?;
223		// A successful BEGIN response and the coordinator state update are two
224		// separate async events. If the socket disconnected in that narrow gap,
225		// pegboard-envoy has already dropped the connection-owned database handle
226		// and rolled the transaction back. Never publish a handle for that stale
227		// transaction.
228		if let Some(session) = remote_session
229			&& self.handle()?.connection_session() != Some(session)
230		{
231			return Err(transaction_connection_lost_error());
232		}
233		let operation = Arc::new(AsyncMutex::new(()));
234		{
235			let mut state = self.transaction_coordinator.state.lock().await;
236			if state.closed {
237				drop(state);
238				if let Err(error) = self
239					.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
240					.await
241				{
242					tracing::error!(%error, "sqlite rollback after begin raced shutdown failed");
243				}
244				return Err(transaction_coordinator_closed_error());
245			}
246			state.active = Some(ActiveTransaction {
247				key: key.clone(),
248				timeout,
249				expiring: false,
250				connection_lost: false,
251				remote_session,
252				gate_guard: Some(gate_guard),
253				operation,
254				timeout_task: None,
255			});
256		}
257		drop(permit);
258
259		let timeout_task = CancellationToken::new();
260		let mut state = self.transaction_coordinator.state.lock().await;
261		if let Some(active) = state.active.as_mut().filter(|active| active.key == key) {
262			active.timeout_task = Some(timeout_task.clone());
263		} else {
264			timeout_task.cancel();
265		}
266		drop(state);
267		spawn_transaction_timeout(
268			self.clone(),
269			key.clone(),
270			timeout,
271			timeout_task,
272			remote_session,
273		);
274
275		Ok(SqliteTransaction {
276			db: self.clone(),
277			key,
278		})
279	}
280
281	async fn transaction_operation(&self, key: &str) -> Result<(Arc<AsyncMutex<()>>, Option<u64>)> {
282		let state = self.transaction_coordinator.state.lock().await;
283		if state.closed {
284			return Err(transaction_coordinator_closed_error());
285		}
286		if let Some(active) = state.active.as_ref().filter(|active| active.key == key) {
287			if active.expiring {
288				if active.connection_lost {
289					return Err(transaction_connection_lost_error());
290				}
291				return Err(transaction_expired_owner_error(key, active.timeout));
292			}
293			return Ok((Arc::clone(&active.operation), active.remote_session));
294		}
295		if let Some(error) = transaction_known_state_error(&state, key) {
296			return Err(error);
297		}
298		Err(transaction_unknown_error(key))
299	}
300
301	async fn ensure_active_transaction(&self, key: &str) -> Result<()> {
302		let state = self.transaction_coordinator.state.lock().await;
303		if state.closed {
304			return Err(transaction_coordinator_closed_error());
305		}
306		if let Some(active) = state.active.as_ref().filter(|active| active.key == key) {
307			if active.expiring {
308				if active.connection_lost {
309					return Err(transaction_connection_lost_error());
310				}
311				return Err(transaction_expired_owner_error(key, active.timeout));
312			}
313			return Ok(());
314		}
315		if let Some(error) = transaction_known_state_error(&state, key) {
316			return Err(error);
317		}
318		Err(transaction_unknown_error(key))
319	}
320
321	async fn transaction_exec(&self, key: &str, sql: String) -> Result<QueryResult> {
322		let db = self.clone();
323		let key = key.to_owned();
324		run_detached_transaction_task(
325			async move { db.transaction_exec_inner(&key, sql).await },
326			"sqlite transaction exec task failed",
327		)
328		.await
329	}
330
331	async fn transaction_exec_inner(&self, key: &str, sql: String) -> Result<QueryResult> {
332		let (operation, remote_session) = self.transaction_operation(key).await?;
333		let _operation = operation.lock().await;
334		self.ensure_active_transaction(key).await?;
335		match self.exec_backend_in_session(sql, remote_session).await {
336			Ok((result, _)) => Ok(result),
337			Err(error) => Err(self.handle_transaction_backend_error(key, error).await),
338		}
339	}
340
341	async fn transaction_execute(
342		&self,
343		key: &str,
344		sql: String,
345		params: Option<Vec<BindParam>>,
346	) -> Result<ExecuteResult> {
347		let db = self.clone();
348		let key = key.to_owned();
349		run_detached_transaction_task(
350			async move { db.transaction_execute_inner(&key, sql, params).await },
351			"sqlite transaction execute task failed",
352		)
353		.await
354	}
355
356	async fn transaction_execute_inner(
357		&self,
358		key: &str,
359		sql: String,
360		params: Option<Vec<BindParam>>,
361	) -> Result<ExecuteResult> {
362		let (operation, remote_session) = self.transaction_operation(key).await?;
363		let _operation = operation.lock().await;
364		self.ensure_active_transaction(key).await?;
365		match self
366			.execute_backend_in_session(sql, params, remote_session)
367			.await
368		{
369			Ok((result, _)) => Ok(result),
370			Err(error) => Err(self.handle_transaction_backend_error(key, error).await),
371		}
372	}
373
374	async fn finish_transaction(&self, key: &str, commit: bool) -> Result<()> {
375		let db = self.clone();
376		let key = key.to_owned();
377		run_detached_transaction_task(
378			async move { db.finish_transaction_inner(&key, commit).await },
379			"sqlite transaction finish task failed",
380		)
381		.await
382	}
383
384	async fn finish_transaction_inner(&self, key: &str, commit: bool) -> Result<()> {
385		let (operation, remote_session) = self.transaction_operation(key).await?;
386		let _operation = operation.lock().await;
387		self.ensure_active_transaction(key).await?;
388
389		let statement = if commit { "COMMIT" } else { "ROLLBACK" };
390		let result = self
391			.execute_backend_in_session(statement.to_owned(), None, remote_session)
392			.await;
393		if let Err(primary) = result {
394			if is_remote_connection_error(&primary) {
395				self.release_transaction(key, TransactionTerminalState::ConnectionLost, false)
396					.await;
397				return Err(self.attach_actor(map_transaction_connection_error(primary)));
398			}
399			if commit {
400				let rollback_result = self
401					.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
402					.await;
403				let rollback_failed = rollback_result
404					.as_ref()
405					.is_err_and(|error| !is_no_active_transaction_error(error));
406				self.release_transaction(
407					key,
408					TransactionTerminalState::RolledBack,
409					rollback_failed,
410				)
411				.await;
412				if let Err(rollback) = rollback_result
413					&& rollback_failed
414				{
415					tracing::error!(%rollback, "sqlite rollback after failed commit failed");
416				}
417				return Err(self.attach_actor(primary));
418			}
419			if is_no_active_transaction_error(&primary) {
420				self.release_transaction(key, TransactionTerminalState::RolledBack, false)
421					.await;
422				return Ok(());
423			}
424
425			self.release_transaction(key, TransactionTerminalState::RolledBack, true)
426				.await;
427			return Err(self.attach_actor(primary));
428		}
429
430		self.release_transaction(
431			key,
432			if commit {
433				TransactionTerminalState::Committed
434			} else {
435				TransactionTerminalState::RolledBack
436			},
437			false,
438		)
439		.await;
440		Ok(())
441	}
442
443	async fn expire_transaction(&self, key: &str) -> Result<()> {
444		let db = self.clone();
445		let key = key.to_owned();
446		run_detached_transaction_task(
447			async move { db.expire_transaction_inner(&key).await },
448			"sqlite transaction expiry task failed",
449		)
450		.await
451	}
452
453	async fn expire_transaction_inner(&self, key: &str) -> Result<()> {
454		let (operation, timeout, remote_session) = {
455			let mut state = self.transaction_coordinator.state.lock().await;
456			if state.closed {
457				return Err(transaction_coordinator_closed_error());
458			}
459			if let Some(active) = state.active.as_mut().filter(|active| active.key == key) {
460				active.expiring = true;
461				(
462					Arc::clone(&active.operation),
463					active.timeout,
464					active.remote_session,
465				)
466			} else if let Some(error) = transaction_known_state_error(&state, key) {
467				return Err(error);
468			} else {
469				return Err(transaction_unknown_error(key));
470			}
471		};
472		let _operation = operation.lock().await;
473		{
474			let state = self.transaction_coordinator.state.lock().await;
475			if state.closed {
476				return Err(transaction_coordinator_closed_error());
477			}
478			if !state
479				.active
480				.as_ref()
481				.is_some_and(|active| active.key == key)
482			{
483				if let Some(error) = transaction_known_state_error(&state, key) {
484					return Err(error);
485				}
486				return Err(transaction_unknown_error(key));
487			}
488		}
489		// The deadline only wins after any operation that already owned the
490		// transaction settles. A commit or rollback that acquired the operation
491		// lock first is allowed to finish without poisoning parked work.
492		self.transaction_coordinator
493			.epoch
494			.fetch_add(1, Ordering::AcqRel);
495		let rollback = self
496			.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
497			.await;
498		let rollback_failed = rollback.as_ref().is_err_and(|error| {
499			!is_no_active_transaction_error(error) && !is_remote_connection_error(error)
500		});
501		// Advance again before releasing the exclusive gate. Calls submitted both
502		// before and during expiry cleanup must observe a changed epoch and fail;
503		// only calls submitted after cleanup may proceed.
504		self.transaction_coordinator
505			.epoch
506			.fetch_add(1, Ordering::AcqRel);
507		self.release_transaction(
508			key,
509			TransactionTerminalState::Expired(timeout),
510			rollback_failed,
511		)
512		.await;
513		if let Err(error) = rollback
514			&& rollback_failed
515		{
516			return Err(self.attach_actor(error));
517		}
518		Ok(())
519	}
520
521	async fn connection_lost_transaction(&self, key: &str, expected_session: u64) -> Result<()> {
522		let (operation, still_expected) = {
523			let mut state = self.transaction_coordinator.state.lock().await;
524			let Some(active) = state.active.as_mut().filter(|active| active.key == key) else {
525				return Ok(());
526			};
527			let still_expected = active.remote_session == Some(expected_session);
528			if still_expected {
529				active.expiring = true;
530				active.connection_lost = true;
531			}
532			(Arc::clone(&active.operation), still_expected)
533		};
534		if !still_expected {
535			return Ok(());
536		}
537		let _operation = operation.lock().await;
538
539		// The remote database handle is scoped to the server-side WebSocket
540		// connection. Disconnect closes it and SQLite rolls back its open
541		// transaction. Sending ROLLBACK on a replacement connection would target a
542		// different database session, so cleanup here is intentionally local: make
543		// the handle terminal and release actor/socket work queued behind it.
544		self.release_transaction(key, TransactionTerminalState::ConnectionLost, false)
545			.await;
546		Ok(())
547	}
548
549	async fn handle_transaction_backend_error(
550		&self,
551		key: &str,
552		error: anyhow::Error,
553	) -> anyhow::Error {
554		if is_remote_connection_error(&error) {
555			self.release_transaction(key, TransactionTerminalState::ConnectionLost, false)
556				.await;
557		}
558		self.attach_actor(map_transaction_connection_error(error))
559	}
560
561	pub(super) async fn release_transaction(
562		&self,
563		key: &str,
564		terminal: TransactionTerminalState,
565		close: bool,
566	) {
567		let mut state = self.transaction_coordinator.state.lock().await;
568		if !state
569			.active
570			.as_ref()
571			.is_some_and(|active| active.key == key)
572		{
573			return;
574		}
575		let mut active = state.active.take().expect("active transaction was checked");
576		if let Some(task) = active.timeout_task.take() {
577			task.cancel();
578		}
579		if let TransactionTerminalState::Expired(timeout) = terminal {
580			state.last_expired_timeout = Some(timeout);
581		}
582		insert_terminal_state(&mut state, key.to_owned(), terminal);
583		state.closed |= close;
584		if close {
585			self.transaction_coordinator.admission.close();
586			if let Ok(config) = self.runtime_config() {
587				report_sqlite_worker_fatal(
588					&self.worker_fatal_reported,
589					config,
590					"sqlite transaction rollback failed; coordinator closed".to_owned(),
591				);
592			}
593		}
594		drop(active.gate_guard.take());
595	}
596
597	pub(super) async fn shutdown_transaction_coordinator(&self) -> OwnedRwLockWriteGuard<()> {
598		let active_operation = {
599			let mut state = self.transaction_coordinator.state.lock().await;
600			state.closed = true;
601			state.active.as_ref().map(|active| {
602				(
603					active.key.clone(),
604					Arc::clone(&active.operation),
605					active.remote_session,
606				)
607			})
608		};
609		self.transaction_coordinator.admission.close();
610		if let Some((key, operation, remote_session)) = active_operation {
611			let _operation = operation.lock().await;
612			let still_active = {
613				let state = self.transaction_coordinator.state.lock().await;
614				state
615					.active
616					.as_ref()
617					.is_some_and(|active| active.key == key)
618			};
619			if still_active {
620				let rollback = self
621					.execute_backend_in_session("ROLLBACK".to_owned(), None, remote_session)
622					.await;
623				let rollback_failed = rollback.as_ref().is_err_and(|error| {
624					!is_no_active_transaction_error(error) && !is_remote_connection_error(error)
625				});
626				if let Err(error) = &rollback
627					&& rollback_failed
628				{
629					tracing::error!(%error, "sqlite rollback during coordinator shutdown failed");
630				}
631				self.transaction_coordinator
632					.epoch
633					.fetch_add(1, Ordering::AcqRel);
634				self.release_transaction(
635					&key,
636					TransactionTerminalState::RolledBack,
637					rollback_failed,
638				)
639				.await;
640			}
641		}
642		Arc::clone(&self.transaction_coordinator.gate)
643			.write_owned()
644			.await
645	}
646}
647
648#[derive(rivet_error::RivetError, Debug, Serialize)]
649#[error(
650	"sqlite",
651	"transaction_queue_full",
652	"SQLite transaction queue is full.",
653	"SQLite transaction coordinator queue is full. Limit is 128 operations."
654)]
655pub struct TransactionQueueFullError;
656
657impl fmt::Display for TransactionQueueFullError {
658	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
659		f.write_str("sqlite transaction coordinator queue is full")
660	}
661}
662
663impl Error for TransactionQueueFullError {}
664
665#[derive(rivet_error::RivetError, Debug, Serialize)]
666#[error(
667	"sqlite",
668	"transaction_closed",
669	"SQLite transaction coordinator is closed."
670)]
671pub struct TransactionCoordinatorClosedError;
672
673impl fmt::Display for TransactionCoordinatorClosedError {
674	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
675		f.write_str("sqlite transaction coordinator is closed")
676	}
677}
678
679impl Error for TransactionCoordinatorClosedError {}
680
681#[derive(rivet_error::RivetError, Debug, Serialize)]
682#[error(
683	"sqlite",
684	"transaction_invalid_argument",
685	"Invalid SQLite transaction argument.",
686	"{message}"
687)]
688pub struct TransactionInvalidArgumentError {
689	pub message: &'static str,
690}
691
692impl fmt::Display for TransactionInvalidArgumentError {
693	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
694		f.write_str(self.message)
695	}
696}
697
698impl Error for TransactionInvalidArgumentError {}
699
700#[derive(rivet_error::RivetError, Debug, Serialize)]
701#[error(
702	"sqlite",
703	"transaction_connection_lost",
704	"SQLite transaction connection was lost.",
705	"The Envoy connection that owned this SQLite transaction disconnected. The transaction was rolled back and cannot be resumed; start a new db.transaction()."
706)]
707pub struct TransactionConnectionLostError;
708
709impl fmt::Display for TransactionConnectionLostError {
710	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
711		f.write_str(
712			"the Envoy connection that owned this SQLite transaction disconnected; the transaction was rolled back and cannot be resumed",
713		)
714	}
715}
716
717impl Error for TransactionConnectionLostError {}
718
719#[derive(rivet_error::RivetError, Debug, Serialize)]
720#[error(
721	"sqlite",
722	"transaction_unknown",
723	"Unknown SQLite transaction handle.",
724	"Unknown SQLite transaction handle `{key}`."
725)]
726pub struct TransactionUnknownError {
727	pub key: String,
728}
729
730impl fmt::Display for TransactionUnknownError {
731	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
732		write!(f, "unknown sqlite transaction handle `{}`", self.key)
733	}
734}
735
736impl Error for TransactionUnknownError {}
737
738#[derive(rivet_error::RivetError, Debug, Serialize)]
739#[error(
740	"sqlite",
741	"transaction_terminal",
742	"SQLite transaction handle is terminal.",
743	"SQLite transaction handle `{key}` is already {state}."
744)]
745pub struct TransactionTerminalError {
746	pub key: String,
747	pub state: &'static str,
748}
749
750impl fmt::Display for TransactionTerminalError {
751	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
752		write!(
753			f,
754			"sqlite transaction handle `{}` is already {}",
755			self.key, self.state
756		)
757	}
758}
759
760impl Error for TransactionTerminalError {}
761
762#[derive(rivet_error::RivetError, Debug, Serialize)]
763#[error(
764	"sqlite",
765	"transaction_expired",
766	"SQLite transaction expired.",
767	"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."
768)]
769pub struct TransactionExpiredError {
770	pub timeout_ms: u64,
771}
772
773impl fmt::Display for TransactionExpiredError {
774	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
775		write!(
776			f,
777			"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",
778			self.timeout_ms
779		)
780	}
781}
782
783impl Error for TransactionExpiredError {}
784
785impl TransactionExpiredError {
786	fn owner(_key: &str, timeout: Duration) -> Self {
787		Self {
788			timeout_ms: duration_millis(timeout),
789		}
790	}
791
792	fn parked(timeout: Duration) -> Self {
793		Self {
794			timeout_ms: duration_millis(timeout),
795		}
796	}
797}
798
799fn duration_millis(duration: Duration) -> u64 {
800	duration.as_millis().try_into().unwrap_or(u64::MAX)
801}
802
803async fn sleep_transaction_timeout(mut timeout: Duration) {
804	// Browser timers commonly cap one wait at signed 32-bit milliseconds.
805	// Chunking preserves the caller's unbounded product-level timeout on wasm.
806	const MAX_TIMER_WAIT: Duration = Duration::from_millis(2_000_000_000);
807	while timeout > MAX_TIMER_WAIT {
808		crate::time::sleep(MAX_TIMER_WAIT).await;
809		timeout = timeout.saturating_sub(MAX_TIMER_WAIT);
810	}
811	crate::time::sleep(timeout).await;
812}
813
814fn spawn_transaction_timeout(
815	db: SqliteDb,
816	key: String,
817	timeout: Duration,
818	cancel: CancellationToken,
819	remote_session: Option<u64>,
820) {
821	let task = async move {
822		let connection_lost = async {
823			let Some(expected) = remote_session else {
824				std::future::pending::<()>().await;
825				return;
826			};
827			let Ok(handle) = db.handle() else {
828				return;
829			};
830			let mut sessions = handle.subscribe_connection_session();
831			loop {
832				if *sessions.borrow_and_update() != expected {
833					return;
834				}
835				if sessions.changed().await.is_err() {
836					return;
837				}
838			}
839		};
840		tokio::pin!(connection_lost);
841
842		let expired = tokio::select! {
843			_ = cancel.cancelled() => return,
844			_ = sleep_transaction_timeout(timeout) => true,
845			_ = &mut connection_lost => false,
846		};
847		let result = if expired {
848			db.expire_transaction(&key).await
849		} else {
850			// This covers all disconnect timing windows. During BEGIN there is no
851			// active coordinator entry yet and the BEGIN caller receives its own
852			// indeterminate/session error. Between statements this branch is the only
853			// signal, and it prevents the next statement from autocommitting after a
854			// fast reconnect. During a statement or COMMIT, request tracking preserves
855			// the operation's indeterminate result while this terminalizes the handle.
856			db.connection_lost_transaction(&key, remote_session.expect("checked above"))
857				.await
858		};
859		if let Err(error) = result {
860			if error.downcast_ref::<TransactionTerminalError>().is_none() {
861				tracing::error!(%error, "sqlite transaction terminal cleanup failed");
862			}
863		}
864	};
865
866	#[cfg(target_arch = "wasm32")]
867	wasm_bindgen_futures::spawn_local(task);
868
869	#[cfg(not(target_arch = "wasm32"))]
870	RuntimeSpawner::spawn(task);
871}
872
873#[cfg(not(target_arch = "wasm32"))]
874pub(super) async fn run_detached_transaction_task<F, T>(
875	future: F,
876	context: &'static str,
877) -> Result<T>
878where
879	F: Future<Output = Result<T>> + Send + 'static,
880	T: Send + 'static,
881{
882	RuntimeSpawner::spawn(future).await.context(context)?
883}
884
885#[cfg(target_arch = "wasm32")]
886pub(super) async fn run_detached_transaction_task<F, T>(
887	future: F,
888	context: &'static str,
889) -> Result<T>
890where
891	F: Future<Output = Result<T>> + 'static,
892	T: 'static,
893{
894	let (response_tx, response_rx) = oneshot::channel();
895	wasm_bindgen_futures::spawn_local(async move {
896		let _ = response_tx.send(future.await);
897	});
898	response_rx.await.context(context)?
899}
900
901pub(super) fn insert_terminal_state(
902	state: &mut TransactionCoordinatorState,
903	key: String,
904	terminal: TransactionTerminalState,
905) {
906	if let TransactionTerminalState::Expired(timeout) = terminal {
907		state.poisoned.insert(key, timeout);
908		return;
909	}
910	if state.terminal.insert(key.clone(), terminal).is_none() {
911		state.terminal_order.push_back(key);
912	}
913	while state.terminal_order.len() > TRANSACTION_TERMINAL_CAPACITY {
914		if let Some(expired_key) = state.terminal_order.pop_front() {
915			state.terminal.remove(&expired_key);
916		}
917	}
918}
919
920fn transaction_known_state_error(
921	state: &TransactionCoordinatorState,
922	key: &str,
923) -> Option<anyhow::Error> {
924	if let Some(timeout) = state.poisoned.get(key).copied() {
925		return Some(transaction_expired_owner_error(key, timeout));
926	}
927	state
928		.terminal
929		.get(key)
930		.copied()
931		.map(|terminal| transaction_terminal_error(key, terminal))
932}
933
934fn is_no_active_transaction_error(error: &anyhow::Error) -> bool {
935	// SQLite does not expose a dedicated result code for "cannot commit/rollback
936	// - no transaction is active"; both native SQLite and the remote executor
937	// surface SQLITE_ERROR plus this stable SQLite-generated text. Keep the
938	// classifier isolated here. In particular, ON CONFLICT ROLLBACK can end a
939	// transaction before our cleanup call, and treating that cleanup response as
940	// fatal would permanently close the coordinator. The cross-runtime actor-db
941	// suite exercises that real automatic-rollback path on native and Wasm.
942	error.chain().any(|cause| {
943		cause
944			.to_string()
945			.to_ascii_lowercase()
946			.contains("no transaction is active")
947	})
948}
949
950fn transaction_queue_full_error() -> anyhow::Error {
951	TransactionQueueFullError
952		.build()
953		.context(TransactionQueueFullError)
954}
955
956fn transaction_coordinator_closed_error() -> anyhow::Error {
957	TransactionCoordinatorClosedError
958		.build()
959		.context(TransactionCoordinatorClosedError)
960}
961
962fn transaction_unknown_error(key: &str) -> anyhow::Error {
963	TransactionUnknownError {
964		key: key.to_owned(),
965	}
966	.build()
967	.context(TransactionUnknownError {
968		key: key.to_owned(),
969	})
970}
971
972fn transaction_expired_error(timeout: Duration) -> anyhow::Error {
973	TransactionExpiredError::parked(timeout)
974		.build()
975		.context(TransactionExpiredError::parked(timeout))
976}
977
978fn transaction_expired_owner_error(key: &str, timeout: Duration) -> anyhow::Error {
979	TransactionExpiredError::owner(key, timeout)
980		.build()
981		.context(TransactionExpiredError::owner(key, timeout))
982}
983
984fn transaction_terminal_error(key: &str, terminal: TransactionTerminalState) -> anyhow::Error {
985	match terminal {
986		TransactionTerminalState::Expired(timeout) => transaction_expired_owner_error(key, timeout),
987		TransactionTerminalState::Committed => transaction_terminal_state_error(key, "committed"),
988		TransactionTerminalState::RolledBack => {
989			transaction_terminal_state_error(key, "rolled back")
990		}
991		TransactionTerminalState::ConnectionLost => transaction_connection_lost_error(),
992	}
993}
994
995fn transaction_invalid_argument_error(message: &'static str) -> anyhow::Error {
996	TransactionInvalidArgumentError { message }
997		.build()
998		.context(TransactionInvalidArgumentError { message })
999}
1000
1001fn transaction_connection_lost_error() -> anyhow::Error {
1002	TransactionConnectionLostError
1003		.build()
1004		.context(TransactionConnectionLostError)
1005}
1006
1007fn is_remote_connection_error(error: &anyhow::Error) -> bool {
1008	error
1009		.downcast_ref::<super::RemoteSqliteConnectionSessionLostError>()
1010		.is_some()
1011		|| error
1012			.downcast_ref::<super::RemoteSqliteIndeterminateResultError>()
1013			.is_some()
1014}
1015
1016fn map_transaction_connection_error(error: anyhow::Error) -> anyhow::Error {
1017	if error
1018		.downcast_ref::<super::RemoteSqliteConnectionSessionLostError>()
1019		.is_some()
1020	{
1021		return transaction_connection_lost_error();
1022	}
1023	super::remote_request_error(error)
1024}
1025
1026fn transaction_terminal_state_error(key: &str, state: &'static str) -> anyhow::Error {
1027	TransactionTerminalError {
1028		key: key.to_owned(),
1029		state,
1030	}
1031	.build()
1032	.context(TransactionTerminalError {
1033		key: key.to_owned(),
1034		state,
1035	})
1036}