turso_orm_driver/transaction.rs
1//! Transactions bound to one pooled connection, modeled by [`Transaction`].
2//!
3//! SQLite transactions are a property of the connection, so a transaction
4//! must pin the pooled connection it started on and route every statement
5//! through it; a second connection would see a different snapshot or block
6//! on the first one's lock. Nesting is done with `SAVEPOINT`s on that same
7//! connection, which is why a nested [`Transaction`] shares the parent's
8//! state rather than acquiring anything.
9//!
10//! The engine keeps one stack of savepoints, so only the innermost open
11//! transaction may act. A statement issued through a parent while a nested
12//! transaction is open would run inside the nested savepoint and share its
13//! fate; one issued after the top level finished would run outside any
14//! transaction. Every handle therefore checks, before each statement, that
15//! it is the top of the shared stack and that the top level is still open,
16//! and fails with [`Error::Misuse`](crate::Error::Misuse) otherwise. Each
17//! savepoint carries a unique identifier rather than its depth, so a stale
18//! handle can never name a newer savepoint that took its place.
19//!
20//! `Drop` cannot await, so an unfinished transaction cannot roll itself
21//! back synchronously. The module makes that safe in two ways: a dropped
22//! top-level transaction discards its connection, which the engine rolls
23//! back when the connection closes and which the pool never hands out
24//! again; a dropped nested transaction records its position in the shared
25//! stack and the parent runs `ROLLBACK TO SAVEPOINT` before its next
26//! statement. The shallowest dropped position wins because rolling back to
27//! it subsumes every deeper savepoint.
28//!
29//! This module owns the transaction lifecycle only. Statement execution is
30//! delegated to `crate::executor` and the pool to `crate::database`.
31//!
32//! - [`Transaction`]: the handle, which must be committed or rolled back;
33//! - [`TransactionMode`]: how a top-level transaction takes its locks.
34
35use std::fmt;
36use std::future::Future;
37use std::pin::Pin;
38use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
39
40use async_trait::async_trait;
41use turso_sql::Statement;
42
43use crate::connection::{ConnectionTrait, StreamTrait, TransactionTrait};
44use crate::database::{PooledConnection, retry_busy};
45use crate::error::{Error, Result};
46use crate::executor::{self, Conn, ExecResult, Row, RowStream};
47
48/// How a top-level transaction is started.
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum TransactionMode {
52 /// `BEGIN DEFERRED`, the default: locks are taken by the first
53 /// statement.
54 #[default]
55 Deferred,
56 /// `BEGIN IMMEDIATE`: the write lock is taken now, so a writer finds out
57 /// about contention before doing any work.
58 Immediate,
59 /// `BEGIN EXCLUSIVE`: the exclusive lock is taken now.
60 Exclusive,
61 /// `BEGIN CONCURRENT`: an optimistic MVCC transaction whose conflicts
62 /// surface at commit. Requires
63 /// [`ConnectOptions::mvcc`](crate::ConnectOptions::mvcc).
64 Concurrent,
65}
66
67impl TransactionMode {
68 /// The `BEGIN` statement for this mode.
69 fn sql(self) -> &'static str {
70 match self {
71 Self::Deferred => "BEGIN DEFERRED",
72 Self::Immediate => "BEGIN IMMEDIATE",
73 Self::Exclusive => "BEGIN EXCLUSIVE",
74 Self::Concurrent => "BEGIN CONCURRENT",
75 }
76 }
77}
78
79/// The savepoint bookkeeping shared by a transaction and its nested ones.
80struct State {
81 /// The identifiers of the open savepoints, outermost first; mirrors the
82 /// engine's savepoint stack.
83 savepoints: Vec<u64>,
84 /// The identifier of the last savepoint created; incremented before use.
85 last_id: u64,
86 /// The stack position of the shallowest nested transaction dropped
87 /// without commit or rollback; rolled back to before the next statement.
88 pending_rollback: Option<usize>,
89 /// Whether the top-level transaction has finished or been dropped.
90 closed: bool,
91}
92
93/// The connection state shared by a transaction and its nested savepoints.
94struct Shared {
95 /// The pinned connection.
96 conn: PooledConnection,
97 /// The savepoint bookkeeping, never held across an `.await`.
98 state: Mutex<State>,
99}
100
101impl Shared {
102 /// Locks the savepoint bookkeeping.
103 fn state(&self) -> MutexGuard<'_, State> {
104 // Nothing panics while the lock is held, so a poisoned lock still
105 // guards a consistent stack.
106 self.state.lock().unwrap_or_else(PoisonError::into_inner)
107 }
108
109 /// Applies the rollback owed by a dropped nested transaction, if any.
110 ///
111 /// Called before every statement and before finishing, so that work
112 /// done inside a dropped savepoint never leaks into the parent.
113 ///
114 /// # Errors
115 ///
116 /// Returns [`Error::Turso`] when the engine cannot roll back to or
117 /// release the savepoint.
118 async fn settle(&self) -> Result<()> {
119 let pending = {
120 let mut state = self.state();
121 state
122 .pending_rollback
123 .take()
124 .and_then(|index| Some((index, *state.savepoints.get(index)?)))
125 };
126 if let Some((index, id)) = pending {
127 tracing::warn!(
128 depth = index + 1,
129 "rolling back nested transaction dropped without commit"
130 );
131 rollback_to(&self.conn, id).await?;
132 self.state().savepoints.truncate(index);
133 }
134 Ok(())
135 }
136}
137
138/// Rolls back to the savepoint `id` and releases it.
139///
140/// `ROLLBACK TO` alone leaves the savepoint on the stack, so it is released
141/// afterwards to keep the engine's savepoint stack in step with the shared
142/// one.
143///
144/// # Errors
145///
146/// Returns [`Error::Turso`] when either statement fails.
147async fn rollback_to(conn: &Conn, id: u64) -> Result<()> {
148 conn.execute_raw(&format!("ROLLBACK TO SAVEPOINT sp{id}"))
149 .await?;
150 conn.execute_raw(&format!("RELEASE SAVEPOINT sp{id}"))
151 .await?;
152 Ok(())
153}
154
155/// A transaction on one Turso connection.
156///
157/// Created with [`Database::begin`](crate::Database::begin) or
158/// [`Database::begin_with_mode`](crate::Database::begin_with_mode). Nested
159/// transactions (`txn.begin()`) are `SAVEPOINT`s on the same connection.
160/// While a nested transaction is open it is the only one that may run
161/// statements, begin, commit or roll back; its parents fail with
162/// [`Error::Misuse`] until it is finished or dropped. Dropping an unfinished
163/// transaction rolls it back: a top-level transaction discards its
164/// connection, a nested one is rolled back to its savepoint before the
165/// parent runs its next statement.
166#[must_use = "a transaction must be committed or rolled back"]
167pub struct Transaction {
168 /// The connection state shared with the parent and children.
169 shared: Arc<Shared>,
170 /// This transaction's savepoint identifier; `None` for the top level.
171 savepoint: Option<u64>,
172 /// This transaction's savepoint depth; `0` for the top level.
173 depth: u32,
174 /// Whether the transaction is still unfinished; cleared by `finish` so
175 /// that `Drop` does nothing after a commit or rollback.
176 open: bool,
177}
178
179impl fmt::Debug for Transaction {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 f.debug_struct("Transaction")
182 .field("depth", &self.depth)
183 .field("open", &self.open)
184 .finish_non_exhaustive()
185 }
186}
187
188impl Transaction {
189 /// Begins a top-level transaction on `conn` with the given mode.
190 ///
191 /// `BEGIN IMMEDIATE` and `BEGIN EXCLUSIVE` fail immediately when another
192 /// writer holds the lock, so the statement is retried for the busy
193 /// timeout; a `None` busy timeout gives a zero budget and no retry.
194 ///
195 /// # Errors
196 ///
197 /// Returns [`Error::Turso`] when the engine cannot start the
198 /// transaction — with [`ErrorKind::Busy`](crate::ErrorKind::Busy) once
199 /// the retry budget is exhausted.
200 pub(crate) async fn begin_top(conn: PooledConnection, mode: TransactionMode) -> Result<Self> {
201 let budget = conn.options().busy_timeout.unwrap_or_default();
202 retry_busy(budget, || async { conn.execute_raw(mode.sql()).await }).await?;
203 Ok(Self {
204 shared: Arc::new(Shared {
205 conn,
206 state: Mutex::new(State {
207 savepoints: Vec::new(),
208 last_id: 0,
209 pending_rollback: None,
210 closed: false,
211 }),
212 }),
213 savepoint: None,
214 depth: 0,
215 open: true,
216 })
217 }
218
219 /// Begins a nested transaction as a savepoint one level deeper.
220 ///
221 /// # Errors
222 ///
223 /// Returns the errors of [`prepare`](Self::prepare);
224 /// [`Error::Turso`] when the `SAVEPOINT` statement fails.
225 async fn begin_nested(&self) -> Result<Self> {
226 self.prepare().await?;
227 let id = {
228 let mut state = self.shared.state();
229 state.last_id += 1;
230 state.last_id
231 };
232 self.conn()
233 .execute_raw(&format!("SAVEPOINT sp{id}"))
234 .await?;
235 self.shared.state().savepoints.push(id);
236 Ok(Self {
237 shared: Arc::clone(&self.shared),
238 savepoint: Some(id),
239 depth: self.depth + 1,
240 open: true,
241 })
242 }
243
244 /// The savepoint depth: `0` for a top-level transaction.
245 pub fn depth(&self) -> u32 {
246 self.depth
247 }
248
249 /// The pinned engine connection.
250 fn conn(&self) -> &Conn {
251 &self.shared.conn
252 }
253
254 /// Checks that this transaction may act, after applying the rollback
255 /// owed by a dropped nested transaction.
256 ///
257 /// # Errors
258 ///
259 /// Returns [`Error::Misuse`] when the top-level transaction has
260 /// finished, when a nested transaction is still open inside this one,
261 /// or when this one was rolled back with a dropped parent;
262 /// [`Error::Turso`] when the pending rollback fails.
263 async fn prepare(&self) -> Result<()> {
264 if self.shared.state().closed {
265 return Err(Error::Misuse("transaction already finished".into()));
266 }
267 self.shared.settle().await?;
268 let state = self.shared.state();
269 let innermost = match self.savepoint {
270 None => state.savepoints.is_empty(),
271 Some(id) => state.savepoints.last() == Some(&id),
272 };
273 if innermost {
274 return Ok(());
275 }
276 let alive = self
277 .savepoint
278 .is_none_or(|id| state.savepoints.contains(&id));
279 Err(Error::Misuse(
280 if alive {
281 "a nested transaction is still open"
282 } else {
283 "nested transaction rolled back with its parent"
284 }
285 .into(),
286 ))
287 }
288
289 /// Finishes the transaction, rolling back when `rollback` is set and
290 /// committing otherwise.
291 ///
292 /// When the checks fail, the transaction is dropped unfinished and
293 /// rolled back as `Drop` describes. Once they pass, `open` is cleared
294 /// so that `Drop` stays inert even if the engine statement fails; a
295 /// failed commit leaves the connection in an unknown state, which the
296 /// pool detects through `is_autocommit`.
297 ///
298 /// # Errors
299 ///
300 /// Returns the errors of [`prepare`](Self::prepare);
301 /// [`Error::Turso`] when the finishing statement fails.
302 async fn finish(mut self, rollback: bool) -> Result<()> {
303 self.prepare().await?;
304 self.open = false;
305 match self.savepoint {
306 None => {
307 self.shared.state().closed = true;
308 self.conn()
309 .execute_raw(if rollback { "ROLLBACK" } else { "COMMIT" })
310 .await?;
311 }
312 Some(id) => {
313 if rollback {
314 rollback_to(self.conn(), id).await?;
315 } else {
316 self.conn()
317 .execute_raw(&format!("RELEASE SAVEPOINT sp{id}"))
318 .await?;
319 }
320 self.shared.state().savepoints.pop();
321 }
322 }
323 Ok(())
324 }
325
326 /// Commits the transaction — `COMMIT`, or `RELEASE SAVEPOINT` when
327 /// nested.
328 ///
329 /// # Errors
330 ///
331 /// Returns [`Error::Misuse`] when a nested transaction is still open or
332 /// the transaction can no longer act, in which case it is rolled back;
333 /// [`Error::Turso`] when the commit fails — an MVCC write conflict
334 /// surfaces here with [`ErrorKind::Busy`](crate::ErrorKind::Busy).
335 pub async fn commit(self) -> Result<()> {
336 self.finish(false).await
337 }
338
339 /// Rolls the transaction back — `ROLLBACK`, or `ROLLBACK TO SAVEPOINT`
340 /// when nested.
341 ///
342 /// # Errors
343 ///
344 /// Returns [`Error::Misuse`] when a nested transaction is still open or
345 /// the transaction can no longer act, in which case it is still rolled
346 /// back; [`Error::Turso`] when the rollback fails.
347 pub async fn rollback(self) -> Result<()> {
348 self.finish(true).await
349 }
350}
351
352impl Drop for Transaction {
353 fn drop(&mut self) {
354 if !self.open {
355 return;
356 }
357 let mut state = self.shared.state();
358 let Some(id) = self.savepoint else {
359 state.closed = true;
360 drop(state);
361 // Nothing can be awaited here, so the connection is withheld
362 // from the pool; the engine rolls back when it is closed.
363 tracing::warn!("transaction dropped without commit or rollback; discarding connection");
364 self.shared.conn.discard();
365 return;
366 };
367 // A savepoint already gone — under a finished top level, or rolled
368 // back with a parent — owes nothing. Otherwise record the shallowest
369 // dropped position: rolling back to it also undoes every deeper
370 // savepoint.
371 if state.closed {
372 return;
373 }
374 if let Some(index) = state.savepoints.iter().position(|&s| s == id) {
375 state.pending_rollback = Some(state.pending_rollback.map_or(index, |p| p.min(index)));
376 }
377 }
378}
379
380#[async_trait]
381impl ConnectionTrait for Transaction {
382 async fn execute(&self, statement: Statement) -> Result<ExecResult> {
383 self.prepare().await?;
384 executor::execute(self.conn(), &statement).await
385 }
386
387 async fn execute_unprepared(&self, sql: &str) -> Result<ExecResult> {
388 self.prepare().await?;
389 executor::execute_unprepared(self.conn(), sql).await
390 }
391
392 async fn query_one(&self, statement: Statement) -> Result<Option<Row>> {
393 self.prepare().await?;
394 executor::query_one(self.conn(), &statement).await
395 }
396
397 async fn query_all(&self, statement: Statement) -> Result<Vec<Row>> {
398 self.prepare().await?;
399 executor::query_all(self.conn(), &statement).await
400 }
401}
402
403impl StreamTrait for Transaction {
404 fn stream<'a>(
405 &'a self,
406 statement: Statement,
407 ) -> Pin<Box<dyn Future<Output = Result<RowStream<'a>>> + Send + 'a>> {
408 Box::pin(async move {
409 self.prepare().await?;
410 // The stream borrows the transaction, which already pins the
411 // connection, so there is nothing extra to hold.
412 executor::stream(self.conn(), &statement, ()).await
413 })
414 }
415}
416
417#[async_trait]
418impl TransactionTrait for Transaction {
419 async fn begin(&self) -> Result<Transaction> {
420 self.begin_nested().await
421 }
422
423 async fn begin_with_mode(&self, _mode: TransactionMode) -> Result<Transaction> {
424 // Nested transactions are savepoints, which have no mode; the mode
425 // only applies at depth 0.
426 self.begin_nested().await
427 }
428}