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 pub async fn with_savepoint<T, E, F>(&mut self, f: F) -> Result<Result<T, E>, sqlx::Error>
159 where
160 F: AsyncFnOnce(&mut SavepointOp<'_>) -> Result<T, E>,
161 {
162 let mut op = self.begin_savepoint().await?;
163 match f(&mut op).await {
164 Ok(value) => {
165 op.release().await?;
166 Ok(Ok(value))
167 }
168 Err(error) => {
169 op.rollback().await?;
170 Ok(Err(error))
171 }
172 }
173 }
174
175 /// Begins a `SAVEPOINT` scope explicitly.
176 ///
177 /// The escape hatch for when [`with_savepoint`](Self::with_savepoint)'s
178 /// closure form doesn't fit — the returned [`SavepointOp`] must be finished
179 /// with [`release`](SavepointOp::release) or
180 /// [`rollback`](SavepointOp::rollback). Dropping it rolls back.
181 pub async fn begin_savepoint(&mut self) -> Result<SavepointOp<'_>, sqlx::Error> {
182 SavepointOp::begin(
183 &mut self.tx,
184 self.clock.clone(),
185 self.now,
186 &mut self.commit_hooks,
187 )
188 .await
189 }
190
191 /// Commits the inner transaction.
192 ///
193 /// On the failure paths the commit hooks' [`on_rollback`] runs **after** the
194 /// transaction is definitively gone, so hook-side compensation never
195 /// contends with the dying transaction's own locks:
196 ///
197 /// - A later hook's `pre_commit` fails → the transaction is rolled back
198 /// first, *then* the earlier (already-pre_committed) hooks are notified.
199 /// - The `COMMIT` itself fails → the transaction is over server-side either
200 /// way, so the hooks are notified directly (their side effects must be
201 /// idempotent against a possibly-landed commit).
202 ///
203 /// [`on_rollback`]: hooks::CommitHook::on_rollback
204 pub async fn commit(mut self) -> Result<(), sqlx::Error> {
205 let commit_hooks = self.commit_hooks.take().expect("no hooks");
206 match commit_hooks.execute_pre(&mut self).await {
207 Ok(post_hooks) => match self.tx.commit().await {
208 Ok(()) => {
209 post_hooks.execute();
210 Ok(())
211 }
212 Err(error) => {
213 // The commit attempt is definitively over server-side (it
214 // may have landed despite the error, or aborted) — there is
215 // no rollback to issue. Fire `on_rollback` so hooks can
216 // signal; their side effects must be idempotent against a
217 // possibly-landed commit.
218 post_hooks.execute_rollback();
219 Err(error)
220 }
221 },
222 Err((error, executed)) => {
223 // A later hook's `pre_commit` failed. Roll back BEFORE
224 // signalling: the rollback is awaited so it has landed
225 // server-side before any `on_rollback` fires, so a hook's
226 // downstream compensation never contends with this dying
227 // transaction's own locks. A rollback error means the
228 // connection is being torn down (which aborts the transaction
229 // anyway) — swallow it and surface the original hook error.
230 let _ = self.tx.rollback().await;
231 executed.execute_rollback();
232 Err(error)
233 }
234 }
235 }
236
237 /// Gets a mutable handle to the inner transaction
238 pub fn tx_mut(&mut self) -> &mut Transaction<'c, db::Db> {
239 &mut self.tx
240 }
241}
242
243impl<'o> AtomicOperation for DbOp<'o> {
244 fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
245 self.maybe_now()
246 }
247
248 fn clock(&self) -> &ClockHandle {
249 &self.clock
250 }
251
252 fn connection(&mut self) -> &mut db::Connection {
253 self.tx.connection()
254 }
255
256 fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
257 self.commit_hooks.as_mut().expect("no hooks").add(hook);
258 Ok(())
259 }
260
261 fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
262 self.commit_hooks.as_ref()?.get_last::<H>()
263 }
264
265 fn supports_hooks(&self) -> bool {
266 true
267 }
268}
269
270/// Equivileant of [`DbOp`] just that the time is guaranteed to be cached.
271///
272/// Used as a wrapper of a [`sqlx::Transaction`] with cached time of the transaction.
273pub struct DbOpWithTime<'c> {
274 inner: DbOp<'c>,
275 now: chrono::DateTime<chrono::Utc>,
276}
277
278impl<'c> DbOpWithTime<'c> {
279 fn new(mut inner: DbOp<'c>, time: chrono::DateTime<chrono::Utc>) -> Self {
280 inner.now = Some(time);
281 Self { inner, now: time }
282 }
283
284 /// The cached [`chrono::DateTime`]
285 pub fn now(&self) -> chrono::DateTime<chrono::Utc> {
286 self.now
287 }
288
289 /// Begins a nested transaction.
290 pub async fn begin(&mut self) -> Result<DbOpWithTime<'_>, sqlx::Error> {
291 Ok(DbOpWithTime::new(self.inner.begin().await?, self.now))
292 }
293
294 /// Runs `f` inside a `SAVEPOINT` — see [`DbOp::with_savepoint`].
295 ///
296 /// The cached time is propagated, so the [`SavepointOp`] reports it from
297 /// [`maybe_now`](AtomicOperation::maybe_now) and wrapping it in
298 /// [`OpWithTime`] is free.
299 pub async fn with_savepoint<T, E, F>(&mut self, f: F) -> Result<Result<T, E>, sqlx::Error>
300 where
301 F: AsyncFnOnce(&mut SavepointOp<'_>) -> Result<T, E>,
302 {
303 self.inner.with_savepoint(f).await
304 }
305
306 /// Begins a `SAVEPOINT` scope explicitly — see [`DbOp::begin_savepoint`].
307 pub async fn begin_savepoint(&mut self) -> Result<SavepointOp<'_>, sqlx::Error> {
308 self.inner.begin_savepoint().await
309 }
310
311 /// Commits the inner transaction.
312 pub async fn commit(self) -> Result<(), sqlx::Error> {
313 self.inner.commit().await
314 }
315
316 /// Gets a mutable handle to the inner transaction
317 pub fn tx_mut(&mut self) -> &mut Transaction<'c, db::Db> {
318 self.inner.tx_mut()
319 }
320}
321
322impl<'o> AtomicOperation for DbOpWithTime<'o> {
323 fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
324 Some(self.now())
325 }
326
327 fn clock(&self) -> &ClockHandle {
328 self.inner.clock()
329 }
330
331 fn connection(&mut self) -> &mut db::Connection {
332 self.inner.connection()
333 }
334
335 fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
336 self.inner.add_commit_hook(hook)
337 }
338
339 fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
340 self.inner.commit_hook::<H>()
341 }
342
343 fn supports_hooks(&self) -> bool {
344 self.inner.supports_hooks()
345 }
346}
347
348impl<'o> AtomicOperationWithTime for DbOpWithTime<'o> {
349 fn now(&self) -> chrono::DateTime<chrono::Utc> {
350 self.now
351 }
352}
353
354/// Trait to signify we can make multiple consistent database roundtrips.
355///
356/// Its a stand in for [`&mut sqlx::Transaction<'_, DB>`](`sqlx::Transaction`).
357/// The reason for having a trait is to support custom types that wrap the inner
358/// transaction while providing additional functionality.
359///
360/// See [`DbOp`] or [`DbOpWithTime`].
361pub trait AtomicOperation: Send {
362 /// Function for querying when the operation is taking place - if it is cached.
363 fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
364 None
365 }
366
367 /// Returns the clock handle for time operations.
368 ///
369 /// Default implementation returns the global clock handle.
370 fn clock(&self) -> &ClockHandle {
371 crate::clock::Clock::handle()
372 }
373
374 /// Returns the raw underlying connection.
375 /// The desired way to represent this would actually be as a GAT:
376 /// ```rust
377 /// trait AtomicOperation {
378 /// type Executor<'c>: sqlx::PgExecutor<'c>
379 /// where Self: 'c;
380 ///
381 /// fn connection<'c>(&'c mut self) -> Self::Executor<'c>;
382 /// }
383 /// ```
384 ///
385 /// But GATs don't play well with `async_trait::async_trait` due to lifetime constraints
386 /// so we return the concrete [`&mut db::Connection`](`crate::db::Connection`) instead as a work around.
387 ///
388 /// Since this trait is generally applied to types that wrap a [`sqlx::Transaction`]
389 /// there is no variance in the return type - so its fine.
390 ///
391 /// Statements executed directly on the returned connection are **not**
392 /// annotated with trace context — use [`as_executor`](Self::as_executor)
393 /// unless raw connection access is required.
394 fn connection(&mut self) -> &mut db::Connection;
395
396 /// Returns the [`sqlx::Executor`] implementation that statements should be
397 /// executed through.
398 ///
399 /// The returned [`OneTimeExecutor`] annotates every statement with the
400 /// current span's `traceparent` SQL comment when the `tracing-context`
401 /// feature is enabled and a *sampled* span is active (see
402 /// [`crate::sql_commenter`]). Otherwise statements pass through untouched.
403 ///
404 /// Trade-off: the trace context makes annotated statement text unique, so
405 /// annotated statements bypass sqlx's per-connection prepared statement
406 /// cache (`persistent(false)`) — costing a server-side parse + plan per
407 /// execution. Un-annotated traffic keeps full prepared-statement reuse.
408 fn as_executor(&mut self) -> OneTimeExecutor<'_, &mut db::Connection> {
409 let now = self.maybe_now();
410 OneTimeExecutor::new(self.connection(), now)
411 }
412
413 /// Registers a commit hook that will run pre_commit before and post_commit after the transaction commits.
414 /// Returns Ok(()) if the hook was registered, Err(hook) if hooks are not supported.
415 fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
416 Err(hook)
417 }
418
419 /// Typed shared access to the currently-accumulating commit hook of type `H`,
420 /// if this operation supports commit hooks and one is registered.
421 /// Returns the hook a subsequent `add_commit_hook::<H>` call would merge into.
422 fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
423 None
424 }
425
426 /// Whether this operation supports commit hooks.
427 ///
428 /// `true` iff [`add_commit_hook`](Self::add_commit_hook) can register a hook
429 /// (i.e. the operation is backed by a [`DbOp`]-style commit-hook buffer, not
430 /// a bare [`sqlx::Transaction`]). Unlike [`commit_hook`](Self::commit_hook) —
431 /// whose `None` is ambiguous between "hooks unsupported" and "supported but
432 /// none registered yet" — this reports support directly, with no registration
433 /// attempt and no `&mut` access.
434 fn supports_hooks(&self) -> bool {
435 false
436 }
437}
438
439impl<'c> AtomicOperation for sqlx::Transaction<'c, db::Db> {
440 fn connection(&mut self) -> &mut db::Connection {
441 &mut *self
442 }
443}