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