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}