es_entity/operation/mod.rs
1//! Handle execution of database operations and transactions.
2
3pub mod hooks;
4mod with_time;
5
6use sqlx::{Acquire, Transaction};
7
8use crate::{clock::ClockHandle, db, one_time_executor::OneTimeExecutor};
9
10pub use with_time::*;
11
12/// Default return type of the derived EsRepo::begin_op().
13///
14/// Used as a wrapper of a [`sqlx::Transaction`] but can also cache the time at which the
15/// transaction is taking place.
16///
17/// When a manual clock is provided, the transaction will automatically cache that
18/// clock's time, enabling deterministic testing. This cached time will be used in all
19/// time-dependent operations.
20pub struct DbOp<'c> {
21 tx: Transaction<'c, db::Db>,
22 clock: ClockHandle,
23 now: Option<chrono::DateTime<chrono::Utc>>,
24 commit_hooks: Option<hooks::CommitHooks>,
25}
26
27impl<'c> DbOp<'c> {
28 fn new(
29 tx: Transaction<'c, db::Db>,
30 clock: ClockHandle,
31 time: Option<chrono::DateTime<chrono::Utc>>,
32 ) -> Self {
33 Self {
34 tx,
35 clock,
36 now: time,
37 commit_hooks: Some(hooks::CommitHooks::new()),
38 }
39 }
40
41 /// Initializes a transaction using the global clock.
42 ///
43 /// Delegates to [`init_with_clock`](Self::init_with_clock) using the global clock handle.
44 pub async fn init(pool: &db::Pool) -> Result<DbOp<'static>, sqlx::Error> {
45 Self::init_with_clock(pool, crate::clock::Clock::handle()).await
46 }
47
48 /// Initializes a transaction with the specified clock.
49 ///
50 /// If the clock is manual, its current time will be cached in the transaction.
51 pub async fn init_with_clock(
52 pool: &db::Pool,
53 clock: &ClockHandle,
54 ) -> Result<DbOp<'static>, sqlx::Error> {
55 let tx = pool.begin().await?;
56
57 // If a manual clock is provided, cache its time for consistent
58 // timestamps within the transaction.
59 let time = clock.manual_now();
60
61 Ok(DbOp::new(tx, clock.clone(), time))
62 }
63
64 /// Transitions to a [`DbOpWithTime`] with the given time cached.
65 pub fn with_time(self, time: chrono::DateTime<chrono::Utc>) -> DbOpWithTime<'c> {
66 DbOpWithTime::new(self, time)
67 }
68
69 /// Transitions to a [`DbOpWithTime`] using the clock.
70 ///
71 /// Uses cached time if present, otherwise uses the clock's current time.
72 pub fn with_clock_time(self) -> DbOpWithTime<'c> {
73 let time = self.now.unwrap_or_else(|| self.clock.now());
74 DbOpWithTime::new(self, time)
75 }
76
77 /// Transitions to a [`DbOpWithTime`] using the database time.
78 ///
79 /// Priority order:
80 /// 1. Cached time if present
81 /// 2. Manual clock time if the clock is manual
82 /// 3. Database time via `SELECT NOW()`
83 pub async fn with_db_time(mut self) -> Result<DbOpWithTime<'c>, sqlx::Error> {
84 let time = if let Some(time) = self.now {
85 time
86 } else if let Some(manual_time) = self.clock.manual_now() {
87 manual_time
88 } else {
89 db::database_now(&mut *self.tx).await?
90 };
91
92 Ok(DbOpWithTime::new(self, time))
93 }
94
95 /// Returns the optionally cached [`chrono::DateTime`]
96 pub fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
97 self.now
98 }
99
100 /// Begins a nested transaction.
101 pub async fn begin(&mut self) -> Result<DbOp<'_>, sqlx::Error> {
102 Ok(DbOp::new(
103 self.tx.begin().await?,
104 self.clock.clone(),
105 self.now,
106 ))
107 }
108
109 /// Commits the inner transaction.
110 ///
111 /// On the failure paths the commit hooks' [`on_rollback`] runs **after** the
112 /// transaction is definitively gone, so hook-side compensation never
113 /// contends with the dying transaction's own locks:
114 ///
115 /// - A later hook's `pre_commit` fails → the transaction is rolled back
116 /// first, *then* the earlier (already-pre_committed) hooks are notified.
117 /// - The `COMMIT` itself fails → the transaction is over server-side either
118 /// way, so the hooks are notified directly (their side effects must be
119 /// idempotent against a possibly-landed commit).
120 ///
121 /// [`on_rollback`]: hooks::CommitHook::on_rollback
122 pub async fn commit(mut self) -> Result<(), sqlx::Error> {
123 let commit_hooks = self.commit_hooks.take().expect("no hooks");
124 match commit_hooks.execute_pre(&mut self).await {
125 Ok(post_hooks) => match self.tx.commit().await {
126 Ok(()) => {
127 post_hooks.execute();
128 Ok(())
129 }
130 Err(error) => {
131 // The commit attempt is definitively over server-side (it
132 // may have landed despite the error, or aborted) — there is
133 // no rollback to issue. Fire `on_rollback` so hooks can
134 // signal; their side effects must be idempotent against a
135 // possibly-landed commit.
136 post_hooks.execute_rollback();
137 Err(error)
138 }
139 },
140 Err((error, executed)) => {
141 // A later hook's `pre_commit` failed. Roll back BEFORE
142 // signalling: the rollback is awaited so it has landed
143 // server-side before any `on_rollback` fires, so a hook's
144 // downstream compensation never contends with this dying
145 // transaction's own locks. A rollback error means the
146 // connection is being torn down (which aborts the transaction
147 // anyway) — swallow it and surface the original hook error.
148 let _ = self.tx.rollback().await;
149 executed.execute_rollback();
150 Err(error)
151 }
152 }
153 }
154
155 /// Gets a mutable handle to the inner transaction
156 pub fn tx_mut(&mut self) -> &mut Transaction<'c, db::Db> {
157 &mut self.tx
158 }
159}
160
161impl<'o> AtomicOperation for DbOp<'o> {
162 fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
163 self.maybe_now()
164 }
165
166 fn clock(&self) -> &ClockHandle {
167 &self.clock
168 }
169
170 fn connection(&mut self) -> &mut db::Connection {
171 self.tx.connection()
172 }
173
174 fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
175 self.commit_hooks.as_mut().expect("no hooks").add(hook);
176 Ok(())
177 }
178
179 fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
180 self.commit_hooks.as_ref()?.get_last::<H>()
181 }
182
183 fn supports_hooks(&self) -> bool {
184 true
185 }
186}
187
188/// Equivileant of [`DbOp`] just that the time is guaranteed to be cached.
189///
190/// Used as a wrapper of a [`sqlx::Transaction`] with cached time of the transaction.
191pub struct DbOpWithTime<'c> {
192 inner: DbOp<'c>,
193 now: chrono::DateTime<chrono::Utc>,
194}
195
196impl<'c> DbOpWithTime<'c> {
197 fn new(mut inner: DbOp<'c>, time: chrono::DateTime<chrono::Utc>) -> Self {
198 inner.now = Some(time);
199 Self { inner, now: time }
200 }
201
202 /// The cached [`chrono::DateTime`]
203 pub fn now(&self) -> chrono::DateTime<chrono::Utc> {
204 self.now
205 }
206
207 /// Begins a nested transaction.
208 pub async fn begin(&mut self) -> Result<DbOpWithTime<'_>, sqlx::Error> {
209 Ok(DbOpWithTime::new(self.inner.begin().await?, self.now))
210 }
211
212 /// Commits the inner transaction.
213 pub async fn commit(self) -> Result<(), sqlx::Error> {
214 self.inner.commit().await
215 }
216
217 /// Gets a mutable handle to the inner transaction
218 pub fn tx_mut(&mut self) -> &mut Transaction<'c, db::Db> {
219 self.inner.tx_mut()
220 }
221}
222
223impl<'o> AtomicOperation for DbOpWithTime<'o> {
224 fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
225 Some(self.now())
226 }
227
228 fn clock(&self) -> &ClockHandle {
229 self.inner.clock()
230 }
231
232 fn connection(&mut self) -> &mut db::Connection {
233 self.inner.connection()
234 }
235
236 fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
237 self.inner.add_commit_hook(hook)
238 }
239
240 fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
241 self.inner.commit_hook::<H>()
242 }
243
244 fn supports_hooks(&self) -> bool {
245 self.inner.supports_hooks()
246 }
247}
248
249impl<'o> AtomicOperationWithTime for DbOpWithTime<'o> {
250 fn now(&self) -> chrono::DateTime<chrono::Utc> {
251 self.now
252 }
253}
254
255/// Trait to signify we can make multiple consistent database roundtrips.
256///
257/// Its a stand in for [`&mut sqlx::Transaction<'_, DB>`](`sqlx::Transaction`).
258/// The reason for having a trait is to support custom types that wrap the inner
259/// transaction while providing additional functionality.
260///
261/// See [`DbOp`] or [`DbOpWithTime`].
262pub trait AtomicOperation: Send {
263 /// Function for querying when the operation is taking place - if it is cached.
264 fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
265 None
266 }
267
268 /// Returns the clock handle for time operations.
269 ///
270 /// Default implementation returns the global clock handle.
271 fn clock(&self) -> &ClockHandle {
272 crate::clock::Clock::handle()
273 }
274
275 /// Returns the raw underlying connection.
276 /// The desired way to represent this would actually be as a GAT:
277 /// ```rust
278 /// trait AtomicOperation {
279 /// type Executor<'c>: sqlx::PgExecutor<'c>
280 /// where Self: 'c;
281 ///
282 /// fn connection<'c>(&'c mut self) -> Self::Executor<'c>;
283 /// }
284 /// ```
285 ///
286 /// But GATs don't play well with `async_trait::async_trait` due to lifetime constraints
287 /// so we return the concrete [`&mut db::Connection`](`crate::db::Connection`) instead as a work around.
288 ///
289 /// Since this trait is generally applied to types that wrap a [`sqlx::Transaction`]
290 /// there is no variance in the return type - so its fine.
291 ///
292 /// Statements executed directly on the returned connection are **not**
293 /// annotated with trace context — use [`as_executor`](Self::as_executor)
294 /// unless raw connection access is required.
295 fn connection(&mut self) -> &mut db::Connection;
296
297 /// Returns the [`sqlx::Executor`] implementation that statements should be
298 /// executed through.
299 ///
300 /// The returned [`OneTimeExecutor`] annotates every statement with the
301 /// current span's `traceparent` SQL comment when the `tracing-context`
302 /// feature is enabled and a *sampled* span is active (see
303 /// [`crate::sql_commenter`]). Otherwise statements pass through untouched.
304 ///
305 /// Trade-off: the trace context makes annotated statement text unique, so
306 /// annotated statements bypass sqlx's per-connection prepared statement
307 /// cache (`persistent(false)`) — costing a server-side parse + plan per
308 /// execution. Un-annotated traffic keeps full prepared-statement reuse.
309 fn as_executor(&mut self) -> OneTimeExecutor<'_, &mut db::Connection> {
310 let now = self.maybe_now();
311 OneTimeExecutor::new(self.connection(), now)
312 }
313
314 /// Registers a commit hook that will run pre_commit before and post_commit after the transaction commits.
315 /// Returns Ok(()) if the hook was registered, Err(hook) if hooks are not supported.
316 fn add_commit_hook<H: hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
317 Err(hook)
318 }
319
320 /// Typed shared access to the currently-accumulating commit hook of type `H`,
321 /// if this operation supports commit hooks and one is registered.
322 /// Returns the hook a subsequent `add_commit_hook::<H>` call would merge into.
323 fn commit_hook<H: hooks::CommitHook>(&self) -> Option<&H> {
324 None
325 }
326
327 /// Whether this operation supports commit hooks.
328 ///
329 /// `true` iff [`add_commit_hook`](Self::add_commit_hook) can register a hook
330 /// (i.e. the operation is backed by a [`DbOp`]-style commit-hook buffer, not
331 /// a bare [`sqlx::Transaction`]). Unlike [`commit_hook`](Self::commit_hook) —
332 /// whose `None` is ambiguous between "hooks unsupported" and "supported but
333 /// none registered yet" — this reports support directly, with no registration
334 /// attempt and no `&mut` access.
335 fn supports_hooks(&self) -> bool {
336 false
337 }
338}
339
340impl<'c> AtomicOperation for sqlx::Transaction<'c, db::Db> {
341 fn connection(&mut self) -> &mut db::Connection {
342 &mut *self
343 }
344}