Skip to main content

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}