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 ///
159 /// Kept as an inherent method so existing call sites need no import; the
160 /// behaviour lives in [`SavepointOperation::with_savepoint`], which every
161 /// [`AtomicOperation`] gets.
162 pub async fn with_savepoint<T, E, F>(&mut self, f: F) -> Result<Result<T, E>, sqlx::Error>
163 where
164 F: AsyncFnOnce(&mut SavepointOp<'_>) -> Result<T, E>,
165 {
166 SavepointOperation::with_savepoint(self, f).await
167 }
168
169 /// Begins a `SAVEPOINT` scope explicitly.
170 ///
171 /// The escape hatch for when [`with_savepoint`](Self::with_savepoint)'s
172 /// closure form doesn't fit — the returned [`SavepointOp`] must be finished
173 /// with [`release`](SavepointOp::release) or
174 /// [`rollback`](SavepointOp::rollback). Dropping it rolls back.
175 pub async fn begin_savepoint(&mut self) -> Result<SavepointOp<'_>, sqlx::Error> {
176 SavepointOperation::begin_savepoint(self).await
177 }
178
179 /// Commits the inner transaction.
180 ///
181 /// On the failure paths the commit hooks' [`on_rollback`] runs **after** the
182 /// transaction is definitively gone, so hook-side compensation never
183 /// contends with the dying transaction's own locks:
184 ///
185 /// - A later hook's `pre_commit` fails → the transaction is rolled back
186 /// first, *then* the earlier (already-pre_committed) hooks are notified.
187 /// - The `COMMIT` itself fails → the transaction is over server-side either
188 /// way, so the hooks are notified directly (their side effects must be
189 /// idempotent against a possibly-landed commit).
190 ///
191 /// [`on_rollback`]: hooks::CommitHook::on_rollback
192 pub async fn commit(mut self) -> Result<(), sqlx::Error> {
193 let commit_hooks = self.commit_hooks.take().expect("no hooks");
194 match commit_hooks.execute_pre(&mut self).await {
195 Ok(post_hooks) => match self.tx.commit().await {
196 Ok(()) => {
197 post_hooks.execute();
198 Ok(())
199 }
200 Err(error) => {
201 // The commit attempt is definitively over server-side (it
202 // may have landed despite the error, or aborted) — there is
203 // no rollback to issue. Fire `on_rollback` so hooks can
204 // signal; their side effects must be idempotent against a
205 // possibly-landed commit.
206 post_hooks.execute_rollback();
207 Err(error)
208 }
209 },
210 Err((error, executed)) => {
211 // A later hook's `pre_commit` failed. Roll back BEFORE
212 // signalling: the rollback is awaited so it has landed
213 // server-side before any `on_rollback` fires, so a hook's
214 // downstream compensation never contends with this dying
215 // transaction's own locks. A rollback error means the
216 // connection is being torn down (which aborts the transaction
217 // anyway) — swallow it and surface the original hook error.
218 let _ = self.tx.rollback().await;
219 executed.execute_rollback();
220 Err(error)
221 }
222 }
223 }
224
225 /// Gets a mutable handle to the inner transaction
226 pub fn tx_mut(&mut self) -> &mut Transaction<'c, db::Db> {
227 &mut self.tx
228 }
229}
230
231impl<'o> AtomicOperation for DbOp<'o> {
232 fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
233 self.maybe_now()
234 }
235
236 fn clock(&self) -> &ClockHandle {
237 &self.clock
238 }
239
240 fn connection(&mut self) -> &mut db::Connection {
241 self.tx.connection()
242 }
243
244 fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
245 self.commit_hooks.as_mut().expect("no hooks").add(hook);
246 Ok(())
247 }
248
249 fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
250 self.commit_hooks.as_ref()?.get_last::<H>()
251 }
252
253 fn supports_hooks(&self) -> bool {
254 true
255 }
256
257 /// `tx` and `commit_hooks` are disjoint fields, so both can be borrowed
258 /// mutably in one expression — the borrow split that a pair of `&mut self`
259 /// accessors could not express, which is the whole reason this method
260 /// returns both halves at once.
261 fn savepoint_parts(&mut self) -> (&mut db::Connection, savepoint::HookSlot<'_>) {
262 (
263 self.tx.connection(),
264 savepoint::HookSlot(self.commit_hooks.as_mut()),
265 )
266 }
267}
268
269/// Equivileant of [`DbOp`] just that the time is guaranteed to be cached.
270///
271/// Used as a wrapper of a [`sqlx::Transaction`] with cached time of the transaction.
272pub struct DbOpWithTime<'c> {
273 inner: DbOp<'c>,
274 now: chrono::DateTime<chrono::Utc>,
275}
276
277impl<'c> DbOpWithTime<'c> {
278 fn new(mut inner: DbOp<'c>, time: chrono::DateTime<chrono::Utc>) -> Self {
279 inner.now = Some(time);
280 Self { inner, now: time }
281 }
282
283 /// The cached [`chrono::DateTime`]
284 pub fn now(&self) -> chrono::DateTime<chrono::Utc> {
285 self.now
286 }
287
288 /// Begins a nested transaction.
289 pub async fn begin(&mut self) -> Result<DbOpWithTime<'_>, sqlx::Error> {
290 Ok(DbOpWithTime::new(self.inner.begin().await?, self.now))
291 }
292
293 /// Runs `f` inside a `SAVEPOINT` — see [`DbOp::with_savepoint`].
294 ///
295 /// The cached time is propagated, so the [`SavepointOp`] reports it from
296 /// [`maybe_now`](AtomicOperation::maybe_now) and wrapping it in
297 /// [`OpWithTime`] is free.
298 pub async fn with_savepoint<T, E, F>(&mut self, f: F) -> Result<Result<T, E>, sqlx::Error>
299 where
300 F: AsyncFnOnce(&mut SavepointOp<'_>) -> Result<T, E>,
301 {
302 SavepointOperation::with_savepoint(self, f).await
303 }
304
305 /// Begins a `SAVEPOINT` scope explicitly — see [`DbOp::begin_savepoint`].
306 pub async fn begin_savepoint(&mut self) -> Result<SavepointOp<'_>, sqlx::Error> {
307 SavepointOperation::begin_savepoint(self).await
308 }
309
310 /// Commits the inner transaction.
311 pub async fn commit(self) -> Result<(), sqlx::Error> {
312 self.inner.commit().await
313 }
314
315 /// Gets a mutable handle to the inner transaction
316 pub fn tx_mut(&mut self) -> &mut Transaction<'c, db::Db> {
317 self.inner.tx_mut()
318 }
319}
320
321impl<'o> AtomicOperation for DbOpWithTime<'o> {
322 fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
323 Some(self.now())
324 }
325
326 fn clock(&self) -> &ClockHandle {
327 self.inner.clock()
328 }
329
330 fn connection(&mut self) -> &mut db::Connection {
331 self.inner.connection()
332 }
333
334 fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
335 self.inner.add_commit_hook(hook)
336 }
337
338 fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
339 self.inner.commit_hook::<H>()
340 }
341
342 fn supports_hooks(&self) -> bool {
343 self.inner.supports_hooks()
344 }
345
346 fn savepoint_parts(&mut self) -> (&mut db::Connection, savepoint::HookSlot<'_>) {
347 self.inner.savepoint_parts()
348 }
349}
350
351impl<'o> AtomicOperationWithTime for DbOpWithTime<'o> {
352 fn now(&self) -> chrono::DateTime<chrono::Utc> {
353 self.now
354 }
355}
356
357/// Trait to signify we can make multiple consistent database roundtrips.
358///
359/// Its a stand in for [`&mut sqlx::Transaction<'_, DB>`](`sqlx::Transaction`).
360/// The reason for having a trait is to support custom types that wrap the inner
361/// transaction while providing additional functionality.
362///
363/// See [`DbOp`] or [`DbOpWithTime`].
364pub trait AtomicOperation: Send {
365 /// Function for querying when the operation is taking place - if it is cached.
366 fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
367 None
368 }
369
370 /// Returns the clock handle for time operations.
371 ///
372 /// Default implementation returns the global clock handle.
373 fn clock(&self) -> &ClockHandle {
374 crate::clock::Clock::handle()
375 }
376
377 /// Returns the raw underlying connection.
378 /// The desired way to represent this would actually be as a GAT:
379 /// ```rust
380 /// trait AtomicOperation {
381 /// type Executor<'c>: sqlx::PgExecutor<'c>
382 /// where Self: 'c;
383 ///
384 /// fn connection<'c>(&'c mut self) -> Self::Executor<'c>;
385 /// }
386 /// ```
387 ///
388 /// But GATs don't play well with `async_trait::async_trait` due to lifetime constraints
389 /// so we return the concrete [`&mut db::Connection`](`crate::db::Connection`) instead as a work around.
390 ///
391 /// Since this trait is generally applied to types that wrap a [`sqlx::Transaction`]
392 /// there is no variance in the return type - so its fine.
393 ///
394 /// Statements executed directly on the returned connection are **not**
395 /// annotated with trace context — use [`as_executor`](Self::as_executor)
396 /// unless raw connection access is required.
397 fn connection(&mut self) -> &mut db::Connection;
398
399 /// Returns the [`sqlx::Executor`] implementation that statements should be
400 /// executed through.
401 ///
402 /// The returned [`OneTimeExecutor`] annotates every statement with the
403 /// current span's `traceparent` SQL comment when the `tracing-context`
404 /// feature is enabled and a *sampled* span is active (see
405 /// [`crate::sql_commenter`]). Otherwise statements pass through untouched.
406 ///
407 /// Trade-off: the trace context makes annotated statement text unique, so
408 /// annotated statements bypass sqlx's per-connection prepared statement
409 /// cache (`persistent(false)`) — costing a server-side parse + plan per
410 /// execution. Un-annotated traffic keeps full prepared-statement reuse.
411 fn as_executor(&mut self) -> OneTimeExecutor<'_, &mut db::Connection> {
412 let now = self.maybe_now();
413 OneTimeExecutor::new(self.connection(), now)
414 }
415
416 /// Registers a commit hook that will run pre_commit before and post_commit after the transaction commits.
417 /// Returns Ok(()) if the hook was registered, Err(hook) if hooks are not supported.
418 fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
419 Err(hook)
420 }
421
422 /// Typed shared access to the currently-accumulating commit hook of type `H`,
423 /// if this operation supports commit hooks and one is registered.
424 /// Returns the hook a subsequent `add_commit_hook::<H>` call would merge into.
425 fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
426 None
427 }
428
429 /// Whether this operation supports commit hooks.
430 ///
431 /// `true` iff [`add_commit_hook`](Self::add_commit_hook) can register a hook
432 /// (i.e. the operation is backed by a [`DbOp`]-style commit-hook buffer, not
433 /// a bare [`sqlx::Transaction`]). Unlike [`commit_hook`](Self::commit_hook) —
434 /// whose `None` is ambiguous between "hooks unsupported" and "supported but
435 /// none registered yet" — this reports support directly, with no registration
436 /// attempt and no `&mut` access.
437 fn supports_hooks(&self) -> bool {
438 false
439 }
440
441 /// Simultaneous access to the connection **and** the commit-hook buffer a
442 /// nested `SAVEPOINT` folds into when released. Implementing this is the
443 /// only thing an operation must do to get the whole of
444 /// [`SavepointOperation`] — `with_savepoint`, `begin_savepoint`, and
445 /// arbitrary-depth nesting — for free.
446 ///
447 /// Returning both halves together is not a convenience: it is a
448 /// requirement. A [`SavepointOp`] holds a `&mut` to the connection *and* a
449 /// `&mut` to the hook buffer for its entire lifetime, and two separate
450 /// `&mut self` accessors can never be live at the same time. Returning the
451 /// pair lets an implementor split the borrow across its own disjoint fields
452 /// — legal inside the type, impossible across a trait boundary otherwise:
453 ///
454 /// ```rust,ignore
455 /// fn savepoint_parts(&mut self) -> (&mut db::Connection, HookSlot<'_>) {
456 /// // `tx` and `commit_hooks` are different fields, so this is fine.
457 /// (self.tx.connection(), HookSlot::root(&mut self.commit_hooks))
458 /// }
459 /// ```
460 ///
461 /// An operation that wraps another should **forward** to the inner one, so
462 /// hook support is preserved:
463 ///
464 /// ```rust,ignore
465 /// fn savepoint_parts(&mut self) -> (&mut db::Connection, HookSlot<'_>) {
466 /// self.inner.savepoint_parts()
467 /// }
468 /// ```
469 ///
470 /// An operation with no hook buffer of its own returns
471 /// [`HookSlot::unsupported`] — savepoints still work at the database level,
472 /// hook registration inside them refuses, and callers fall back to
473 /// [`force_execute_pre_commit`](hooks::CommitHook::force_execute_pre_commit)
474 /// exactly as they already do on the operation itself.
475 ///
476 /// The default reports no hook buffer, which is correct for an operation
477 /// that has none — a bare [`sqlx::Transaction`] needs nothing else.
478 ///
479 /// It is **not** correct for an operation that wraps one which does. Such a
480 /// type must override this — the
481 /// [`delegate_atomic_operation!`](crate::delegate_atomic_operation) macro
482 /// does it for you — because the default would otherwise refuse hooks inside every
483 /// savepoint taken through it while the wrapped operation supports them
484 /// fine. That mismatch is caught rather than left silent:
485 /// [`begin_savepoint`](SavepointOperation::begin_savepoint) fails with a
486 /// protocol error when an operation reports
487 /// [`supports_hooks`](Self::supports_hooks) but yields an unsupported slot,
488 /// which is exactly the shape "delegated `supports_hooks`, inherited
489 /// `savepoint_parts`" produces.
490 fn savepoint_parts(&mut self) -> (&mut db::Connection, savepoint::HookSlot<'_>) {
491 (self.connection(), savepoint::HookSlot::unsupported())
492 }
493}
494
495/// A bare transaction carries no commit-hook buffer, so the defaulted
496/// `savepoint_parts` is already right: savepoints work at the database level and
497/// refuse hook registration.
498impl<'c> AtomicOperation for sqlx::Transaction<'c, db::Db> {
499 fn connection(&mut self) -> &mut db::Connection {
500 &mut *self
501 }
502}