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