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_dyn(
234        &mut self,
235        type_id: std::any::TypeId,
236        hook: Box<dyn hooks::DynHook>,
237    ) -> Result<(), Box<dyn hooks::DynHook>> {
238        if self.parent_hooks.is_none() {
239            return Err(hook);
240        }
241        self.staged.push_or_merge(type_id, hook);
242        Ok(())
243    }
244
245    /// Reads the staged buffer first, falling back to the parent's.
246    ///
247    /// While a savepoint is open the two are not yet merged, so a hook type
248    /// present in *both* reports only the staged instance here — they become
249    /// one hook when the savepoint is released.
250    fn commit_hook_dyn(&self, type_id: std::any::TypeId) -> Option<&dyn hooks::DynHook> {
251        self.staged
252            .get_last_dyn(type_id)
253            .or_else(|| self.parent_hooks.as_ref()?.get_last_dyn(type_id))
254    }
255
256    fn supports_hooks(&self) -> bool {
257        self.parent_hooks.is_some()
258    }
259
260    /// Nesting: an inner savepoint folds into *this* savepoint's staged buffer,
261    /// not straight into the root, so an N-deep chain rolls up one level at a
262    /// time. `tx`, `staged` and `parent_hooks` are disjoint fields, so the reads
263    /// and borrows below coexist — the split that the trait boundary could not
264    /// otherwise express.
265    ///
266    /// Hook capability is **propagated, not assumed**: this savepoint offers its
267    /// `staged` buffer to an inner savepoint only if its own chain can ultimately
268    /// receive hooks. Handing the buffer over unconditionally would let an inner
269    /// savepoint accept a hook that had nowhere to go, so `add_commit_hook` would
270    /// report success for work whose `pre_commit`/`post_commit` could never run.
271    fn savepoint_parts(&mut self) -> (&mut db::Connection, HookSlot<'_>) {
272        let supported = self.parent_hooks.is_some();
273        (
274            self.tx.connection(),
275            HookSlot(supported.then_some(&mut self.staged)),
276        )
277    }
278}
279
280/// Savepoints for every [`AtomicOperation`], derived rather than hand-written.
281///
282/// This trait has a blanket implementation and no methods to implement: an
283/// operation earns savepoints by implementing
284/// [`AtomicOperation::savepoint_parts`], and a wrapper type that implements
285/// the [`delegate_atomic_operation!`](crate::delegate_atomic_operation) macro
286/// generates even that. So
287/// `DbOp`, `DbOpWithTime`, `SavepointOp` (nesting), [`HookOperation`], a bare
288/// [`sqlx::Transaction`], any [`OpWithTime`] wrapper, and operation types
289/// defined outside this crate all share one implementation of the pair.
290///
291/// The other half of the point is reachability: because these are trait methods
292/// rather than inherent ones, a function generic over `impl AtomicOperation` can
293/// take a savepoint. Inherent methods are invisible behind a generic bound,
294/// which is why code wanting savepoints previously had to name a concrete
295/// operation type in its signature.
296///
297/// ```rust,ignore
298/// use es_entity::{AtomicOperation, SavepointOperation};
299///
300/// // Generic over the operation: callers can pass a DbOp, a SavepointOp
301/// // (nesting one level deeper), or a HookOperation from inside a pre_commit.
302/// async fn process_all(
303///     op: &mut impl AtomicOperation,
304///     items: &[Item],
305/// ) -> Result<(), sqlx::Error> {
306///     for item in items {
307///         let _ = op.with_savepoint(async |sp| process_one(sp, item).await).await?;
308///     }
309///     Ok(())
310/// }
311/// ```
312///
313/// [`HookOperation`]: super::hooks::HookOperation
314/// [`OpWithTime`]: super::OpWithTime
315pub trait SavepointOperation: AtomicOperation {
316    /// Runs `f` inside a `SAVEPOINT`, keeping its work on `Ok` and undoing it on
317    /// `Err` — see [`DbOp::with_savepoint`](super::DbOp::with_savepoint) for the
318    /// full contract, including the two layers of `Result`.
319    ///
320    /// The returned future is deliberately **not** declared `Send`. Doing so
321    /// would require proving `f`'s future `Send` for every `SavepointOp<'_>`
322    /// lifetime, which needs the unstable `async_fn_traits` and still fails to
323    /// unify under a higher-ranked bound. Auto-trait inference at each call site
324    /// handles it instead, so this composes normally inside `Send` futures —
325    /// with the same caveat the inherent form already had: a closure capturing
326    /// `&self` may need to capture an owned clone instead.
327    fn with_savepoint<T, E, F>(
328        &mut self,
329        f: F,
330    ) -> impl Future<Output = Result<Result<T, E>, sqlx::Error>>
331    where
332        F: AsyncFnOnce(&mut SavepointOp<'_>) -> Result<T, E>,
333    {
334        async move {
335            let mut op = self.begin_savepoint().await?;
336            match f(&mut op).await {
337                Ok(value) => {
338                    op.release().await?;
339                    Ok(Ok(value))
340                }
341                Err(error) => {
342                    op.rollback().await?;
343                    Ok(Err(error))
344                }
345            }
346        }
347    }
348
349    /// Begins a `SAVEPOINT` scope explicitly — see
350    /// [`DbOp::begin_savepoint`](super::DbOp::begin_savepoint). Must be finished
351    /// with [`release`](SavepointOp::release) or
352    /// [`rollback`](SavepointOp::rollback); dropping it rolls back.
353    ///
354    /// # Incoherent hook capability
355    ///
356    /// Fails with a protocol error if the operation reports
357    /// [`supports_hooks`](AtomicOperation::supports_hooks) but hands back a slot
358    /// that cannot receive hooks. The two can only disagree one way: an
359    /// implementor forwarded `supports_hooks` to an operation it wraps but
360    /// inherited the default
361    /// [`savepoint_parts`](AtomicOperation::savepoint_parts), which can only
362    /// report "no hook buffer".
363    ///
364    /// Left unchecked that is silent: hooks registered inside the savepoint
365    /// would be refused, callers would fall back to
366    /// [`force_execute_pre_commit`](hooks::CommitHook::force_execute_pre_commit),
367    /// and `post_commit`/`on_rollback` would stop running for an operation whose
368    /// wrapped op supports them perfectly well. Reporting it turns a missing
369    /// method override into a loud failure the first time a savepoint is taken,
370    /// rather than a behaviour change nobody notices.
371    fn begin_savepoint(
372        &mut self,
373    ) -> impl Future<Output = Result<SavepointOp<'_>, sqlx::Error>> + Send {
374        async move {
375            // All three reads must complete before the `&mut` borrow below:
376            // cloning the handle is what releases the `&self` borrow `clock()`
377            // takes.
378            let clock = self.clock().clone();
379            let now = self.maybe_now();
380            let declares_hooks = self.supports_hooks();
381
382            let (conn, slot) = self.savepoint_parts();
383            if declares_hooks && !slot.supports_hooks() {
384                return Err(sqlx::Error::Protocol(
385                    "operation reports supports_hooks() but its savepoint_parts() \
386                     yields no hook buffer — commit hooks registered inside this \
387                     savepoint would be silently refused. Implement \
388                     AtomicOperation::savepoint_parts (or use the 
389                     delegate_atomic_operation! macro) on this \
390                     type instead of inheriting the default."
391                        .to_string(),
392                ));
393            }
394
395            SavepointOp::begin(conn, clock, now, slot.0).await
396        }
397    }
398}
399
400impl<T: AtomicOperation + ?Sized> SavepointOperation for T {}