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    pub async fn with_savepoint<T, E, F>(&mut self, f: F) -> Result<Result<T, E>, sqlx::Error>
159    where
160        F: AsyncFnOnce(&mut SavepointOp<'_>) -> Result<T, E>,
161    {
162        let mut op = self.begin_savepoint().await?;
163        match f(&mut op).await {
164            Ok(value) => {
165                op.release().await?;
166                Ok(Ok(value))
167            }
168            Err(error) => {
169                op.rollback().await?;
170                Ok(Err(error))
171            }
172        }
173    }
174
175    /// Begins a `SAVEPOINT` scope explicitly.
176    ///
177    /// The escape hatch for when [`with_savepoint`](Self::with_savepoint)'s
178    /// closure form doesn't fit — the returned [`SavepointOp`] must be finished
179    /// with [`release`](SavepointOp::release) or
180    /// [`rollback`](SavepointOp::rollback). Dropping it rolls back.
181    pub async fn begin_savepoint(&mut self) -> Result<SavepointOp<'_>, sqlx::Error> {
182        SavepointOp::begin(
183            &mut self.tx,
184            self.clock.clone(),
185            self.now,
186            &mut self.commit_hooks,
187        )
188        .await
189    }
190
191    /// Commits the inner transaction.
192    ///
193    /// On the failure paths the commit hooks' [`on_rollback`] runs **after** the
194    /// transaction is definitively gone, so hook-side compensation never
195    /// contends with the dying transaction's own locks:
196    ///
197    /// - A later hook's `pre_commit` fails → the transaction is rolled back
198    ///   first, *then* the earlier (already-pre_committed) hooks are notified.
199    /// - The `COMMIT` itself fails → the transaction is over server-side either
200    ///   way, so the hooks are notified directly (their side effects must be
201    ///   idempotent against a possibly-landed commit).
202    ///
203    /// [`on_rollback`]: hooks::CommitHook::on_rollback
204    pub async fn commit(mut self) -> Result<(), sqlx::Error> {
205        let commit_hooks = self.commit_hooks.take().expect("no hooks");
206        match commit_hooks.execute_pre(&mut self).await {
207            Ok(post_hooks) => match self.tx.commit().await {
208                Ok(()) => {
209                    post_hooks.execute();
210                    Ok(())
211                }
212                Err(error) => {
213                    // The commit attempt is definitively over server-side (it
214                    // may have landed despite the error, or aborted) — there is
215                    // no rollback to issue. Fire `on_rollback` so hooks can
216                    // signal; their side effects must be idempotent against a
217                    // possibly-landed commit.
218                    post_hooks.execute_rollback();
219                    Err(error)
220                }
221            },
222            Err((error, executed)) => {
223                // A later hook's `pre_commit` failed. Roll back BEFORE
224                // signalling: the rollback is awaited so it has landed
225                // server-side before any `on_rollback` fires, so a hook's
226                // downstream compensation never contends with this dying
227                // transaction's own locks. A rollback error means the
228                // connection is being torn down (which aborts the transaction
229                // anyway) — swallow it and surface the original hook error.
230                let _ = self.tx.rollback().await;
231                executed.execute_rollback();
232                Err(error)
233            }
234        }
235    }
236
237    /// Gets a mutable handle to the inner transaction
238    pub fn tx_mut(&mut self) -> &mut Transaction<'c, db::Db> {
239        &mut self.tx
240    }
241}
242
243impl<'o> AtomicOperation for DbOp<'o> {
244    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
245        self.maybe_now()
246    }
247
248    fn clock(&self) -> &ClockHandle {
249        &self.clock
250    }
251
252    fn connection(&mut self) -> &mut db::Connection {
253        self.tx.connection()
254    }
255
256    fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
257        self.commit_hooks.as_mut().expect("no hooks").add(hook);
258        Ok(())
259    }
260
261    fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
262        self.commit_hooks.as_ref()?.get_last::<H>()
263    }
264
265    fn supports_hooks(&self) -> bool {
266        true
267    }
268}
269
270/// Equivileant of [`DbOp`] just that the time is guaranteed to be cached.
271///
272/// Used as a wrapper of a [`sqlx::Transaction`] with cached time of the transaction.
273pub struct DbOpWithTime<'c> {
274    inner: DbOp<'c>,
275    now: chrono::DateTime<chrono::Utc>,
276}
277
278impl<'c> DbOpWithTime<'c> {
279    fn new(mut inner: DbOp<'c>, time: chrono::DateTime<chrono::Utc>) -> Self {
280        inner.now = Some(time);
281        Self { inner, now: time }
282    }
283
284    /// The cached [`chrono::DateTime`]
285    pub fn now(&self) -> chrono::DateTime<chrono::Utc> {
286        self.now
287    }
288
289    /// Begins a nested transaction.
290    pub async fn begin(&mut self) -> Result<DbOpWithTime<'_>, sqlx::Error> {
291        Ok(DbOpWithTime::new(self.inner.begin().await?, self.now))
292    }
293
294    /// Runs `f` inside a `SAVEPOINT` — see [`DbOp::with_savepoint`].
295    ///
296    /// The cached time is propagated, so the [`SavepointOp`] reports it from
297    /// [`maybe_now`](AtomicOperation::maybe_now) and wrapping it in
298    /// [`OpWithTime`] is free.
299    pub async fn with_savepoint<T, E, F>(&mut self, f: F) -> Result<Result<T, E>, sqlx::Error>
300    where
301        F: AsyncFnOnce(&mut SavepointOp<'_>) -> Result<T, E>,
302    {
303        self.inner.with_savepoint(f).await
304    }
305
306    /// Begins a `SAVEPOINT` scope explicitly — see [`DbOp::begin_savepoint`].
307    pub async fn begin_savepoint(&mut self) -> Result<SavepointOp<'_>, sqlx::Error> {
308        self.inner.begin_savepoint().await
309    }
310
311    /// Commits the inner transaction.
312    pub async fn commit(self) -> Result<(), sqlx::Error> {
313        self.inner.commit().await
314    }
315
316    /// Gets a mutable handle to the inner transaction
317    pub fn tx_mut(&mut self) -> &mut Transaction<'c, db::Db> {
318        self.inner.tx_mut()
319    }
320}
321
322impl<'o> AtomicOperation for DbOpWithTime<'o> {
323    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
324        Some(self.now())
325    }
326
327    fn clock(&self) -> &ClockHandle {
328        self.inner.clock()
329    }
330
331    fn connection(&mut self) -> &mut db::Connection {
332        self.inner.connection()
333    }
334
335    fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
336        self.inner.add_commit_hook(hook)
337    }
338
339    fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
340        self.inner.commit_hook::<H>()
341    }
342
343    fn supports_hooks(&self) -> bool {
344        self.inner.supports_hooks()
345    }
346}
347
348impl<'o> AtomicOperationWithTime for DbOpWithTime<'o> {
349    fn now(&self) -> chrono::DateTime<chrono::Utc> {
350        self.now
351    }
352}
353
354/// Trait to signify we can make multiple consistent database roundtrips.
355///
356/// Its a stand in for [`&mut sqlx::Transaction<'_, DB>`](`sqlx::Transaction`).
357/// The reason for having a trait is to support custom types that wrap the inner
358/// transaction while providing additional functionality.
359///
360/// See [`DbOp`] or [`DbOpWithTime`].
361pub trait AtomicOperation: Send {
362    /// Function for querying when the operation is taking place - if it is cached.
363    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
364        None
365    }
366
367    /// Returns the clock handle for time operations.
368    ///
369    /// Default implementation returns the global clock handle.
370    fn clock(&self) -> &ClockHandle {
371        crate::clock::Clock::handle()
372    }
373
374    /// Returns the raw underlying connection.
375    /// The desired way to represent this would actually be as a GAT:
376    /// ```rust
377    /// trait AtomicOperation {
378    ///     type Executor<'c>: sqlx::PgExecutor<'c>
379    ///         where Self: 'c;
380    ///
381    ///     fn connection<'c>(&'c mut self) -> Self::Executor<'c>;
382    /// }
383    /// ```
384    ///
385    /// But GATs don't play well with `async_trait::async_trait` due to lifetime constraints
386    /// so we return the concrete [`&mut db::Connection`](`crate::db::Connection`) instead as a work around.
387    ///
388    /// Since this trait is generally applied to types that wrap a [`sqlx::Transaction`]
389    /// there is no variance in the return type - so its fine.
390    ///
391    /// Statements executed directly on the returned connection are **not**
392    /// annotated with trace context — use [`as_executor`](Self::as_executor)
393    /// unless raw connection access is required.
394    fn connection(&mut self) -> &mut db::Connection;
395
396    /// Returns the [`sqlx::Executor`] implementation that statements should be
397    /// executed through.
398    ///
399    /// The returned [`OneTimeExecutor`] annotates every statement with the
400    /// current span's `traceparent` SQL comment when the `tracing-context`
401    /// feature is enabled and a *sampled* span is active (see
402    /// [`crate::sql_commenter`]). Otherwise statements pass through untouched.
403    ///
404    /// Trade-off: the trace context makes annotated statement text unique, so
405    /// annotated statements bypass sqlx's per-connection prepared statement
406    /// cache (`persistent(false)`) — costing a server-side parse + plan per
407    /// execution. Un-annotated traffic keeps full prepared-statement reuse.
408    fn as_executor(&mut self) -> OneTimeExecutor<'_, &mut db::Connection> {
409        let now = self.maybe_now();
410        OneTimeExecutor::new(self.connection(), now)
411    }
412
413    /// Registers a commit hook that will run pre_commit before and post_commit after the transaction commits.
414    /// Returns Ok(()) if the hook was registered, Err(hook) if hooks are not supported.
415    fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
416        Err(hook)
417    }
418
419    /// Typed shared access to the currently-accumulating commit hook of type `H`,
420    /// if this operation supports commit hooks and one is registered.
421    /// Returns the hook a subsequent `add_commit_hook::<H>` call would merge into.
422    fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
423        None
424    }
425
426    /// Whether this operation supports commit hooks.
427    ///
428    /// `true` iff [`add_commit_hook`](Self::add_commit_hook) can register a hook
429    /// (i.e. the operation is backed by a [`DbOp`]-style commit-hook buffer, not
430    /// a bare [`sqlx::Transaction`]). Unlike [`commit_hook`](Self::commit_hook) —
431    /// whose `None` is ambiguous between "hooks unsupported" and "supported but
432    /// none registered yet" — this reports support directly, with no registration
433    /// attempt and no `&mut` access.
434    fn supports_hooks(&self) -> bool {
435        false
436    }
437}
438
439impl<'c> AtomicOperation for sqlx::Transaction<'c, db::Db> {
440    fn connection(&mut self) -> &mut db::Connection {
441        &mut *self
442    }
443}