Skip to main content

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