Skip to main content

es_entity/operation/
mod.rs

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