es_entity/operation/hooks.rs
1//! Commit hooks for executing custom logic before and after transaction commits.
2//!
3//! This module provides the [`CommitHook`] trait and supporting types that allow you to
4//! register hooks that execute during the commit lifecycle of a transaction. This is useful for:
5//!
6//! - Publishing events to message queues after successful commits
7//! - Updating caches
8//! - Triggering side effects that should only occur if the transaction succeeds
9//! - Accumulating operations across multiple entity updates in a transaction
10//!
11//! # Hook Lifecycle
12//!
13//! 1. **Registration**: Hooks are registered using [`AtomicOperation::add_commit_hook()`]
14//! 2. **Merging**: Multiple hooks of the same type may be merged via [`CommitHook::merge()`]
15//! 3. **Pre-commit**: [`CommitHook::pre_commit()`] executes before the transaction commits
16//! 4. **Commit**: The underlying database transaction is committed
17//! 5. **Post-commit**: [`CommitHook::post_commit()`] executes after successful commit
18//!
19//! If the commit **fails** instead — either a later hook's `pre_commit` errors (the
20//! transaction is rolled back first) or the `COMMIT` itself errors — then
21//! [`CommitHook::on_rollback()`] is fired on every hook whose `pre_commit` had already
22//! completed, in registration order, in place of `post_commit`. It is a synchronous,
23//! infallible, signal-only callback (see its docs).
24//!
25//! # Hook Ordering
26//!
27//! Hooks execute in **registration order** — per hook, not per type:
28//!
29//! 1. Hooks run in the order they were added to the operation, regardless of their
30//! type. A hook that refuses to merge ([`CommitHook::merge()`] returns `false`)
31//! executes at its own (later) registration position, even when an earlier hook
32//! of the same type exists.
33//! 2. A hook that merges executes at the position of the hook it merged into (the
34//! earlier one). For always-merging hook types this means the type's position is
35//! anchored by its **first** registration in the operation; all later
36//! registrations fold into that position.
37//! 3. [`CommitHook::post_commit()`] hooks run in the same order as their
38//! [`CommitHook::pre_commit()`] counterparts (registration order).
39//! 4. A hook's `pre_commit` may itself register further hooks — see
40//! [`AtomicOperation::add_commit_hook()`] on the [`HookOperation`] it is handed.
41//! Those join the **tail of the same commit pass** rather than freezing the set
42//! before the first `pre_commit` runs. See "Re-entrant Registration" below.
43//!
44//! Registration order is the base order; [`CommitHook::runs_after()`] is the one
45//! sanctioned refinement — it can only *delay* a hook until still-pending instances
46//! of its declared dependency types have executed, never advance it:
47//!
48//! 5. A hook may declare hook **types** it must run after via
49//! [`CommitHook::runs_after()`]. While any still-pending instance of a declared
50//! type remains in the queue, the hook is deferred to the back and re-checked
51//! later; once no declared type is still pending it runs. This is evaluated
52//! dynamically on every attempt, so it composes with re-entrant staging — a
53//! dependency staged mid-pass re-blocks a hook that already deferred past it.
54//! 6. Among hooks with no unsatisfied `runs_after` dependency, registration order
55//! (points 1-3 above) is preserved. Execution remains fully deterministic.
56//! 7. A dependency type that never registers, or whose instances have all already
57//! executed, imposes no constraint — deferral is vacuous in both cases.
58//! 8. Declared dependencies that cannot all be satisfied (a cycle, direct or
59//! transitive) fail the commit loudly with a protocol error instead of hanging —
60//! see [`CommitHook::runs_after()`].
61//!
62//! # Re-entrant Registration
63//!
64//! [`AtomicOperation::add_commit_hook()`] succeeds on the [`HookOperation`] passed to
65//! a `pre_commit` that is running as part of a real commit pass — a hook can register
66//! more hooks, and they join the pass instead of being silently dropped or forced to
67//! run outside the commit lifecycle:
68//!
69//! - A newly-registered hook merges into a **still-pending** hook of the same type,
70//! keeping that hook's queue position — indistinguishable from having registered it
71//! there directly. An **already-executed** hook of that type is never a merge
72//! target (its `pre_commit` already ran), so registering that type again after its
73//! own execution always starts a fresh instance, which runs its own `pre_commit`
74//! later in the same pass.
75//! - A bound ([`MAX_HOOK_GENERATIONS`] re-entrant generations) guards against a
76//! registration cycle (A registers B, B registers A, …), which would otherwise grow
77//! the queue forever inside an open transaction. Exceeding it fails the commit
78//! loudly instead of hanging.
79//! - This only applies to a real commit pass. [`CommitHook::force_execute_pre_commit()`]
80//! — the escape hatch for ops that don't support hooks at all — still returns
81//! `Err` from `add_commit_hook`, because there is no pass for a registered hook to
82//! join; its `post_commit`/`on_rollback` would simply never run.
83//! - A hook deferred by [`CommitHook::runs_after()`] is still **pending**, so it
84//! remains a merge target for re-entrant staging exactly like any other
85//! not-yet-executed hook. This is what lets a producer's `pre_commit` stage work
86//! into a consumer hook that declared `runs_after` the producer's type: the
87//! consumer is waiting (deferred) rather than gone, so the staged instance merges
88//! into it — one execution — instead of starting a fresh generation.
89//!
90//! # Savepoints
91//!
92//! Hooks registered on a [`SavepointOp`] are staged and only enter the parent
93//! operation's set — through the same registration/merge path — when the savepoint
94//! is released; a rolled-back savepoint discards them. No callback runs at a
95//! savepoint boundary, so the lifecycle above is unchanged: one `pre_commit` pass at
96//! the root's commit, `post_commit` only after a durable `COMMIT`. Savepoints nest:
97//! `with_savepoint`/`begin_savepoint` come from [`SavepointOperation`], which every
98//! [`AtomicOperation`] gets — including [`SavepointOp`] itself and [`HookOperation`],
99//! so a hook's own `pre_commit` can isolate its own multi-statement write the same
100//! way. Releasing an inner savepoint folds into its *immediate* parent's staged
101//! buffer, not straight to the root, so an N-deep chain rolls up one level at a
102//! time. See [`SavepointOp`] for details.
103//!
104//! [`SavepointOp`]: super::SavepointOp
105//! [`SavepointOperation`]: super::SavepointOperation
106//!
107//! # Examples
108//!
109//! ## Hook with Database Operations and Channel-Based Publishing
110//!
111//! This example shows a complete event publishing hook that:
112//! - Stores events in the database during pre-commit (within the transaction)
113//! - Sends events to a channel during post-commit for async processing
114//! - Merges multiple hook instances to batch operations
115//!
116//! Note: `post_commit()` is synchronous and cannot fail, so it's best used for
117//! fire-and-forget operations like sending to channels. A background task can then
118//! handle the async work of publishing to external systems.
119//!
120//! ```
121//! use es_entity::{AtomicOperation, operation::hooks::{CommitHook, HookOperation, PreCommitRet}};
122//!
123//! #[derive(Debug, Clone)]
124//! struct Event {
125//! entity_id: uuid::Uuid,
126//! event_type: String,
127//! }
128//!
129//! #[derive(Debug)]
130//! struct EventPublisher {
131//! events: Vec<Event>,
132//! // Channel sender for publishing events to a background processor
133//! // In production, this might be tokio::sync::mpsc::Sender or similar
134//! tx: std::sync::mpsc::Sender<Event>,
135//! }
136//!
137//! impl CommitHook for EventPublisher {
138//! async fn pre_commit(self, mut op: HookOperation<'_>)
139//! -> Result<PreCommitRet<'_, Self>, sqlx::Error>
140//! {
141//! // Store events in the database within the transaction
142//! // If the transaction fails, these inserts will be rolled back
143//! for event in &self.events {
144//! sqlx::query!(
145//! "INSERT INTO hook_events (entity_id, event_type, created_at) VALUES ($1, $2, NOW())",
146//! event.entity_id,
147//! event.event_type
148//! )
149//! .execute(op.as_executor())
150//! .await?;
151//! }
152//!
153//! PreCommitRet::ok(self, op)
154//! }
155//!
156//! fn post_commit(self) {
157//! // Send events to a channel for async processing
158//! // This only runs if the transaction succeeded
159//! // Channel sends are fast and don't block; a background task handles publishing
160//! for event in self.events {
161//! // In production, handle send failures appropriately (logging, metrics, etc.)
162//! // The channel might be bounded to apply backpressure
163//! let _ = self.tx.send(event);
164//! }
165//! }
166//!
167//! fn merge(&mut self, other: &mut Self) -> bool {
168//! // Merge multiple EventPublisher hooks into one to batch operations
169//! self.events.append(&mut other.events);
170//! true
171//! }
172//! }
173//!
174//! // Separate background task for async event publishing
175//! // async fn event_publisher_task(mut rx: tokio::sync::mpsc::Receiver<Event>) {
176//! // while let Some(event) = rx.recv().await {
177//! // // Publish to Kafka, RabbitMQ, webhooks, etc.
178//! // // Handle failures with retries, dead-letter queues, etc.
179//! // match publish_to_external_system(&event).await {
180//! // Ok(_) => log::info!("Published event: {:?}", event),
181//! // Err(e) => log::error!("Failed to publish event: {:?}", e),
182//! // }
183//! // }
184//! // }
185//! ```
186//!
187//! ## Usage
188//!
189//! ```no_run
190//! # use es_entity::{AtomicOperation, DbOp, operation::hooks::{CommitHook, HookOperation, PreCommitRet}};
191//! # use es_entity::db;
192//! # #[derive(Debug, Clone)]
193//! # struct Event { entity_id: uuid::Uuid, event_type: String }
194//! # #[derive(Debug)]
195//! # struct EventPublisher { events: Vec<Event>, tx: std::sync::mpsc::Sender<Event> }
196//! # impl CommitHook for EventPublisher {
197//! # async fn pre_commit(self, mut op: HookOperation<'_>) -> Result<PreCommitRet<'_, Self>, sqlx::Error> {
198//! # for event in &self.events {
199//! # sqlx::query!(
200//! # "INSERT INTO hook_events (entity_id, event_type, created_at) VALUES ($1, $2, NOW())",
201//! # event.entity_id, event.event_type
202//! # ).execute(op.as_executor()).await?;
203//! # }
204//! # PreCommitRet::ok(self, op)
205//! # }
206//! # fn post_commit(self) { for event in self.events { let _ = self.tx.send(event); } }
207//! # fn merge(&mut self, other: &mut Self) -> bool { self.events.append(&mut other.events); true }
208//! # }
209//! # async fn example(pool: db::Pool) -> Result<(), sqlx::Error> {
210//! let user_id = uuid::Uuid::nil();
211//! let (tx, _rx) = std::sync::mpsc::channel();
212//! let mut op = DbOp::init(&pool).await?;
213//!
214//! // Add first hook
215//! op.add_commit_hook(EventPublisher {
216//! events: vec![Event { entity_id: user_id, event_type: "user.created".to_string() }],
217//! tx: tx.clone(),
218//! }).expect("could not add hook");
219//!
220//! // Add second hook - will merge with the first
221//! op.add_commit_hook(EventPublisher {
222//! events: vec![Event { entity_id: user_id, event_type: "email.sent".to_string() }],
223//! tx: tx.clone(),
224//! }).expect("could not add hook");
225//!
226//! // Both hooks merge into one, events are stored in DB, then sent to channel
227//! op.commit().await?;
228//! # Ok(())
229//! # }
230//! ```
231
232use std::{
233 any::{Any, TypeId},
234 collections::VecDeque,
235 future::Future,
236 pin::Pin,
237};
238
239use crate::db;
240
241use super::AtomicOperation;
242
243/// Type alias for boxed async futures.
244pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
245
246/// Trait for implementing custom commit hooks that execute before and after transaction commits.
247///
248/// Hooks execute in order: [`pre_commit()`](Self::pre_commit) → database commit → [`post_commit()`](Self::post_commit).
249/// Multiple hooks of the same type can be merged via [`merge()`](Self::merge).
250///
251/// Hooks registered on the same operation execute in registration order — see the
252/// [module-level documentation](self#hook-ordering) for the full ordering contract
253/// and a complete example. [`runs_after()`](Self::runs_after) lets a hook refine
254/// that order by declaring dependency types it must run after.
255pub trait CommitHook: Send + 'static + Sized {
256 /// Called before the transaction commits. Can perform database operations.
257 ///
258 /// Errors returned here will roll back the transaction.
259 fn pre_commit(
260 self,
261 op: HookOperation<'_>,
262 ) -> impl Future<Output = Result<PreCommitRet<'_, Self>, sqlx::Error>> + Send {
263 async { PreCommitRet::ok(self, op) }
264 }
265
266 /// Called after successful commit. Cannot fail, not async.
267 fn post_commit(self) {
268 // Default: do nothing
269 }
270
271 /// Called when the operation's commit has **failed** after this hook's
272 /// [`pre_commit()`](Self::pre_commit) had already completed successfully.
273 ///
274 /// Two situations trigger it:
275 /// 1. A *later* hook's `pre_commit` returned an error. The transaction has
276 /// been **rolled back before this runs** — so any downstream side effect
277 /// the signal triggers never contends with the failed transaction's own
278 /// locks.
279 /// 2. The `COMMIT` itself returned an error. The transaction is over
280 /// server-side either way (it may have landed despite the client error,
281 /// or aborted), so downstream side effects must be idempotent against a
282 /// possibly-landed commit.
283 ///
284 /// Signal-only, synchronous and infallible — mirrors
285 /// [`post_commit()`](Self::post_commit). Do **not** perform database work
286 /// here (the transaction is gone and there is no async context); hand work
287 /// to an out-of-band task via a channel send / flag set instead.
288 ///
289 /// Not called when `pre_commit` never ran (an operation dropped without
290 /// `commit()` produced no effects to compensate), nor for the hook whose
291 /// own `pre_commit` failed — that hook is consumed by the failing call and
292 /// must signal from its own error branch.
293 fn on_rollback(self) {
294 // Default: do nothing
295 }
296
297 /// Try to merge another hook of the same type into this one.
298 ///
299 /// Returns `true` if merged (other will be dropped), `false` if not (both execute separately).
300 fn merge(&mut self, _other: &mut Self) -> bool {
301 false
302 }
303
304 /// Hook types (by `TypeId`) whose still-pending instances must run their
305 /// [`pre_commit`](Self::pre_commit) before this hook's.
306 ///
307 /// Consulted each time this hook reaches the front of the commit pass's
308 /// queue: if any *still-pending* hook in the pass has a type in this list,
309 /// this hook is deferred behind it and retried after other hooks execute.
310 /// A listed type that never registered on the operation — or whose
311 /// instances have all already executed — imposes no constraint.
312 ///
313 /// Declared per instance but effectively per type: instances that merge
314 /// keep the merge target's list, so all instances of one logical hook
315 /// should return the same list.
316 ///
317 /// Mutually-dependent hooks (A after B and B after A, directly or
318 /// transitively) cannot make progress; the commit fails loudly with a
319 /// protocol error and the transaction rolls back. Never list your own
320 /// type.
321 fn runs_after(&self) -> &[TypeId] {
322 &[]
323 }
324
325 /// Execute the hook immediately, bypassing the hook system.
326 ///
327 /// Useful when [`AtomicOperation::add_commit_hook()`] returns `Err(hook)`.
328 fn force_execute_pre_commit(
329 self,
330 op: &mut (impl AtomicOperation + ?Sized),
331 ) -> impl Future<Output = Result<Self, sqlx::Error>> + Send {
332 async {
333 let hook_op = HookOperation::new(op);
334 Ok(self.pre_commit(hook_op).await?.hook)
335 }
336 }
337}
338
339/// Wrapper around a database connection passed to [`CommitHook::pre_commit()`].
340///
341/// Implements [`AtomicOperation`] to allow executing database queries within the
342/// transaction. Whether it also supports registering *further* commit hooks depends
343/// on how it was constructed — see the `staged` field below and "Re-entrant
344/// Registration" in the [module docs](self).
345pub struct HookOperation<'c> {
346 now: Option<chrono::DateTime<chrono::Utc>>,
347 conn: &'c mut db::Connection,
348 /// Hooks registered by the `pre_commit` currently holding this op, staged for
349 /// the enclosing commit pass to fold into its own not-yet-executed tail once
350 /// that `pre_commit` returns.
351 ///
352 /// `Some` when this op was constructed by [`CommitHooks::execute_pre`] (a real
353 /// commit pass is running and can fold staged hooks in). `None` on the
354 /// [`force_execute_pre_commit`] path — there is no pass to fold into, so
355 /// registration there must keep failing, so callers take their
356 /// immediate-execution fallback instead of silently losing a hook's
357 /// `post_commit`/`on_rollback`.
358 ///
359 /// [`force_execute_pre_commit`]: CommitHook::force_execute_pre_commit
360 staged: Option<CommitHooks>,
361}
362
363impl<'c> HookOperation<'c> {
364 /// Force-execute path: no commit pass to fold into, so registering further
365 /// hooks stays unsupported.
366 fn new(op: &'c mut (impl AtomicOperation + ?Sized)) -> Self {
367 Self {
368 now: op.maybe_now(),
369 conn: op.connection(),
370 staged: None,
371 }
372 }
373
374 /// Commit-pass path: hooks registered while this op is threaded through
375 /// [`CommitHooks::execute_pre`] accumulate here.
376 fn staging(op: &'c mut impl AtomicOperation) -> Self {
377 Self {
378 now: op.maybe_now(),
379 conn: op.connection(),
380 staged: Some(CommitHooks::new()),
381 }
382 }
383
384 /// Takes whatever the `pre_commit` that just returned staged, leaving a fresh
385 /// empty buffer behind for the next hook. `None` on the force-execute path.
386 fn drain_staged(&mut self) -> Option<CommitHooks> {
387 Some(std::mem::take(self.staged.as_mut()?))
388 }
389}
390
391impl<'c> AtomicOperation for HookOperation<'c> {
392 fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
393 self.now
394 }
395
396 fn connection(&mut self) -> &mut db::Connection {
397 self.conn
398 }
399
400 fn add_commit_hook_dyn(
401 &mut self,
402 type_id: TypeId,
403 hook: Box<dyn DynHook>,
404 ) -> Result<(), Box<dyn DynHook>> {
405 match self.staged.as_mut() {
406 Some(staged) => {
407 staged.push_or_merge(type_id, hook);
408 Ok(())
409 }
410 None => Err(hook),
411 }
412 }
413
414 fn commit_hook_dyn(&self, type_id: TypeId) -> Option<&dyn DynHook> {
415 self.staged.as_ref()?.get_last_dyn(type_id)
416 }
417
418 fn supports_hooks(&self) -> bool {
419 self.staged.is_some()
420 }
421
422 /// Lets a hook's own `pre_commit` isolate its multi-statement work in a
423 /// `SAVEPOINT` — see [`SavepointOperation`](super::SavepointOperation).
424 ///
425 /// Hooks registered inside stage normally and, on
426 /// [`release`](super::SavepointOp::release), fold into this
427 /// `HookOperation`'s own buffer exactly like a top-level savepoint folds
428 /// into a `DbOp` — *if* this `HookOperation` is on a real commit pass
429 /// ([`supports_hooks`](AtomicOperation::supports_hooks) is `true`). On the
430 /// [`force_execute_pre_commit`](CommitHook::force_execute_pre_commit) path
431 /// `staged` is `None`, so registering a hook inside the savepoint fails the
432 /// same way it would have on this `HookOperation` directly — the
433 /// `SAVEPOINT`/`RELEASE`/`ROLLBACK` machinery itself still works, since it
434 /// needs only the connection.
435 fn savepoint_parts(&mut self) -> (&mut db::Connection, super::savepoint::HookSlot<'_>) {
436 (
437 &mut *self.conn,
438 super::savepoint::HookSlot(self.staged.as_mut()),
439 )
440 }
441}
442
443/// Return type for [`CommitHook::pre_commit()`].
444///
445/// Use [`PreCommitRet::ok()`] to construct: `PreCommitRet::ok(self, op)`.
446pub struct PreCommitRet<'c, H> {
447 op: HookOperation<'c>,
448 hook: H,
449}
450
451impl<'c, H> PreCommitRet<'c, H> {
452 /// Creates a successful pre-commit result.
453 pub fn ok(hook: H, op: HookOperation<'c>) -> Result<Self, sqlx::Error> {
454 Ok(Self { op, hook })
455 }
456}
457
458// --- Object-safe internal trait ---
459pub trait DynHook: Send {
460 #[allow(clippy::type_complexity)]
461 fn pre_commit_boxed<'c>(
462 self: Box<Self>,
463 op: HookOperation<'c>,
464 ) -> BoxFuture<'c, Result<(HookOperation<'c>, Box<dyn DynHook>), sqlx::Error>>;
465
466 fn post_commit_boxed(self: Box<Self>);
467
468 fn on_rollback_boxed(self: Box<Self>);
469
470 fn try_merge(&mut self, other: &mut dyn DynHook) -> bool;
471
472 fn runs_after(&self) -> &[TypeId];
473
474 fn as_any(&self) -> &dyn Any;
475
476 fn as_any_mut(&mut self) -> &mut dyn Any;
477
478 fn into_any(self: Box<Self>) -> Box<dyn Any>;
479}
480
481impl<H: CommitHook> DynHook for H {
482 fn pre_commit_boxed<'c>(
483 self: Box<Self>,
484 op: HookOperation<'c>,
485 ) -> BoxFuture<'c, Result<(HookOperation<'c>, Box<dyn DynHook>), sqlx::Error>> {
486 Box::pin(async move {
487 let ret = self.pre_commit(op).await?;
488 Ok((ret.op, Box::new(ret.hook) as Box<dyn DynHook>))
489 })
490 }
491
492 fn post_commit_boxed(self: Box<Self>) {
493 (*self).post_commit()
494 }
495
496 fn on_rollback_boxed(self: Box<Self>) {
497 (*self).on_rollback()
498 }
499
500 fn try_merge(&mut self, other: &mut dyn DynHook) -> bool {
501 let other_h = other
502 .as_any_mut()
503 .downcast_mut::<H>()
504 .expect("hook type mismatch");
505 self.merge(other_h)
506 }
507
508 fn runs_after(&self) -> &[TypeId] {
509 // UFCS is REQUIRED: `self.runs_after()` is ambiguous here because H
510 // implements both CommitHook and (this very) DynHook.
511 CommitHook::runs_after(self)
512 }
513
514 fn as_any(&self) -> &dyn Any {
515 self
516 }
517
518 fn as_any_mut(&mut self) -> &mut dyn Any {
519 self
520 }
521
522 fn into_any(self: Box<Self>) -> Box<dyn Any> {
523 self
524 }
525}
526
527/// Hooks are stored in a single flat insertion-ordered vec so that
528/// [`execute_pre`](Self::execute_pre) runs them in registration order — per hook,
529/// not per type. See the [module-level documentation](self#hook-ordering) for the
530/// ordering contract.
531pub(crate) struct CommitHooks {
532 hooks: Vec<(TypeId, Box<dyn DynHook>)>,
533}
534
535impl CommitHooks {
536 pub fn new() -> Self {
537 Self { hooks: Vec::new() }
538 }
539
540 /// Folds the hooks staged by a released [`SavepointOp`] into this buffer.
541 ///
542 /// Replays them through the same path as [`push_or_merge`](Self::push_or_merge),
543 /// in their staging order, so the result is indistinguishable from having
544 /// registered them on this operation directly: mergeable types accumulate
545 /// into the earlier instance (keeping its position), non-mergeable ones
546 /// append.
547 ///
548 /// [`SavepointOp`]: super::SavepointOp
549 pub(super) fn absorb_staged(&mut self, staged: Self) {
550 for (type_id, hook) in staged.hooks {
551 self.push_or_merge(type_id, hook);
552 }
553 }
554
555 pub(crate) fn push_or_merge(&mut self, type_id: TypeId, mut new_hook: Box<dyn DynHook>) {
556 // Merge with the most recently added hook of the same type, keeping the
557 // existing hook's original (earlier) position in the execution order.
558 if let Some((_, existing)) = self.hooks.iter_mut().rev().find(|(t, _)| *t == type_id)
559 && existing.try_merge(new_hook.as_mut())
560 {
561 return;
562 }
563
564 self.hooks.push((type_id, new_hook));
565 }
566
567 pub(super) fn is_empty(&self) -> bool {
568 self.hooks.is_empty()
569 }
570
571 pub(crate) fn get_last_dyn(&self, type_id: TypeId) -> Option<&dyn DynHook> {
572 self.hooks
573 .iter()
574 .rev()
575 .find(|(t, _)| *t == type_id)
576 .map(|(_, hook)| hook.as_ref())
577 }
578
579 /// Runs each hook's `pre_commit` in registration order.
580 ///
581 /// Re-entrant: a hook may register further hooks via
582 /// [`AtomicOperation::add_commit_hook`] on the [`HookOperation`] it is handed.
583 /// Those join the **tail of this same pass** — merged into a still-pending hook
584 /// of the same type (keeping that hook's position), or appended fresh if no
585 /// pending hook of that type remains (an already-executed hook is never a merge
586 /// target). [`MAX_HOOK_GENERATIONS`] bounds the resulting chain against a
587 /// registration cycle. See the [module docs](self#re-entrant-registration).
588 ///
589 /// On failure the already-executed hooks travel back **with** the error (as
590 /// a [`PostCommitHooks`]) instead of being dropped, so the caller can fire
591 /// their [`CommitHook::on_rollback`] after rolling the transaction back.
592 /// Hooks after the failing one — pending or not yet staged — never ran their
593 /// `pre_commit`, produced no effects, and are simply dropped.
594 ///
595 /// A hook whose [`CommitHook::runs_after()`] names a still-pending type is
596 /// deferred to the back of the queue instead of executing — see the
597 /// [module docs](self#hook-ordering). Declared dependencies that can never
598 /// all be satisfied (a cycle) are detected when every remaining hook has been
599 /// deferred once in a row without any execution in between, and fail the
600 /// commit loudly instead of spinning inside an open transaction.
601 pub(super) async fn execute_pre(
602 self,
603 op: &mut impl AtomicOperation,
604 ) -> Result<PostCommitHooks, (sqlx::Error, PostCommitHooks)> {
605 let mut op = HookOperation::staging(op);
606 let mut pending: VecDeque<(TypeId, Box<dyn DynHook>, u8)> = self
607 .hooks
608 .into_iter()
609 .map(|(type_id, hook)| (type_id, hook, 0))
610 .collect();
611 let mut post_hooks = Vec::with_capacity(pending.len());
612 let mut deferred_streak = 0usize;
613
614 while let Some((type_id, hook, generation)) = pending.pop_front() {
615 // Deferral: a hook waits while any still-pending hook of a declared
616 // dependency type exists. Evaluated dynamically on every pop, so it
617 // composes with re-entrant staging (a dep staged mid-pass re-blocks
618 // a hook that already deferred past it).
619 let blocked = hook
620 .runs_after()
621 .iter()
622 .any(|dep| pending.iter().any(|(t, _, _)| t == dep));
623 if blocked {
624 pending.push_back((type_id, hook, generation));
625 deferred_streak += 1;
626 if deferred_streak >= pending.len() {
627 // Every remaining hook deferred consecutively without any
628 // execution in between → the declared dependencies form a
629 // cycle. Fail loudly instead of spinning inside an open
630 // transaction.
631 let error = sqlx::Error::Protocol(format!(
632 "commit hook runs_after dependencies form a cycle among \
633 {} pending hooks — no hook can execute",
634 pending.len()
635 ));
636 return Err((error, PostCommitHooks { hooks: post_hooks }));
637 }
638 continue;
639 }
640 deferred_streak = 0;
641
642 match hook.pre_commit_boxed(op).await {
643 Ok((mut new_op, hook)) => {
644 post_hooks.push(hook);
645
646 // Fold whatever that `pre_commit` staged into the tail of this
647 // same pass, through the same registration/merge path
648 // `absorb_staged` uses for released savepoints.
649 if let Some(staged) = new_op.drain_staged() {
650 let next_generation = generation.saturating_add(1);
651 for (type_id, staged_hook) in staged.hooks {
652 if let Err(error) = push_or_merge_pending(
653 &mut pending,
654 type_id,
655 staged_hook,
656 next_generation,
657 ) {
658 return Err((error, PostCommitHooks { hooks: post_hooks }));
659 }
660 }
661 }
662
663 op = new_op;
664 }
665 Err(error) => {
666 return Err((error, PostCommitHooks { hooks: post_hooks }));
667 }
668 }
669 }
670
671 Ok(PostCommitHooks { hooks: post_hooks })
672 }
673}
674
675impl Default for CommitHooks {
676 fn default() -> Self {
677 Self::new()
678 }
679}
680
681/// Maximum number of re-entrant "generations" a single commit pass will execute.
682/// Generation 0 is the hook set registered on the operation before `commit()`
683/// starts; a hook staged by a generation-*N* hook's `pre_commit` is generation
684/// *N*+1. Bounds a registration cycle (A registers B, B registers A, …), which
685/// would otherwise grow the queue forever inside an open transaction — see the
686/// [module docs](self#re-entrant-registration).
687pub const MAX_HOOK_GENERATIONS: u8 = 8;
688
689/// Merges `hook` into a still-pending hook of the same type — keeping that hook's
690/// queue position and generation — or appends it fresh at `generation`. The
691/// deferred-execution counterpart of [`CommitHooks::push_or_merge`]: only hooks that
692/// have not yet run their `pre_commit` are eligible merge targets, so a type that
693/// already executed this pass always gets a fresh instance rather than retroactively
694/// absorbing new work into a hook whose `pre_commit` already returned.
695///
696/// Errors if the fresh instance would exceed [`MAX_HOOK_GENERATIONS`] — the loud,
697/// bounded failure mode for a registration cycle instead of an unbounded queue
698/// inside an open transaction.
699fn push_or_merge_pending(
700 pending: &mut VecDeque<(TypeId, Box<dyn DynHook>, u8)>,
701 type_id: TypeId,
702 mut hook: Box<dyn DynHook>,
703 generation: u8,
704) -> Result<(), sqlx::Error> {
705 if let Some((_, existing, _)) = pending.iter_mut().rev().find(|(t, _, _)| *t == type_id)
706 && existing.try_merge(hook.as_mut())
707 {
708 return Ok(());
709 }
710
711 if generation > MAX_HOOK_GENERATIONS {
712 return Err(sqlx::Error::Protocol(format!(
713 "commit hook registration exceeded the maximum of {MAX_HOOK_GENERATIONS} \
714 re-entrant generations in one commit pass — likely a registration cycle"
715 )));
716 }
717
718 pending.push_back((type_id, hook, generation));
719 Ok(())
720}
721
722pub struct PostCommitHooks {
723 hooks: Vec<Box<dyn DynHook>>,
724}
725
726impl PostCommitHooks {
727 pub(super) fn execute(self) {
728 for hook in self.hooks {
729 hook.post_commit_boxed();
730 }
731 }
732
733 /// Fires [`CommitHook::on_rollback`] on each already-pre_committed hook in
734 /// registration order (same order as [`execute`](Self::execute)). Sync and
735 /// infallible, mirroring `execute`.
736 pub(super) fn execute_rollback(self) {
737 for hook in self.hooks {
738 hook.on_rollback_boxed();
739 }
740 }
741}