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_dyn(
247        &mut self,
248        type_id: std::any::TypeId,
249        hook: Box<dyn hooks::DynHook>,
250    ) -> Result<(), Box<dyn hooks::DynHook>> {
251        self.commit_hooks
252            .as_mut()
253            .expect("no hooks")
254            .push_or_merge(type_id, hook);
255        Ok(())
256    }
257
258    fn commit_hook_dyn(&self, type_id: std::any::TypeId) -> Option<&dyn hooks::DynHook> {
259        self.commit_hooks.as_ref()?.get_last_dyn(type_id)
260    }
261
262    fn supports_hooks(&self) -> bool {
263        true
264    }
265
266    /// `tx` and `commit_hooks` are disjoint fields, so both can be borrowed
267    /// mutably in one expression — the borrow split that a pair of `&mut self`
268    /// accessors could not express, which is the whole reason this method
269    /// returns both halves at once.
270    fn savepoint_parts(&mut self) -> (&mut db::Connection, savepoint::HookSlot<'_>) {
271        (
272            self.tx.connection(),
273            savepoint::HookSlot(self.commit_hooks.as_mut()),
274        )
275    }
276}
277
278/// Equivileant of [`DbOp`] just that the time is guaranteed to be cached.
279///
280/// Used as a wrapper of a [`sqlx::Transaction`] with cached time of the transaction.
281pub struct DbOpWithTime<'c> {
282    inner: DbOp<'c>,
283    now: chrono::DateTime<chrono::Utc>,
284}
285
286impl<'c> DbOpWithTime<'c> {
287    fn new(mut inner: DbOp<'c>, time: chrono::DateTime<chrono::Utc>) -> Self {
288        inner.now = Some(time);
289        Self { inner, now: time }
290    }
291
292    /// The cached [`chrono::DateTime`]
293    pub fn now(&self) -> chrono::DateTime<chrono::Utc> {
294        self.now
295    }
296
297    /// Begins a nested transaction.
298    pub async fn begin(&mut self) -> Result<DbOpWithTime<'_>, sqlx::Error> {
299        Ok(DbOpWithTime::new(self.inner.begin().await?, self.now))
300    }
301
302    /// Runs `f` inside a `SAVEPOINT` — see [`DbOp::with_savepoint`].
303    ///
304    /// The cached time is propagated, so the [`SavepointOp`] reports it from
305    /// [`maybe_now`](AtomicOperation::maybe_now) and wrapping it in
306    /// [`OpWithTime`] is free.
307    pub async fn with_savepoint<T, E, F>(&mut self, f: F) -> Result<Result<T, E>, sqlx::Error>
308    where
309        F: AsyncFnOnce(&mut SavepointOp<'_>) -> Result<T, E>,
310    {
311        SavepointOperation::with_savepoint(self, f).await
312    }
313
314    /// Begins a `SAVEPOINT` scope explicitly — see [`DbOp::begin_savepoint`].
315    pub async fn begin_savepoint(&mut self) -> Result<SavepointOp<'_>, sqlx::Error> {
316        SavepointOperation::begin_savepoint(self).await
317    }
318
319    /// Commits the inner transaction.
320    pub async fn commit(self) -> Result<(), sqlx::Error> {
321        self.inner.commit().await
322    }
323
324    /// Gets a mutable handle to the inner transaction
325    pub fn tx_mut(&mut self) -> &mut Transaction<'c, db::Db> {
326        self.inner.tx_mut()
327    }
328}
329
330impl<'o> AtomicOperation for DbOpWithTime<'o> {
331    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
332        Some(self.now())
333    }
334
335    fn clock(&self) -> &ClockHandle {
336        self.inner.clock()
337    }
338
339    fn connection(&mut self) -> &mut db::Connection {
340        self.inner.connection()
341    }
342
343    fn add_commit_hook_dyn(
344        &mut self,
345        type_id: std::any::TypeId,
346        hook: Box<dyn hooks::DynHook>,
347    ) -> Result<(), Box<dyn hooks::DynHook>> {
348        self.inner.add_commit_hook_dyn(type_id, hook)
349    }
350
351    fn commit_hook_dyn(&self, type_id: std::any::TypeId) -> Option<&dyn hooks::DynHook> {
352        self.inner.commit_hook_dyn(type_id)
353    }
354
355    fn supports_hooks(&self) -> bool {
356        self.inner.supports_hooks()
357    }
358
359    fn savepoint_parts(&mut self) -> (&mut db::Connection, savepoint::HookSlot<'_>) {
360        self.inner.savepoint_parts()
361    }
362}
363
364impl<'o> AtomicOperationWithTime for DbOpWithTime<'o> {
365    fn now(&self) -> chrono::DateTime<chrono::Utc> {
366        self.now
367    }
368}
369
370/// Trait to signify we can make multiple consistent database roundtrips.
371///
372/// Its a stand in for [`&mut sqlx::Transaction<'_, DB>`](`sqlx::Transaction`).
373/// The reason for having a trait is to support custom types that wrap the inner
374/// transaction while providing additional functionality.
375///
376/// See [`DbOp`] or [`DbOpWithTime`].
377pub trait AtomicOperation: Send {
378    /// Function for querying when the operation is taking place - if it is cached.
379    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
380        None
381    }
382
383    /// Returns the clock handle for time operations.
384    ///
385    /// Default implementation returns the global clock handle.
386    fn clock(&self) -> &ClockHandle {
387        crate::clock::Clock::handle()
388    }
389
390    /// Returns the raw underlying connection.
391    /// The desired way to represent this would actually be as a GAT:
392    /// ```rust
393    /// trait AtomicOperation {
394    ///     type Executor<'c>: sqlx::PgExecutor<'c>
395    ///         where Self: 'c;
396    ///
397    ///     fn connection<'c>(&'c mut self) -> Self::Executor<'c>;
398    /// }
399    /// ```
400    ///
401    /// But GATs don't play well with `async_trait::async_trait` due to lifetime constraints
402    /// so we return the concrete [`&mut db::Connection`](`crate::db::Connection`) instead as a work around.
403    ///
404    /// Since this trait is generally applied to types that wrap a [`sqlx::Transaction`]
405    /// there is no variance in the return type - so its fine.
406    ///
407    /// Statements executed directly on the returned connection are **not**
408    /// annotated with trace context — use [`as_executor`](Self::as_executor)
409    /// unless raw connection access is required.
410    fn connection(&mut self) -> &mut db::Connection;
411
412    /// Returns the [`sqlx::Executor`] implementation that statements should be
413    /// executed through.
414    ///
415    /// The returned [`OneTimeExecutor`] annotates every statement with the
416    /// current span's `traceparent` SQL comment when the `tracing-context`
417    /// feature is enabled and a *sampled* span is active (see
418    /// [`crate::sql_commenter`]). Otherwise statements pass through untouched.
419    ///
420    /// Trade-off: the trace context makes annotated statement text unique, so
421    /// annotated statements bypass sqlx's per-connection prepared statement
422    /// cache (`persistent(false)`) — costing a server-side parse + plan per
423    /// execution. Un-annotated traffic keeps full prepared-statement reuse.
424    fn as_executor(&mut self) -> OneTimeExecutor<'_, &mut db::Connection> {
425        let now = self.maybe_now();
426        OneTimeExecutor::new(self.connection(), now)
427    }
428
429    /// Object-safe, type-erased form of [`add_commit_hook`](Self::add_commit_hook).
430    fn add_commit_hook_dyn(
431        &mut self,
432        _type_id: std::any::TypeId,
433        hook: Box<dyn hooks::DynHook>,
434    ) -> Result<(), Box<dyn hooks::DynHook>> {
435        Err(hook)
436    }
437
438    /// Object-safe, type-erased form of [`commit_hook`](Self::commit_hook).
439    fn commit_hook_dyn(&self, _type_id: std::any::TypeId) -> Option<&dyn hooks::DynHook> {
440        None
441    }
442
443    /// Registers a commit hook that will run pre_commit before and post_commit after the transaction commits.
444    /// Returns Ok(()) if the hook was registered, Err(hook) if hooks are not supported.
445    fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H>
446    where
447        Self: Sized,
448    {
449        self.add_commit_hook_dyn(std::any::TypeId::of::<H>(), Box::new(hook))
450            .map_err(|hook| {
451                *hook
452                    .into_any()
453                    .downcast::<H>()
454                    .unwrap_or_else(|_| panic!("hook type mismatch"))
455            })
456    }
457
458    /// Typed shared access to the currently-accumulating commit hook of type `H`,
459    /// if this operation supports commit hooks and one is registered.
460    /// Returns the hook a subsequent `add_commit_hook::<H>` call would merge into.
461    fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H>
462    where
463        Self: Sized,
464    {
465        self.commit_hook_dyn(std::any::TypeId::of::<H>())?
466            .as_any()
467            .downcast_ref::<H>()
468    }
469
470    /// Whether this operation supports commit hooks.
471    ///
472    /// `true` iff [`add_commit_hook`](Self::add_commit_hook) can register a hook
473    /// (i.e. the operation is backed by a [`DbOp`]-style commit-hook buffer, not
474    /// a bare [`sqlx::Transaction`]). Unlike [`commit_hook`](Self::commit_hook) —
475    /// whose `None` is ambiguous between "hooks unsupported" and "supported but
476    /// none registered yet" — this reports support directly, with no registration
477    /// attempt and no `&mut` access.
478    fn supports_hooks(&self) -> bool {
479        false
480    }
481
482    /// Simultaneous access to the connection **and** the commit-hook buffer a
483    /// nested `SAVEPOINT` folds into when released. Implementing this is the
484    /// only thing an operation must do to get the whole of
485    /// [`SavepointOperation`] — `with_savepoint`, `begin_savepoint`, and
486    /// arbitrary-depth nesting — for free.
487    ///
488    /// Returning both halves together is not a convenience: it is a
489    /// requirement. A [`SavepointOp`] holds a `&mut` to the connection *and* a
490    /// `&mut` to the hook buffer for its entire lifetime, and two separate
491    /// `&mut self` accessors can never be live at the same time. Returning the
492    /// pair lets an implementor split the borrow across its own disjoint fields
493    /// — legal inside the type, impossible across a trait boundary otherwise:
494    ///
495    /// ```rust,ignore
496    /// fn savepoint_parts(&mut self) -> (&mut db::Connection, HookSlot<'_>) {
497    ///     // `tx` and `commit_hooks` are different fields, so this is fine.
498    ///     (self.tx.connection(), HookSlot::root(&mut self.commit_hooks))
499    /// }
500    /// ```
501    ///
502    /// An operation that wraps another should **forward** to the inner one, so
503    /// hook support is preserved:
504    ///
505    /// ```rust,ignore
506    /// fn savepoint_parts(&mut self) -> (&mut db::Connection, HookSlot<'_>) {
507    ///     self.inner.savepoint_parts()
508    /// }
509    /// ```
510    ///
511    /// An operation with no hook buffer of its own returns
512    /// [`HookSlot::unsupported`] — savepoints still work at the database level,
513    /// hook registration inside them refuses, and callers fall back to
514    /// [`force_execute_pre_commit`](hooks::CommitHook::force_execute_pre_commit)
515    /// exactly as they already do on the operation itself.
516    ///
517    /// The default reports no hook buffer, which is correct for an operation
518    /// that has none — a bare [`sqlx::Transaction`] needs nothing else.
519    ///
520    /// It is **not** correct for an operation that wraps one which does. Such a
521    /// type must override this — the
522    /// [`delegate_atomic_operation!`](crate::delegate_atomic_operation) macro
523    /// does it for you — because the default would otherwise refuse hooks inside every
524    /// savepoint taken through it while the wrapped operation supports them
525    /// fine. That mismatch is caught rather than left silent:
526    /// [`begin_savepoint`](SavepointOperation::begin_savepoint) fails with a
527    /// protocol error when an operation reports
528    /// [`supports_hooks`](Self::supports_hooks) but yields an unsupported slot,
529    /// which is exactly the shape "delegated `supports_hooks`, inherited
530    /// `savepoint_parts`" produces.
531    fn savepoint_parts(&mut self) -> (&mut db::Connection, savepoint::HookSlot<'_>) {
532        (self.connection(), savepoint::HookSlot::unsupported())
533    }
534}
535
536/// A bare transaction carries no commit-hook buffer, so the defaulted
537/// `savepoint_parts` is already right: savepoints work at the database level and
538/// refuse hook registration.
539impl<'c> AtomicOperation for sqlx::Transaction<'c, db::Db> {
540    fn connection(&mut self) -> &mut db::Connection {
541        &mut *self
542    }
543}
544
545impl<O: AtomicOperation + ?Sized> AtomicOperation for &mut O {
546    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
547        O::maybe_now(&**self)
548    }
549
550    fn clock(&self) -> &ClockHandle {
551        O::clock(&**self)
552    }
553
554    fn connection(&mut self) -> &mut db::Connection {
555        O::connection(&mut **self)
556    }
557
558    fn as_executor(&mut self) -> OneTimeExecutor<'_, &mut db::Connection> {
559        O::as_executor(&mut **self)
560    }
561
562    fn add_commit_hook_dyn(
563        &mut self,
564        type_id: std::any::TypeId,
565        hook: Box<dyn hooks::DynHook>,
566    ) -> Result<(), Box<dyn hooks::DynHook>> {
567        O::add_commit_hook_dyn(&mut **self, type_id, hook)
568    }
569
570    fn commit_hook_dyn(&self, type_id: std::any::TypeId) -> Option<&dyn hooks::DynHook> {
571        O::commit_hook_dyn(&**self, type_id)
572    }
573
574    fn supports_hooks(&self) -> bool {
575        O::supports_hooks(&**self)
576    }
577
578    fn savepoint_parts(&mut self) -> (&mut db::Connection, savepoint::HookSlot<'_>) {
579        O::savepoint_parts(&mut **self)
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn atomic_operation_is_object_safe() {
589        fn assert_object_safe(_: &mut dyn AtomicOperation) {}
590        let _ = assert_object_safe as fn(&mut dyn AtomicOperation);
591    }
592}