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}