Skip to main content

es_entity/operation/
savepoint.rs

1//! Savepoint-scoped operations for per-item isolation inside a single transaction.
2
3use sqlx::{Acquire, Transaction};
4
5use std::future::Future;
6
7use crate::{clock::ClockHandle, db};
8
9use super::{AtomicOperation, hooks};
10
11/// The buffer a released savepoint's staged hooks fold into, or `None` when
12/// there is nowhere for them to go.
13///
14/// Deliberately an `Option<&mut CommitHooks>` rather than an enum
15/// distinguishing "root" from "nested": the distinction carried no behavioural
16/// difference, and encoding it invited the bug where a *nested* parent was
17/// assumed to accept hooks regardless of whether the chain above it did. With
18/// one representation, capability is a property of the buffer itself and
19/// propagates down a nesting chain by construction.
20///
21/// `None` arises from a bare [`sqlx::Transaction`] or any implementor that opts
22/// out via [`HookSlot::unsupported`]; from a `DbOp`/`HookOperation` whose own
23/// buffer is `None` (the [`force_execute_pre_commit`] path, which has no commit
24/// pass to fold into); and — transitively — from any savepoint nested inside one
25/// of those.
26///
27/// [`force_execute_pre_commit`]: hooks::CommitHook::force_execute_pre_commit
28pub(super) type HookParent<'t> = Option<&'t mut hooks::CommitHooks>;
29
30/// Folds `staged` into `parent`.
31///
32/// Errors — rather than silently dropping — if hooks were staged against a
33/// parent that cannot receive them. With capability propagated correctly this
34/// is unreachable, so it is defence in depth: it converts a future regression
35/// from silent hook loss (`pre_commit`/`post_commit` never running for work the
36/// caller was told had been registered) into a loud failure, in release builds
37/// as well as debug. Mirrors how the crate already reports the impossible
38/// `runs_after` cycle.
39fn absorb_staged(
40    parent: &mut HookParent<'_>,
41    staged: hooks::CommitHooks,
42) -> Result<(), sqlx::Error> {
43    match parent {
44        Some(hooks) => {
45            hooks.absorb_staged(staged);
46            Ok(())
47        }
48        None if staged.is_empty() => Ok(()),
49        None => Err(sqlx::Error::Protocol(
50            "commit hooks were staged on a savepoint whose enclosing operation \
51             cannot receive them — they would never run. This is a bug in the \
52             savepoint hook-capability propagation."
53                .to_string(),
54        )),
55    }
56}
57
58/// Where a released [`SavepointOp`]'s staged commit hooks fold into.
59///
60/// Returned as the second half of
61/// [`AtomicOperation::savepoint_parts`](super::AtomicOperation::savepoint_parts),
62/// paired with the connection the savepoint runs on. It is deliberately opaque:
63/// the only things an implementor outside this crate can do with one are
64/// *forward* a slot obtained from an operation it wraps, or declare that it has
65/// no hook buffer via [`unsupported`](Self::unsupported).
66///
67/// The pairing is the point. A savepoint needs a `&mut` to the connection **and**
68/// a `&mut` to the hook buffer, held simultaneously for its whole lifetime. Two
69/// separate `&mut self` accessors could never hand out both at once; returning
70/// them together lets the implementor split the borrow across its own disjoint
71/// fields, where the compiler permits it, and pass the result across the trait
72/// boundary already split.
73pub struct HookSlot<'t>(pub(super) HookParent<'t>);
74
75impl HookSlot<'_> {
76    /// Declares that this operation has no commit-hook buffer.
77    ///
78    /// Savepoints taken through it still work at the database level; they simply
79    /// report [`supports_hooks`](AtomicOperation::supports_hooks) as `false` and
80    /// refuse [`add_commit_hook`](AtomicOperation::add_commit_hook), so callers
81    /// take their [`force_execute_pre_commit`] fallback — the same path they
82    /// already take on the operation itself.
83    ///
84    /// [`force_execute_pre_commit`]: hooks::CommitHook::force_execute_pre_commit
85    pub fn unsupported() -> Self {
86        Self(None)
87    }
88
89    /// Whether this slot can actually receive folded hooks. Used to catch an
90    /// operation that claims hook support while yielding an unsupported slot —
91    /// see [`SavepointOperation::begin_savepoint`].
92    pub(super) fn supports_hooks(&self) -> bool {
93        self.0.is_some()
94    }
95}
96
97/// An [`AtomicOperation`] scoped to a database `SAVEPOINT` inside a parent [`DbOp`]
98/// or another `SavepointOp`.
99///
100/// Created by [`SavepointOperation::with_savepoint`] /
101/// [`SavepointOperation::begin_savepoint`] on any operation — including on another
102/// `SavepointOp`, which is how savepoints nest.
103/// Statements executed through it run inside the savepoint, so they can be undone
104/// with [`rollback`](Self::rollback) without poisoning — or ending — the parent
105/// transaction. This is what makes a "loop over N items in one transaction, but
106/// isolate each item's failure" pattern possible: one `COMMIT` (one WAL flush)
107/// for the whole batch, while a failing item unwinds only its own writes.
108/// Nesting extends this to per-sub-item isolation within an already-isolated
109/// item, without giving up any of the outer batch's atomicity.
110///
111/// # Hooks are staged, not executed
112///
113/// Commit hooks registered on a `SavepointOp` — including the ones repositories
114/// register internally, e.g. via `post_persist_hook` — are **staged** in a
115/// private buffer rather than added to the parent operation:
116///
117/// - [`release`](Self::release) issues `RELEASE SAVEPOINT` and then folds the
118///   staged hooks into the parent's buffer through the ordinary
119///   registration/merge path, exactly as if they had been registered on the
120///   parent directly. For a nested savepoint the "parent" is the enclosing
121///   `SavepointOp`'s own staged buffer — folding there is not yet visible to
122///   *its* parent until it, too, is released.
123/// - [`rollback`](Self::rollback) issues `ROLLBACK TO SAVEPOINT` and drops the
124///   staged hooks. A rolled-back item therefore contributes zero hook state to
125///   match its zero database state — no phantom event publishes, no inserts
126///   referencing rows that no longer exist.
127///
128/// No hook's [`pre_commit`] / [`post_commit`] ever runs at savepoint boundaries,
129/// nested or not. They run once, at the root [`commit`](super::DbOp::commit), over
130/// the final merged hook set — so `post_commit` still only fires after a durable
131/// `COMMIT`, and [`on_rollback`] still only fires when the whole transaction is
132/// gone.
133///
134/// [`DbOp`]: super::DbOp
135/// [`pre_commit`]: hooks::CommitHook::pre_commit
136/// [`post_commit`]: hooks::CommitHook::post_commit
137/// [`on_rollback`]: hooks::CommitHook::on_rollback
138pub struct SavepointOp<'t> {
139    tx: Transaction<'t, db::Db>,
140    clock: ClockHandle,
141    now: Option<chrono::DateTime<chrono::Utc>>,
142    /// Hooks registered while the savepoint is open. Folded into
143    /// `parent_hooks` on release, dropped on rollback.
144    staged: hooks::CommitHooks,
145    /// The enclosing operation's hook buffer — a `DbOp`'s own buffer for a
146    /// top-level savepoint, or a parent `SavepointOp`'s `staged` buffer for a
147    /// nested one. Held as a `&mut` to a *disjoint* field of that operation
148    /// (the nested transaction borrows its `tx` field), so this savepoint can
149    /// fold into it without the enclosing hooks ever leaving their owner.
150    parent_hooks: HookParent<'t>,
151}
152
153impl<'t> SavepointOp<'t> {
154    /// Opens the savepoint on a raw connection.
155    ///
156    /// Taking the bare `&mut db::Connection` — rather than a `&mut Transaction`
157    /// — is what lets one implementation serve *every* operation. sqlx tracks
158    /// transaction depth on the connection itself, so this correctly issues
159    /// `SAVEPOINT` rather than `BEGIN` whenever we are already inside a
160    /// transaction, regardless of whether the caller happens to hold a
161    /// `Transaction` wrapper (`DbOp`, another `SavepointOp`) or just the
162    /// connection (a [`HookOperation`](super::hooks::HookOperation)).
163    pub(super) async fn begin(
164        conn: &'t mut db::Connection,
165        clock: ClockHandle,
166        now: Option<chrono::DateTime<chrono::Utc>>,
167        parent_hooks: HookParent<'t>,
168    ) -> Result<Self, sqlx::Error> {
169        Ok(Self {
170            tx: conn.begin().await?,
171            clock,
172            now,
173            staged: hooks::CommitHooks::new(),
174            parent_hooks,
175        })
176    }
177
178    /// Releases the savepoint, keeping this scope's work.
179    ///
180    /// Issues `RELEASE SAVEPOINT`, then folds the staged commit hooks into the
181    /// enclosing operation's buffer via the normal registration/merge path — so
182    /// a mergeable hook type accumulates across savepoints exactly as it would
183    /// have on the parent, and a non-mergeable one lands at its own position in
184    /// release order. For a nested savepoint this is its parent `SavepointOp`'s
185    /// staged buffer, not necessarily the root `DbOp` — a further `release` (or
186    /// `rollback`) up the chain is what makes the fold visible further out.
187    ///
188    /// If the `RELEASE` itself fails the staged hooks are dropped and the error
189    /// is returned: the enclosing transaction is in an indeterminate state and
190    /// the caller must abandon it rather than commit or release further.
191    pub async fn release(self) -> Result<(), sqlx::Error> {
192        let Self {
193            tx,
194            staged,
195            mut parent_hooks,
196            ..
197        } = self;
198        tx.commit().await?;
199        absorb_staged(&mut parent_hooks, staged)
200    }
201
202    /// Rolls back to the savepoint, discarding this scope's work.
203    ///
204    /// Issues `ROLLBACK TO SAVEPOINT` and drops the staged commit hooks. The
205    /// enclosing operation stays alive and usable — including after an error
206    /// that would otherwise have poisoned it.
207    ///
208    /// Dropping a `SavepointOp` without calling either `release` or `rollback`
209    /// has the same database effect (sqlx queues the rollback on the
210    /// connection) and likewise discards the staged hooks.
211    pub async fn rollback(self) -> Result<(), sqlx::Error> {
212        self.tx.rollback().await
213    }
214}
215
216impl AtomicOperation for SavepointOp<'_> {
217    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
218        self.now
219    }
220
221    fn clock(&self) -> &ClockHandle {
222        &self.clock
223    }
224
225    fn connection(&mut self) -> &mut db::Connection {
226        self.tx.connection()
227    }
228
229    /// Refuses, like any other operation, when the enclosing chain ultimately
230    /// has nowhere to fold hooks — a savepoint over a bare [`sqlx::Transaction`]
231    /// or any op that returned [`HookSlot::unsupported`], or one nested under a
232    /// `HookOperation` on the `force_execute_pre_commit` path.
233    fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
234        if self.parent_hooks.is_none() {
235            return Err(hook);
236        }
237        self.staged.add(hook);
238        Ok(())
239    }
240
241    /// Reads the staged buffer first, falling back to the parent's.
242    ///
243    /// While a savepoint is open the two are not yet merged, so a hook type
244    /// present in *both* reports only the staged instance here — they become
245    /// one hook when the savepoint is released.
246    fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
247        self.staged
248            .get_last::<H>()
249            .or_else(|| self.parent_hooks.as_ref()?.get_last::<H>())
250    }
251
252    fn supports_hooks(&self) -> bool {
253        self.parent_hooks.is_some()
254    }
255
256    /// Nesting: an inner savepoint folds into *this* savepoint's staged buffer,
257    /// not straight into the root, so an N-deep chain rolls up one level at a
258    /// time. `tx`, `staged` and `parent_hooks` are disjoint fields, so the reads
259    /// and borrows below coexist — the split that the trait boundary could not
260    /// otherwise express.
261    ///
262    /// Hook capability is **propagated, not assumed**: this savepoint offers its
263    /// `staged` buffer to an inner savepoint only if its own chain can ultimately
264    /// receive hooks. Handing the buffer over unconditionally would let an inner
265    /// savepoint accept a hook that had nowhere to go, so `add_commit_hook` would
266    /// report success for work whose `pre_commit`/`post_commit` could never run.
267    fn savepoint_parts(&mut self) -> (&mut db::Connection, HookSlot<'_>) {
268        let supported = self.parent_hooks.is_some();
269        (
270            self.tx.connection(),
271            HookSlot(supported.then_some(&mut self.staged)),
272        )
273    }
274}
275
276/// Savepoints for every [`AtomicOperation`], derived rather than hand-written.
277///
278/// This trait has a blanket implementation and no methods to implement: an
279/// operation earns savepoints by implementing
280/// [`AtomicOperation::savepoint_parts`], and a wrapper type that implements
281/// the [`delegate_atomic_operation!`](crate::delegate_atomic_operation) macro
282/// generates even that. So
283/// `DbOp`, `DbOpWithTime`, `SavepointOp` (nesting), [`HookOperation`], a bare
284/// [`sqlx::Transaction`], any [`OpWithTime`] wrapper, and operation types
285/// defined outside this crate all share one implementation of the pair.
286///
287/// The other half of the point is reachability: because these are trait methods
288/// rather than inherent ones, a function generic over `impl AtomicOperation` can
289/// take a savepoint. Inherent methods are invisible behind a generic bound,
290/// which is why code wanting savepoints previously had to name a concrete
291/// operation type in its signature.
292///
293/// ```rust,ignore
294/// use es_entity::{AtomicOperation, SavepointOperation};
295///
296/// // Generic over the operation: callers can pass a DbOp, a SavepointOp
297/// // (nesting one level deeper), or a HookOperation from inside a pre_commit.
298/// async fn process_all(
299///     op: &mut impl AtomicOperation,
300///     items: &[Item],
301/// ) -> Result<(), sqlx::Error> {
302///     for item in items {
303///         let _ = op.with_savepoint(async |sp| process_one(sp, item).await).await?;
304///     }
305///     Ok(())
306/// }
307/// ```
308///
309/// [`HookOperation`]: super::hooks::HookOperation
310/// [`OpWithTime`]: super::OpWithTime
311pub trait SavepointOperation: AtomicOperation {
312    /// Runs `f` inside a `SAVEPOINT`, keeping its work on `Ok` and undoing it on
313    /// `Err` — see [`DbOp::with_savepoint`](super::DbOp::with_savepoint) for the
314    /// full contract, including the two layers of `Result`.
315    ///
316    /// The returned future is deliberately **not** declared `Send`. Doing so
317    /// would require proving `f`'s future `Send` for every `SavepointOp<'_>`
318    /// lifetime, which needs the unstable `async_fn_traits` and still fails to
319    /// unify under a higher-ranked bound. Auto-trait inference at each call site
320    /// handles it instead, so this composes normally inside `Send` futures —
321    /// with the same caveat the inherent form already had: a closure capturing
322    /// `&self` may need to capture an owned clone instead.
323    fn with_savepoint<T, E, F>(
324        &mut self,
325        f: F,
326    ) -> impl Future<Output = Result<Result<T, E>, sqlx::Error>>
327    where
328        F: AsyncFnOnce(&mut SavepointOp<'_>) -> Result<T, E>,
329    {
330        async move {
331            let mut op = self.begin_savepoint().await?;
332            match f(&mut op).await {
333                Ok(value) => {
334                    op.release().await?;
335                    Ok(Ok(value))
336                }
337                Err(error) => {
338                    op.rollback().await?;
339                    Ok(Err(error))
340                }
341            }
342        }
343    }
344
345    /// Begins a `SAVEPOINT` scope explicitly — see
346    /// [`DbOp::begin_savepoint`](super::DbOp::begin_savepoint). Must be finished
347    /// with [`release`](SavepointOp::release) or
348    /// [`rollback`](SavepointOp::rollback); dropping it rolls back.
349    ///
350    /// # Incoherent hook capability
351    ///
352    /// Fails with a protocol error if the operation reports
353    /// [`supports_hooks`](AtomicOperation::supports_hooks) but hands back a slot
354    /// that cannot receive hooks. The two can only disagree one way: an
355    /// implementor forwarded `supports_hooks` to an operation it wraps but
356    /// inherited the default
357    /// [`savepoint_parts`](AtomicOperation::savepoint_parts), which can only
358    /// report "no hook buffer".
359    ///
360    /// Left unchecked that is silent: hooks registered inside the savepoint
361    /// would be refused, callers would fall back to
362    /// [`force_execute_pre_commit`](hooks::CommitHook::force_execute_pre_commit),
363    /// and `post_commit`/`on_rollback` would stop running for an operation whose
364    /// wrapped op supports them perfectly well. Reporting it turns a missing
365    /// method override into a loud failure the first time a savepoint is taken,
366    /// rather than a behaviour change nobody notices.
367    fn begin_savepoint(
368        &mut self,
369    ) -> impl Future<Output = Result<SavepointOp<'_>, sqlx::Error>> + Send {
370        async move {
371            // All three reads must complete before the `&mut` borrow below:
372            // cloning the handle is what releases the `&self` borrow `clock()`
373            // takes.
374            let clock = self.clock().clone();
375            let now = self.maybe_now();
376            let declares_hooks = self.supports_hooks();
377
378            let (conn, slot) = self.savepoint_parts();
379            if declares_hooks && !slot.supports_hooks() {
380                return Err(sqlx::Error::Protocol(
381                    "operation reports supports_hooks() but its savepoint_parts() \
382                     yields no hook buffer — commit hooks registered inside this \
383                     savepoint would be silently refused. Implement \
384                     AtomicOperation::savepoint_parts (or use the 
385                     delegate_atomic_operation! macro) on this \
386                     type instead of inheriting the default."
387                        .to_string(),
388                ));
389            }
390
391            SavepointOp::begin(conn, clock, now, slot.0).await
392        }
393    }
394}
395
396impl<T: AtomicOperation + ?Sized> SavepointOperation for T {}