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 {}