Skip to main content

backbone_orm/
write_tx.rs

1//! The write transaction: one database transaction around everything a write does.
2//!
3//! A repository write commits on its own, statement by statement: a create commits, and only
4//! then does the service run its after-hooks and publish its events. A crash between the two
5//! loses the effect, and a hook that fails afterwards cannot undo the row. [`in_write_tx`] closes
6//! that gap. It opens one transaction, publishes it as a task-local, and commits it only when the
7//! whole write succeeds; every repository write path and every scoped statement helper then runs
8//! on it instead of committing by itself.
9//!
10//! Behaviour without a write transaction is exactly what it was: a hand-written caller that
11//! never opens one sees no change.
12//!
13//! # Which connection
14//!
15//! - When a request scope holds a dedicated connection
16//!   ([`with_request_scope`](crate::company_scope::with_request_scope) or the org request scope),
17//!   the transaction is begun on that connection. The fence variables it carries (company, org
18//!   units, acting unit, audit attribution) were set at session level, so they hold inside the
19//!   transaction without being set again, and ID-only lookups stay fenced.
20//! - Otherwise a pool connection is taken, the transaction is begun, and the company scope of
21//!   the task, the org scope and the audit attribution are bound transaction-locally, as
22//!   [`bind_current_company`](crate::company_scope::bind_current_company) does for a bulk write.
23//!
24//! Row-level security therefore decides inside the transaction exactly what it decided outside.
25//! A row it refuses refuses the write and, since Postgres aborts the transaction, the whole of it.
26//!
27//! # Nesting and savepoints
28//!
29//! A write transaction opened while one is already active joins it as a savepoint: if the inner
30//! work fails, only the inner work is undone and the outer transaction can carry on; if the inner
31//! work succeeds, its effects wait for the outer commit. The bulk repository paths use the same
32//! mechanism, so a bulk write that fails inside a service's transaction rolls back its own rows
33//! and reports the error without poisoning anything the caller already holds.
34//!
35//! # Cancellation
36//!
37//! If the future running inside [`in_write_tx`] is dropped (a client disconnect, a timeout layer),
38//! the transaction is rolled back by a task spawned from the guard's `Drop`, and the connection
39//! is closed instead of returned to the pool if that rollback fails. A transaction never returns
40//! to the pool open.
41//!
42//! # Reading through it
43//!
44//! While a write transaction is active, [`request_conn`](crate::company_scope) answers with its
45//! connection, so every scoped read and write helper of this crate, and every repository method,
46//! sees the transaction's own uncommitted rows. Code that opens a connection of its own (a bare
47//! `pool.begin()` inside a hook) does not, and cannot see them: that is the one behaviour a hook
48//! has to know about.
49
50use std::future::Future;
51use std::ops::{Deref, DerefMut};
52use std::sync::Arc;
53
54use sqlx::pool::PoolConnection;
55use sqlx::postgres::PgConnectOptions;
56use sqlx::{Database, PgConnection, PgPool, Postgres, TransactionManager};
57use tokio::sync::{Mutex, MutexGuard};
58
59type Tm = <Postgres as Database>::TransactionManager;
60
61tokio::task_local! {
62    /// The write transaction of the current task, published by [`in_write_tx`].
63    static WRITE_TX: Arc<WriteTx>;
64}
65
66/// The write transaction of the current task: a handle to the connection it runs on.
67///
68/// Cloning the `Arc` is cheap; the connection is behind a mutex, so statements of one task run
69/// one at a time, in order. After the transaction ends a clone that is still alive runs
70/// statements outside any transaction, unscoped on a pool connection (fail-closed) or under the
71/// request scope's own variables.
72pub struct WriteTx {
73    conn: Arc<Mutex<PoolConnection<Postgres>>>,
74    /// The pool the transaction took its own connection from; `None` when it rides the request
75    /// connection. A write transaction opened for another pool (a database per tenant) must not
76    /// join this one.
77    owned_pool: Option<Arc<PgConnectOptions>>,
78}
79
80impl WriteTx {
81    /// Lock the transaction's connection to run statements on it: `query.execute(&mut *tx.conn().await)`.
82    ///
83    /// Do not hold the guard across a call that runs repository methods or opens a savepoint:
84    /// they lock the same connection and would wait for it.
85    pub async fn conn(&self) -> WriteTxConn<'_> {
86        WriteTxConn(self.conn.lock().await)
87    }
88
89    fn accepts(&self, pool: &PgPool) -> bool {
90        match &self.owned_pool {
91            None => true,
92            Some(own) => Arc::ptr_eq(own, &pool.connect_options()),
93        }
94    }
95}
96
97/// The connection of a write transaction, locked. Dereferences to the Postgres connection.
98pub struct WriteTxConn<'a>(MutexGuard<'a, PoolConnection<Postgres>>);
99
100impl Deref for WriteTxConn<'_> {
101    type Target = PgConnection;
102    fn deref(&self) -> &PgConnection {
103        &self.0
104    }
105}
106
107impl DerefMut for WriteTxConn<'_> {
108    fn deref_mut(&mut self) -> &mut PgConnection {
109        &mut self.0
110    }
111}
112
113/// The write transaction of the current task, if [`in_write_tx`] opened one.
114pub fn current_write_tx() -> Option<Arc<WriteTx>> {
115    WRITE_TX.try_with(|tx| tx.clone()).ok()
116}
117
118/// Whether the current task runs inside a write transaction.
119pub fn in_write_tx_scope() -> bool {
120    WRITE_TX.try_with(|_| ()).is_ok()
121}
122
123/// The connection of the active write transaction, for the scoped statement helpers.
124pub(crate) fn current_conn() -> Option<Arc<Mutex<PoolConnection<Postgres>>>> {
125    WRITE_TX.try_with(|tx| tx.conn.clone()).ok()
126}
127
128/// Run `f` inside one transaction: committed when `f` returns `Ok`, rolled back when it returns
129/// `Err` or is dropped.
130///
131/// Every repository write and scoped statement `f` runs, in this crate or in a hand-written
132/// service that uses its helpers, rides the transaction (see the [module](self) docs). Opened
133/// inside another write transaction for the same pool it is a savepoint of it.
134///
135/// # Errors
136/// `f`'s own error; or a database error from beginning, binding the scope or committing, converted
137/// through `E: From<sqlx::Error>`. A failed commit (a deferred constraint, a serialization
138/// failure) is returned as an error and nothing of the transaction survives.
139pub async fn in_write_tx<F, T, E>(pool: &PgPool, f: F) -> Result<T, E>
140where
141    F: Future<Output = Result<T, E>>,
142    E: From<sqlx::Error>,
143{
144    if let Some(ambient) = current_write_tx() {
145        if ambient.accepts(pool) {
146            // A savepoint of the transaction already open.
147            let unit = TxUnit::begin_on(ambient.conn.clone()).await?;
148            let result = f.await;
149            return unit.finish(result).await;
150        }
151    }
152
153    let (holder, owned_pool) = match crate::company_scope::request_conn_only() {
154        // The request's own connection: session-level fence variables already hold on it.
155        Some(conn) => (conn, None),
156        None => {
157            let conn = pool.acquire().await?;
158            (Arc::new(Mutex::new(conn)), Some(pool.connect_options()))
159        }
160    };
161    let owned = owned_pool.is_some();
162    let unit = TxUnit::begin_on(holder.clone()).await?;
163    if owned {
164        // A transaction on a connection of its own carries nothing of the request: bind the
165        // scope of the task onto it, transaction-locally, so the fence and the audit actor hold.
166        let bound = {
167            let mut conn = holder.lock().await;
168            bind_task_scope(&mut conn).await
169        };
170        if let Err(e) = bound {
171            return unit.finish(Err(e.into())).await;
172        }
173    }
174    let tx = Arc::new(WriteTx {
175        conn: holder,
176        owned_pool,
177    });
178    let result = WRITE_TX.scope(tx, f).await;
179    unit.finish(result).await
180}
181
182/// Why a write transaction run through [`in_write_tx_result`] failed: the work's own error, or
183/// the database while beginning, binding the scope or committing.
184#[derive(Debug)]
185pub enum WriteTxFailure<E> {
186    /// The work returned this error; the transaction was rolled back.
187    Inner(E),
188    /// The database refused to begin, bind the scope or commit.
189    Db(sqlx::Error),
190}
191
192impl<E> From<sqlx::Error> for WriteTxFailure<E> {
193    fn from(e: sqlx::Error) -> Self {
194        Self::Db(e)
195    }
196}
197
198/// [`in_write_tx`] for work whose error type cannot absorb a `sqlx::Error`: the two kinds of
199/// failure stay apart in [`WriteTxFailure`], so a caller maps each as it must.
200pub async fn in_write_tx_result<F, T, E>(pool: &PgPool, f: F) -> Result<T, WriteTxFailure<E>>
201where
202    F: Future<Output = Result<T, E>>,
203{
204    in_write_tx(pool, async { f.await.map_err(WriteTxFailure::Inner) }).await
205}
206
207/// Bind the company or org scope of the current task, and its audit attribution, onto a
208/// connection that is inside a transaction.
209async fn bind_task_scope(conn: &mut PgConnection) -> Result<(), sqlx::Error> {
210    match crate::org_scope::current_org_scope() {
211        Some(scope) => crate::org_scope::bind_org_scope_on(conn, &scope).await,
212        None => crate::company_scope::bind_current_company(conn).await,
213    }
214}
215
216/// Run `f` as a savepoint of the active write transaction, so that a failure inside it, which
217/// Postgres would otherwise let abort the whole transaction, is undone on its own and can be
218/// handled. Outside a write transaction `f` simply runs.
219///
220/// For a probe that is allowed to fail: a lookup that tries one table spelling and then another,
221/// or a count whose failure means "cannot tell".
222pub(crate) async fn within_savepoint<T, F>(f: F) -> Result<T, sqlx::Error>
223where
224    F: Future<Output = Result<T, sqlx::Error>>,
225{
226    let Some(conn) = current_conn() else {
227        return f.await;
228    };
229    let unit = TxUnit::begin_on(conn).await?;
230    let result = f.await;
231    unit.finish(result).await
232}
233
234/// One transactional unit of a bulk write: begun here, finished with [`TxUnit::finish`].
235///
236/// Joins the active write transaction as a savepoint; otherwise begins a transaction on the
237/// request connection when one is bound, which carries the request's whole fence (company, org
238/// units, audit attribution); otherwise on a pool connection with the current company bound.
239/// Every id-list and row-list batch begins here, so none of them sees less than a single write.
240pub(crate) async fn begin_unit(pool: &PgPool) -> Result<TxUnit, sqlx::Error> {
241    if let Some(conn) = current_conn() {
242        return TxUnit::begin_on(conn).await;
243    }
244    if let Some(conn) = crate::company_scope::request_conn_only() {
245        return TxUnit::begin_on(conn).await;
246    }
247    let conn = pool.acquire().await?;
248    let unit = TxUnit::begin_on(Arc::new(Mutex::new(conn))).await?;
249    let bound = {
250        let mut conn = unit.conn().await;
251        crate::company_scope::bind_current_company(&mut conn).await
252    };
253    match bound {
254        Ok(()) => Ok(unit),
255        Err(e) => Err(unit.finish::<(), sqlx::Error>(Err(e)).await.unwrap_err()),
256    }
257}
258
259/// A begun transaction or savepoint on a held connection. Finish it with [`finish`](Self::finish):
260/// dropping it unfinished rolls it back from a spawned task, as cancellation requires.
261pub(crate) struct TxUnit {
262    holder: Arc<Mutex<PoolConnection<Postgres>>>,
263    /// The transaction depth before this unit began; the unit's own rollback returns to it.
264    base_depth: usize,
265    done: bool,
266}
267
268impl TxUnit {
269    async fn begin_on(holder: Arc<Mutex<PoolConnection<Postgres>>>) -> Result<Self, sqlx::Error> {
270        let base_depth = {
271            let mut conn = holder.lock().await;
272            let base = Tm::get_transaction_depth(&conn);
273            Tm::begin(&mut conn, None).await?;
274            base
275        };
276        Ok(Self {
277            holder,
278            base_depth,
279            done: false,
280        })
281    }
282
283    /// The unit's connection, locked for one statement or one short run of them.
284    pub(crate) async fn conn(&self) -> WriteTxConn<'_> {
285        WriteTxConn(self.holder.lock().await)
286    }
287
288    /// Commit on `Ok`, roll back on `Err`, and return `result` (a failed commit replaces an `Ok`).
289    pub(crate) async fn finish<T, E>(mut self, result: Result<T, E>) -> Result<T, E>
290    where
291        E: From<sqlx::Error>,
292    {
293        self.done = true;
294        let mut conn = self.holder.lock().await;
295        match result {
296            Ok(value) => {
297                // Postgres answers COMMIT on an aborted transaction with a quiet ROLLBACK, so a
298                // statement that failed and whose error a hook swallowed would otherwise read as
299                // a committed write. Only the outermost unit needs the probe: releasing a
300                // savepoint of an aborted transaction is refused on its own.
301                if self.base_depth == 0 {
302                    if let Err(e) = sqlx::query("SELECT 1").execute(&mut **conn).await {
303                        rollback_to(&mut conn, self.base_depth).await;
304                        return Err(e.into());
305                    }
306                }
307                match Tm::commit(&mut conn).await {
308                    Ok(()) => Ok(value),
309                    Err(e) => {
310                        // A failed COMMIT has already ended the transaction on the server; bring
311                        // the driver's own depth back in line, or close the connection if it
312                        // cannot.
313                        rollback_to(&mut conn, self.base_depth).await;
314                        Err(e.into())
315                    }
316                }
317            }
318            Err(e) => {
319                rollback_to(&mut conn, self.base_depth).await;
320                Err(e)
321            }
322        }
323    }
324}
325
326impl Drop for TxUnit {
327    fn drop(&mut self) {
328        if self.done {
329            return;
330        }
331        let holder = self.holder.clone();
332        let base = self.base_depth;
333        match tokio::runtime::Handle::try_current() {
334            Ok(runtime) => {
335                runtime.spawn(async move {
336                    let mut conn = holder.lock().await;
337                    rollback_to(&mut conn, base).await;
338                });
339            }
340            // No runtime left to run the rollback: never return the connection open.
341            Err(_) => {
342                if let Ok(mut conn) = holder.try_lock() {
343                    conn.close_on_drop();
344                }
345            }
346        }
347    }
348}
349
350/// Roll back until the connection is at `base` depth; close the connection if that fails.
351async fn rollback_to(conn: &mut PoolConnection<Postgres>, base: usize) {
352    while Tm::get_transaction_depth(conn) > base {
353        if let Err(e) = Tm::rollback(conn).await {
354            tracing::error!(
355                target: "backbone_orm::write_tx",
356                error = %e,
357                "a write transaction could not be rolled back; closing its connection instead of \
358                 returning it to the pool",
359            );
360            conn.close_on_drop();
361            return;
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn no_write_transaction_outside_a_scope() {
372        assert!(current_write_tx().is_none());
373        assert!(!in_write_tx_scope());
374        assert!(current_conn().is_none());
375    }
376
377    /// A transaction on a connection of its own carries the request's actor, so the audit triggers
378    /// of every row it writes name who made the change, and the actor dies with the transaction.
379    #[tokio::test]
380    async fn a_transaction_on_its_own_connection_carries_the_ambient_audit_actor() {
381        let Ok(dsn) = std::env::var("BACKBONE_ORM_RLS_DSN") else {
382            eprintln!("skipping: BACKBONE_ORM_RLS_DSN not set");
383            return;
384        };
385        let pool = sqlx::postgres::PgPoolOptions::new()
386            .max_connections(1)
387            .connect(&dsn)
388            .await
389            .unwrap();
390        let audit = crate::audit_context::RequestAuditContext::new("tx-actor");
391        let actor_inside: Result<String, sqlx::Error> =
392            crate::audit_context::with_request_audit(audit, async {
393                in_write_tx(&pool, async {
394                    let tx = current_write_tx().expect("a write transaction is active");
395                    let mut conn = tx.conn().await;
396                    sqlx::query_scalar("SELECT coalesce(current_setting('app.actor', true), '')")
397                        .fetch_one(&mut *conn)
398                        .await
399                })
400                .await
401            })
402            .await;
403        assert_eq!(actor_inside.unwrap(), "tx-actor");
404        let actor_after: String =
405            sqlx::query_scalar("SELECT coalesce(current_setting('app.actor', true), '')")
406                .fetch_one(&pool)
407                .await
408                .unwrap();
409        assert_eq!(
410            actor_after, "",
411            "the actor must not ride the pooled connection into the next request"
412        );
413    }
414}