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 `use_request_conn` and one is bound (the shape `bulk_create` always
238/// had), or on a pool connection with the current company bound (the shape of the other bulk
239/// paths). Behaviour without a write transaction is unchanged.
240pub(crate) async fn begin_unit(
241    pool: &PgPool,
242    use_request_conn: bool,
243) -> Result<TxUnit, sqlx::Error> {
244    if let Some(conn) = current_conn() {
245        return TxUnit::begin_on(conn).await;
246    }
247    if use_request_conn {
248        if let Some(conn) = crate::company_scope::request_conn_only() {
249            return TxUnit::begin_on(conn).await;
250        }
251    }
252    let conn = pool.acquire().await?;
253    let unit = TxUnit::begin_on(Arc::new(Mutex::new(conn))).await?;
254    let bound = {
255        let mut conn = unit.conn().await;
256        crate::company_scope::bind_current_company(&mut conn).await
257    };
258    match bound {
259        Ok(()) => Ok(unit),
260        Err(e) => Err(unit.finish::<(), sqlx::Error>(Err(e)).await.unwrap_err()),
261    }
262}
263
264/// A begun transaction or savepoint on a held connection. Finish it with [`finish`](Self::finish):
265/// dropping it unfinished rolls it back from a spawned task, as cancellation requires.
266pub(crate) struct TxUnit {
267    holder: Arc<Mutex<PoolConnection<Postgres>>>,
268    /// The transaction depth before this unit began; the unit's own rollback returns to it.
269    base_depth: usize,
270    done: bool,
271}
272
273impl TxUnit {
274    async fn begin_on(holder: Arc<Mutex<PoolConnection<Postgres>>>) -> Result<Self, sqlx::Error> {
275        let base_depth = {
276            let mut conn = holder.lock().await;
277            let base = Tm::get_transaction_depth(&conn);
278            Tm::begin(&mut conn, None).await?;
279            base
280        };
281        Ok(Self {
282            holder,
283            base_depth,
284            done: false,
285        })
286    }
287
288    /// The unit's connection, locked for one statement or one short run of them.
289    pub(crate) async fn conn(&self) -> WriteTxConn<'_> {
290        WriteTxConn(self.holder.lock().await)
291    }
292
293    /// Commit on `Ok`, roll back on `Err`, and return `result` (a failed commit replaces an `Ok`).
294    pub(crate) async fn finish<T, E>(mut self, result: Result<T, E>) -> Result<T, E>
295    where
296        E: From<sqlx::Error>,
297    {
298        self.done = true;
299        let mut conn = self.holder.lock().await;
300        match result {
301            Ok(value) => {
302                // Postgres answers COMMIT on an aborted transaction with a quiet ROLLBACK, so a
303                // statement that failed and whose error a hook swallowed would otherwise read as
304                // a committed write. Only the outermost unit needs the probe: releasing a
305                // savepoint of an aborted transaction is refused on its own.
306                if self.base_depth == 0 {
307                    if let Err(e) = sqlx::query("SELECT 1").execute(&mut **conn).await {
308                        rollback_to(&mut conn, self.base_depth).await;
309                        return Err(e.into());
310                    }
311                }
312                match Tm::commit(&mut conn).await {
313                    Ok(()) => Ok(value),
314                    Err(e) => {
315                        // A failed COMMIT has already ended the transaction on the server; bring
316                        // the driver's own depth back in line, or close the connection if it
317                        // cannot.
318                        rollback_to(&mut conn, self.base_depth).await;
319                        Err(e.into())
320                    }
321                }
322            }
323            Err(e) => {
324                rollback_to(&mut conn, self.base_depth).await;
325                Err(e)
326            }
327        }
328    }
329}
330
331impl Drop for TxUnit {
332    fn drop(&mut self) {
333        if self.done {
334            return;
335        }
336        let holder = self.holder.clone();
337        let base = self.base_depth;
338        match tokio::runtime::Handle::try_current() {
339            Ok(runtime) => {
340                runtime.spawn(async move {
341                    let mut conn = holder.lock().await;
342                    rollback_to(&mut conn, base).await;
343                });
344            }
345            // No runtime left to run the rollback: never return the connection open.
346            Err(_) => {
347                if let Ok(mut conn) = holder.try_lock() {
348                    conn.close_on_drop();
349                }
350            }
351        }
352    }
353}
354
355/// Roll back until the connection is at `base` depth; close the connection if that fails.
356async fn rollback_to(conn: &mut PoolConnection<Postgres>, base: usize) {
357    while Tm::get_transaction_depth(conn) > base {
358        if let Err(e) = Tm::rollback(conn).await {
359            tracing::error!(
360                target: "backbone_orm::write_tx",
361                error = %e,
362                "a write transaction could not be rolled back; closing its connection instead of \
363                 returning it to the pool",
364            );
365            conn.close_on_drop();
366            return;
367        }
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn no_write_transaction_outside_a_scope() {
377        assert!(current_write_tx().is_none());
378        assert!(!in_write_tx_scope());
379        assert!(current_conn().is_none());
380    }
381
382    /// A transaction on a connection of its own carries the request's actor, so the audit triggers
383    /// of every row it writes name who made the change, and the actor dies with the transaction.
384    #[tokio::test]
385    async fn a_transaction_on_its_own_connection_carries_the_ambient_audit_actor() {
386        let Ok(dsn) = std::env::var("BACKBONE_ORM_RLS_DSN") else {
387            eprintln!("skipping: BACKBONE_ORM_RLS_DSN not set");
388            return;
389        };
390        let pool = sqlx::postgres::PgPoolOptions::new()
391            .max_connections(1)
392            .connect(&dsn)
393            .await
394            .unwrap();
395        let audit = crate::audit_context::RequestAuditContext::new("tx-actor");
396        let actor_inside: Result<String, sqlx::Error> =
397            crate::audit_context::with_request_audit(audit, async {
398                in_write_tx(&pool, async {
399                    let tx = current_write_tx().expect("a write transaction is active");
400                    let mut conn = tx.conn().await;
401                    sqlx::query_scalar("SELECT coalesce(current_setting('app.actor', true), '')")
402                        .fetch_one(&mut *conn)
403                        .await
404                })
405                .await
406            })
407            .await;
408        assert_eq!(actor_inside.unwrap(), "tx-actor");
409        let actor_after: String =
410            sqlx::query_scalar("SELECT coalesce(current_setting('app.actor', true), '')")
411                .fetch_one(&pool)
412                .await
413                .unwrap();
414        assert_eq!(
415            actor_after, "",
416            "the actor must not ride the pooled connection into the next request"
417        );
418    }
419}