Skip to main content

es_entity/operation/
mod.rs

1//! Handle execution of database operations and transactions.
2
3pub mod hooks;
4mod savepoint;
5mod with_time;
6
7use sqlx::{Acquire, Transaction};
8
9use crate::{clock::ClockHandle, db, one_time_executor::OneTimeExecutor};
10
11pub use savepoint::*;
12pub use with_time::*;
13
14/// Default return type of the derived EsRepo::begin_op().
15///
16/// Used as a wrapper of a [`sqlx::Transaction`] but can also cache the time at which the
17/// transaction is taking place.
18///
19/// When a manual clock is provided, the transaction will automatically cache that
20/// clock's time, enabling deterministic testing. This cached time will be used in all
21/// time-dependent operations.
22pub struct DbOp<'c> {
23    tx: Transaction<'c, db::Db>,
24    clock: ClockHandle,
25    now: Option<chrono::DateTime<chrono::Utc>>,
26    commit_hooks: Option<hooks::CommitHooks>,
27}
28
29impl<'c> DbOp<'c> {
30    fn new(
31        tx: Transaction<'c, db::Db>,
32        clock: ClockHandle,
33        time: Option<chrono::DateTime<chrono::Utc>>,
34    ) -> Self {
35        Self {
36            tx,
37            clock,
38            now: time,
39            commit_hooks: Some(hooks::CommitHooks::new()),
40        }
41    }
42
43    /// Initializes a transaction using the global clock.
44    ///
45    /// Delegates to [`init_with_clock`](Self::init_with_clock) using the global clock handle.
46    pub async fn init(pool: &db::Pool) -> Result<DbOp<'static>, sqlx::Error> {
47        Self::init_with_clock(pool, crate::clock::Clock::handle()).await
48    }
49
50    /// Initializes a transaction with the specified clock.
51    ///
52    /// If the clock is manual, its current time will be cached in the transaction.
53    pub async fn init_with_clock(
54        pool: &db::Pool,
55        clock: &ClockHandle,
56    ) -> Result<DbOp<'static>, sqlx::Error> {
57        let tx = pool.begin().await?;
58
59        // If a manual clock is provided, cache its time for consistent
60        // timestamps within the transaction.
61        let time = clock.manual_now();
62
63        Ok(DbOp::new(tx, clock.clone(), time))
64    }
65
66    /// Transitions to a [`DbOpWithTime`] with the given time cached.
67    pub fn with_time(self, time: chrono::DateTime<chrono::Utc>) -> DbOpWithTime<'c> {
68        DbOpWithTime::new(self, time)
69    }
70
71    /// Transitions to a [`DbOpWithTime`] using the clock.
72    ///
73    /// Uses cached time if present, otherwise uses the clock's current time.
74    pub fn with_clock_time(self) -> DbOpWithTime<'c> {
75        let time = self.now.unwrap_or_else(|| self.clock.now());
76        DbOpWithTime::new(self, time)
77    }
78
79    /// Transitions to a [`DbOpWithTime`] using the database time.
80    ///
81    /// Priority order:
82    /// 1. Cached time if present
83    /// 2. Manual clock time if the clock is manual
84    /// 3. Database time via `SELECT NOW()`
85    pub async fn with_db_time(mut self) -> Result<DbOpWithTime<'c>, sqlx::Error> {
86        let time = if let Some(time) = self.now {
87            time
88        } else if let Some(manual_time) = self.clock.manual_now() {
89            manual_time
90        } else {
91            db::database_now(&mut *self.tx).await?
92        };
93
94        Ok(DbOpWithTime::new(self, time))
95    }
96
97    /// Returns the optionally cached [`chrono::DateTime`]
98    pub fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
99        self.now
100    }
101
102    /// Begins a nested transaction.
103    pub async fn begin(&mut self) -> Result<DbOp<'_>, sqlx::Error> {
104        Ok(DbOp::new(
105            self.tx.begin().await?,
106            self.clock.clone(),
107            self.now,
108        ))
109    }
110
111    /// Runs `f` inside a `SAVEPOINT`, keeping its work on `Ok` and undoing it on `Err`.
112    ///
113    /// This is the building block for processing a batch of items in **one**
114    /// transaction — one `COMMIT`, one WAL flush — while still isolating each
115    /// item's failure. An item that errors unwinds only its own writes and
116    /// staged commit hooks; the transaction stays usable, so the loop continues
117    /// and its healthy items still commit.
118    ///
119    /// # Two layers of `Result`
120    ///
121    /// - The **outer** `Err(sqlx::Error)` means the savepoint machinery itself
122    ///   failed (or the error was never savepoint-recoverable, e.g. the
123    ///   connection died). The parent operation is in an indeterminate state:
124    ///   abandon it, don't commit.
125    /// - The **inner** `Err(E)` is the item's own failure, already rolled back
126    ///   cleanly. Record the outcome and keep going.
127    ///
128    /// If the closure fails *and* the rollback fails, the rollback error is
129    /// returned as the outer `Err` and the item's error is dropped — the
130    /// poisoned-transaction signal is what the caller must act on.
131    ///
132    /// # Collecting per-item outcomes
133    ///
134    /// The closure may borrow from its environment, but host-side mutations do
135    /// **not** unwind with the savepoint. Return the item's verdict through
136    /// `Ok`/`Err` and record it outside, where the outcome is authoritative:
137    ///
138    /// ```rust,ignore
139    /// let mut op = DbOp::init(&pool).await?;
140    /// let mut outcomes = Vec::with_capacity(items.len());
141    ///
142    /// for item in items {
143    ///     // `?` here: infra failure — abandon the whole batch.
144    ///     let res = op
145    ///         .with_savepoint(async |op| self.process_in_op(op, item).await)
146    ///         .await?;
147    ///
148    ///     outcomes.push(match res {
149    ///         Ok(()) => Outcome::Complete,
150    ///         Err(e) => Outcome::Retry(e),
151    ///     });
152    /// }
153    ///
154    /// op.commit().await?;
155    /// ```
156    ///
157    /// See [`SavepointOp`] for how commit hooks are staged and folded in.
158    ///
159    /// Kept as an inherent method so existing call sites need no import; the
160    /// behaviour lives in [`SavepointOperation::with_savepoint`], which every
161    /// [`AtomicOperation`] gets.
162    pub async fn with_savepoint<T, E, F>(&mut self, f: F) -> Result<Result<T, E>, sqlx::Error>
163    where
164        F: AsyncFnOnce(&mut SavepointOp<'_>) -> Result<T, E>,
165    {
166        SavepointOperation::with_savepoint(self, f).await
167    }
168
169    /// Begins a `SAVEPOINT` scope explicitly.
170    ///
171    /// The escape hatch for when [`with_savepoint`](Self::with_savepoint)'s
172    /// closure form doesn't fit — the returned [`SavepointOp`] must be finished
173    /// with [`release`](SavepointOp::release) or
174    /// [`rollback`](SavepointOp::rollback). Dropping it rolls back.
175    pub async fn begin_savepoint(&mut self) -> Result<SavepointOp<'_>, sqlx::Error> {
176        SavepointOperation::begin_savepoint(self).await
177    }
178
179    /// Commits the inner transaction.
180    ///
181    /// On the failure paths the commit hooks' [`on_rollback`] runs **after** the
182    /// transaction is definitively gone, so hook-side compensation never
183    /// contends with the dying transaction's own locks:
184    ///
185    /// - A later hook's `pre_commit` fails → the transaction is rolled back
186    ///   first, *then* the earlier (already-pre_committed) hooks are notified.
187    /// - The `COMMIT` itself fails → the transaction is over server-side either
188    ///   way, so the hooks are notified directly (their side effects must be
189    ///   idempotent against a possibly-landed commit).
190    ///
191    /// [`on_rollback`]: hooks::CommitHook::on_rollback
192    pub async fn commit(mut self) -> Result<(), sqlx::Error> {
193        let commit_hooks = self.commit_hooks.take().expect("no hooks");
194        match commit_hooks.execute_pre(&mut self).await {
195            Ok(post_hooks) => match self.tx.commit().await {
196                Ok(()) => {
197                    post_hooks.execute();
198                    Ok(())
199                }
200                Err(error) => {
201                    // The commit attempt is definitively over server-side (it
202                    // may have landed despite the error, or aborted) — there is
203                    // no rollback to issue. Fire `on_rollback` so hooks can
204                    // signal; their side effects must be idempotent against a
205                    // possibly-landed commit.
206                    post_hooks.execute_rollback();
207                    Err(error)
208                }
209            },
210            Err((error, executed)) => {
211                // A later hook's `pre_commit` failed. Roll back BEFORE
212                // signalling: the rollback is awaited so it has landed
213                // server-side before any `on_rollback` fires, so a hook's
214                // downstream compensation never contends with this dying
215                // transaction's own locks. A rollback error means the
216                // connection is being torn down (which aborts the transaction
217                // anyway) — swallow it and surface the original hook error.
218                let _ = self.tx.rollback().await;
219                executed.execute_rollback();
220                Err(error)
221            }
222        }
223    }
224
225    /// Gets a mutable handle to the inner transaction
226    pub fn tx_mut(&mut self) -> &mut Transaction<'c, db::Db> {
227        &mut self.tx
228    }
229}
230
231impl<'o> AtomicOperation for DbOp<'o> {
232    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
233        self.maybe_now()
234    }
235
236    fn clock(&self) -> &ClockHandle {
237        &self.clock
238    }
239
240    fn connection(&mut self) -> &mut db::Connection {
241        self.tx.connection()
242    }
243
244    fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
245        self.commit_hooks.as_mut().expect("no hooks").add(hook);
246        Ok(())
247    }
248
249    fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
250        self.commit_hooks.as_ref()?.get_last::<H>()
251    }
252
253    fn supports_hooks(&self) -> bool {
254        true
255    }
256
257    /// `tx` and `commit_hooks` are disjoint fields, so both can be borrowed
258    /// mutably in one expression — the borrow split that a pair of `&mut self`
259    /// accessors could not express, which is the whole reason this method
260    /// returns both halves at once.
261    fn savepoint_parts(&mut self) -> (&mut db::Connection, savepoint::HookSlot<'_>) {
262        (
263            self.tx.connection(),
264            savepoint::HookSlot(self.commit_hooks.as_mut()),
265        )
266    }
267}
268
269/// Equivileant of [`DbOp`] just that the time is guaranteed to be cached.
270///
271/// Used as a wrapper of a [`sqlx::Transaction`] with cached time of the transaction.
272pub struct DbOpWithTime<'c> {
273    inner: DbOp<'c>,
274    now: chrono::DateTime<chrono::Utc>,
275}
276
277impl<'c> DbOpWithTime<'c> {
278    fn new(mut inner: DbOp<'c>, time: chrono::DateTime<chrono::Utc>) -> Self {
279        inner.now = Some(time);
280        Self { inner, now: time }
281    }
282
283    /// The cached [`chrono::DateTime`]
284    pub fn now(&self) -> chrono::DateTime<chrono::Utc> {
285        self.now
286    }
287
288    /// Begins a nested transaction.
289    pub async fn begin(&mut self) -> Result<DbOpWithTime<'_>, sqlx::Error> {
290        Ok(DbOpWithTime::new(self.inner.begin().await?, self.now))
291    }
292
293    /// Runs `f` inside a `SAVEPOINT` — see [`DbOp::with_savepoint`].
294    ///
295    /// The cached time is propagated, so the [`SavepointOp`] reports it from
296    /// [`maybe_now`](AtomicOperation::maybe_now) and wrapping it in
297    /// [`OpWithTime`] is free.
298    pub async fn with_savepoint<T, E, F>(&mut self, f: F) -> Result<Result<T, E>, sqlx::Error>
299    where
300        F: AsyncFnOnce(&mut SavepointOp<'_>) -> Result<T, E>,
301    {
302        SavepointOperation::with_savepoint(self, f).await
303    }
304
305    /// Begins a `SAVEPOINT` scope explicitly — see [`DbOp::begin_savepoint`].
306    pub async fn begin_savepoint(&mut self) -> Result<SavepointOp<'_>, sqlx::Error> {
307        SavepointOperation::begin_savepoint(self).await
308    }
309
310    /// Commits the inner transaction.
311    pub async fn commit(self) -> Result<(), sqlx::Error> {
312        self.inner.commit().await
313    }
314
315    /// Gets a mutable handle to the inner transaction
316    pub fn tx_mut(&mut self) -> &mut Transaction<'c, db::Db> {
317        self.inner.tx_mut()
318    }
319}
320
321impl<'o> AtomicOperation for DbOpWithTime<'o> {
322    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
323        Some(self.now())
324    }
325
326    fn clock(&self) -> &ClockHandle {
327        self.inner.clock()
328    }
329
330    fn connection(&mut self) -> &mut db::Connection {
331        self.inner.connection()
332    }
333
334    fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
335        self.inner.add_commit_hook(hook)
336    }
337
338    fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
339        self.inner.commit_hook::<H>()
340    }
341
342    fn supports_hooks(&self) -> bool {
343        self.inner.supports_hooks()
344    }
345
346    fn savepoint_parts(&mut self) -> (&mut db::Connection, savepoint::HookSlot<'_>) {
347        self.inner.savepoint_parts()
348    }
349}
350
351impl<'o> AtomicOperationWithTime for DbOpWithTime<'o> {
352    fn now(&self) -> chrono::DateTime<chrono::Utc> {
353        self.now
354    }
355}
356
357/// Trait to signify we can make multiple consistent database roundtrips.
358///
359/// Its a stand in for [`&mut sqlx::Transaction<'_, DB>`](`sqlx::Transaction`).
360/// The reason for having a trait is to support custom types that wrap the inner
361/// transaction while providing additional functionality.
362///
363/// See [`DbOp`] or [`DbOpWithTime`].
364pub trait AtomicOperation: Send {
365    /// Function for querying when the operation is taking place - if it is cached.
366    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
367        None
368    }
369
370    /// Returns the clock handle for time operations.
371    ///
372    /// Default implementation returns the global clock handle.
373    fn clock(&self) -> &ClockHandle {
374        crate::clock::Clock::handle()
375    }
376
377    /// Returns the raw underlying connection.
378    /// The desired way to represent this would actually be as a GAT:
379    /// ```rust
380    /// trait AtomicOperation {
381    ///     type Executor<'c>: sqlx::PgExecutor<'c>
382    ///         where Self: 'c;
383    ///
384    ///     fn connection<'c>(&'c mut self) -> Self::Executor<'c>;
385    /// }
386    /// ```
387    ///
388    /// But GATs don't play well with `async_trait::async_trait` due to lifetime constraints
389    /// so we return the concrete [`&mut db::Connection`](`crate::db::Connection`) instead as a work around.
390    ///
391    /// Since this trait is generally applied to types that wrap a [`sqlx::Transaction`]
392    /// there is no variance in the return type - so its fine.
393    ///
394    /// Statements executed directly on the returned connection are **not**
395    /// annotated with trace context — use [`as_executor`](Self::as_executor)
396    /// unless raw connection access is required.
397    fn connection(&mut self) -> &mut db::Connection;
398
399    /// Returns the [`sqlx::Executor`] implementation that statements should be
400    /// executed through.
401    ///
402    /// The returned [`OneTimeExecutor`] annotates every statement with the
403    /// current span's `traceparent` SQL comment when the `tracing-context`
404    /// feature is enabled and a *sampled* span is active (see
405    /// [`crate::sql_commenter`]). Otherwise statements pass through untouched.
406    ///
407    /// Trade-off: the trace context makes annotated statement text unique, so
408    /// annotated statements bypass sqlx's per-connection prepared statement
409    /// cache (`persistent(false)`) — costing a server-side parse + plan per
410    /// execution. Un-annotated traffic keeps full prepared-statement reuse.
411    fn as_executor(&mut self) -> OneTimeExecutor<'_, &mut db::Connection> {
412        let now = self.maybe_now();
413        OneTimeExecutor::new(self.connection(), now)
414    }
415
416    /// Registers a commit hook that will run pre_commit before and post_commit after the transaction commits.
417    /// Returns Ok(()) if the hook was registered, Err(hook) if hooks are not supported.
418    fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
419        Err(hook)
420    }
421
422    /// Typed shared access to the currently-accumulating commit hook of type `H`,
423    /// if this operation supports commit hooks and one is registered.
424    /// Returns the hook a subsequent `add_commit_hook::<H>` call would merge into.
425    fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
426        None
427    }
428
429    /// Whether this operation supports commit hooks.
430    ///
431    /// `true` iff [`add_commit_hook`](Self::add_commit_hook) can register a hook
432    /// (i.e. the operation is backed by a [`DbOp`]-style commit-hook buffer, not
433    /// a bare [`sqlx::Transaction`]). Unlike [`commit_hook`](Self::commit_hook) —
434    /// whose `None` is ambiguous between "hooks unsupported" and "supported but
435    /// none registered yet" — this reports support directly, with no registration
436    /// attempt and no `&mut` access.
437    fn supports_hooks(&self) -> bool {
438        false
439    }
440
441    /// Simultaneous access to the connection **and** the commit-hook buffer a
442    /// nested `SAVEPOINT` folds into when released. Implementing this is the
443    /// only thing an operation must do to get the whole of
444    /// [`SavepointOperation`] — `with_savepoint`, `begin_savepoint`, and
445    /// arbitrary-depth nesting — for free.
446    ///
447    /// Returning both halves together is not a convenience: it is a
448    /// requirement. A [`SavepointOp`] holds a `&mut` to the connection *and* a
449    /// `&mut` to the hook buffer for its entire lifetime, and two separate
450    /// `&mut self` accessors can never be live at the same time. Returning the
451    /// pair lets an implementor split the borrow across its own disjoint fields
452    /// — legal inside the type, impossible across a trait boundary otherwise:
453    ///
454    /// ```rust,ignore
455    /// fn savepoint_parts(&mut self) -> (&mut db::Connection, HookSlot<'_>) {
456    ///     // `tx` and `commit_hooks` are different fields, so this is fine.
457    ///     (self.tx.connection(), HookSlot::root(&mut self.commit_hooks))
458    /// }
459    /// ```
460    ///
461    /// An operation that wraps another should **forward** to the inner one, so
462    /// hook support is preserved:
463    ///
464    /// ```rust,ignore
465    /// fn savepoint_parts(&mut self) -> (&mut db::Connection, HookSlot<'_>) {
466    ///     self.inner.savepoint_parts()
467    /// }
468    /// ```
469    ///
470    /// An operation with no hook buffer of its own returns
471    /// [`HookSlot::unsupported`] — savepoints still work at the database level,
472    /// hook registration inside them refuses, and callers fall back to
473    /// [`force_execute_pre_commit`](hooks::CommitHook::force_execute_pre_commit)
474    /// exactly as they already do on the operation itself.
475    ///
476    /// The default reports no hook buffer, which is correct for an operation
477    /// that has none — a bare [`sqlx::Transaction`] needs nothing else.
478    ///
479    /// It is **not** correct for an operation that wraps one which does. Such a
480    /// type must override this — the
481    /// [`delegate_atomic_operation!`](crate::delegate_atomic_operation) macro
482    /// does it for you — because the default would otherwise refuse hooks inside every
483    /// savepoint taken through it while the wrapped operation supports them
484    /// fine. That mismatch is caught rather than left silent:
485    /// [`begin_savepoint`](SavepointOperation::begin_savepoint) fails with a
486    /// protocol error when an operation reports
487    /// [`supports_hooks`](Self::supports_hooks) but yields an unsupported slot,
488    /// which is exactly the shape "delegated `supports_hooks`, inherited
489    /// `savepoint_parts`" produces.
490    fn savepoint_parts(&mut self) -> (&mut db::Connection, savepoint::HookSlot<'_>) {
491        (self.connection(), savepoint::HookSlot::unsupported())
492    }
493}
494
495/// A bare transaction carries no commit-hook buffer, so the defaulted
496/// `savepoint_parts` is already right: savepoints work at the database level and
497/// refuse hook registration.
498impl<'c> AtomicOperation for sqlx::Transaction<'c, db::Db> {
499    fn connection(&mut self) -> &mut db::Connection {
500        &mut *self
501    }
502}