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. The hook set is frozen when `commit()` starts (hooks cannot register further
40//!    hooks), so ordering is fully determined before the first `pre_commit` runs.
41//!
42//! Registration order is a determinism guarantee, not a priority mechanism — there
43//! is no way to reorder hooks independently of the order in which they were added.
44//!
45//! # Savepoints
46//!
47//! Hooks registered on a [`SavepointOp`] are staged and only enter the parent
48//! operation's set — through the same registration/merge path — when the savepoint
49//! is released; a rolled-back savepoint discards them. No callback runs at a
50//! savepoint boundary, so the lifecycle above is unchanged: one `pre_commit` pass at
51//! the parent's commit, `post_commit` only after a durable `COMMIT`. See
52//! [`SavepointOp`] for details.
53//!
54//! [`SavepointOp`]: super::SavepointOp
55//!
56//! # Examples
57//!
58//! ## Hook with Database Operations and Channel-Based Publishing
59//!
60//! This example shows a complete event publishing hook that:
61//! - Stores events in the database during pre-commit (within the transaction)
62//! - Sends events to a channel during post-commit for async processing
63//! - Merges multiple hook instances to batch operations
64//!
65//! Note: `post_commit()` is synchronous and cannot fail, so it's best used for
66//! fire-and-forget operations like sending to channels. A background task can then
67//! handle the async work of publishing to external systems.
68//!
69//! ```
70//! use es_entity::{AtomicOperation, operation::hooks::{CommitHook, HookOperation, PreCommitRet}};
71//!
72//! #[derive(Debug, Clone)]
73//! struct Event {
74//!     entity_id: uuid::Uuid,
75//!     event_type: String,
76//! }
77//!
78//! #[derive(Debug)]
79//! struct EventPublisher {
80//!     events: Vec<Event>,
81//!     // Channel sender for publishing events to a background processor
82//!     // In production, this might be tokio::sync::mpsc::Sender or similar
83//!     tx: std::sync::mpsc::Sender<Event>,
84//! }
85//!
86//! impl CommitHook for EventPublisher {
87//!     async fn pre_commit(self, mut op: HookOperation<'_>)
88//!         -> Result<PreCommitRet<'_, Self>, sqlx::Error>
89//!     {
90//!         // Store events in the database within the transaction
91//!         // If the transaction fails, these inserts will be rolled back
92//!         for event in &self.events {
93//!             sqlx::query!(
94//!                 "INSERT INTO hook_events (entity_id, event_type, created_at) VALUES ($1, $2, NOW())",
95//!                 event.entity_id,
96//!                 event.event_type
97//!             )
98//!             .execute(op.as_executor())
99//!             .await?;
100//!         }
101//!
102//!         PreCommitRet::ok(self, op)
103//!     }
104//!
105//!     fn post_commit(self) {
106//!         // Send events to a channel for async processing
107//!         // This only runs if the transaction succeeded
108//!         // Channel sends are fast and don't block; a background task handles publishing
109//!         for event in self.events {
110//!             // In production, handle send failures appropriately (logging, metrics, etc.)
111//!             // The channel might be bounded to apply backpressure
112//!             let _ = self.tx.send(event);
113//!         }
114//!     }
115//!
116//!     fn merge(&mut self, other: &mut Self) -> bool {
117//!         // Merge multiple EventPublisher hooks into one to batch operations
118//!         self.events.append(&mut other.events);
119//!         true
120//!     }
121//! }
122//!
123//! // Separate background task for async event publishing
124//! // async fn event_publisher_task(mut rx: tokio::sync::mpsc::Receiver<Event>) {
125//! //     while let Some(event) = rx.recv().await {
126//! //         // Publish to Kafka, RabbitMQ, webhooks, etc.
127//! //         // Handle failures with retries, dead-letter queues, etc.
128//! //         match publish_to_external_system(&event).await {
129//! //             Ok(_) => log::info!("Published event: {:?}", event),
130//! //             Err(e) => log::error!("Failed to publish event: {:?}", e),
131//! //         }
132//! //     }
133//! // }
134//! ```
135//!
136//! ## Usage
137//!
138//! ```no_run
139//! # use es_entity::{AtomicOperation, DbOp, operation::hooks::{CommitHook, HookOperation, PreCommitRet}};
140//! # use es_entity::db;
141//! # #[derive(Debug, Clone)]
142//! # struct Event { entity_id: uuid::Uuid, event_type: String }
143//! # #[derive(Debug)]
144//! # struct EventPublisher { events: Vec<Event>, tx: std::sync::mpsc::Sender<Event> }
145//! # impl CommitHook for EventPublisher {
146//! #     async fn pre_commit(self, mut op: HookOperation<'_>) -> Result<PreCommitRet<'_, Self>, sqlx::Error> {
147//! #         for event in &self.events {
148//! #             sqlx::query!(
149//! #                 "INSERT INTO hook_events (entity_id, event_type, created_at) VALUES ($1, $2, NOW())",
150//! #                 event.entity_id, event.event_type
151//! #             ).execute(op.as_executor()).await?;
152//! #         }
153//! #         PreCommitRet::ok(self, op)
154//! #     }
155//! #     fn post_commit(self) { for event in self.events { let _ = self.tx.send(event); } }
156//! #     fn merge(&mut self, other: &mut Self) -> bool { self.events.append(&mut other.events); true }
157//! # }
158//! # async fn example(pool: db::Pool) -> Result<(), sqlx::Error> {
159//! let user_id = uuid::Uuid::nil();
160//! let (tx, _rx) = std::sync::mpsc::channel();
161//! let mut op = DbOp::init(&pool).await?;
162//!
163//! // Add first hook
164//! op.add_commit_hook(EventPublisher {
165//!     events: vec![Event { entity_id: user_id, event_type: "user.created".to_string() }],
166//!     tx: tx.clone(),
167//! }).expect("could not add hook");
168//!
169//! // Add second hook - will merge with the first
170//! op.add_commit_hook(EventPublisher {
171//!     events: vec![Event { entity_id: user_id, event_type: "email.sent".to_string() }],
172//!     tx: tx.clone(),
173//! }).expect("could not add hook");
174//!
175//! // Both hooks merge into one, events are stored in DB, then sent to channel
176//! op.commit().await?;
177//! # Ok(())
178//! # }
179//! ```
180
181use std::{
182    any::{Any, TypeId},
183    future::Future,
184    pin::Pin,
185};
186
187use crate::db;
188
189use super::AtomicOperation;
190
191/// Type alias for boxed async futures.
192pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
193
194/// Trait for implementing custom commit hooks that execute before and after transaction commits.
195///
196/// Hooks execute in order: [`pre_commit()`](Self::pre_commit) → database commit → [`post_commit()`](Self::post_commit).
197/// Multiple hooks of the same type can be merged via [`merge()`](Self::merge).
198///
199/// Hooks registered on the same operation execute in registration order — see the
200/// [module-level documentation](self#hook-ordering) for the full ordering contract
201/// and a complete example.
202pub trait CommitHook: Send + 'static + Sized {
203    /// Called before the transaction commits. Can perform database operations.
204    ///
205    /// Errors returned here will roll back the transaction.
206    fn pre_commit(
207        self,
208        op: HookOperation<'_>,
209    ) -> impl Future<Output = Result<PreCommitRet<'_, Self>, sqlx::Error>> + Send {
210        async { PreCommitRet::ok(self, op) }
211    }
212
213    /// Called after successful commit. Cannot fail, not async.
214    fn post_commit(self) {
215        // Default: do nothing
216    }
217
218    /// Called when the operation's commit has **failed** after this hook's
219    /// [`pre_commit()`](Self::pre_commit) had already completed successfully.
220    ///
221    /// Two situations trigger it:
222    /// 1. A *later* hook's `pre_commit` returned an error. The transaction has
223    ///    been **rolled back before this runs** — so any downstream side effect
224    ///    the signal triggers never contends with the failed transaction's own
225    ///    locks.
226    /// 2. The `COMMIT` itself returned an error. The transaction is over
227    ///    server-side either way (it may have landed despite the client error,
228    ///    or aborted), so downstream side effects must be idempotent against a
229    ///    possibly-landed commit.
230    ///
231    /// Signal-only, synchronous and infallible — mirrors
232    /// [`post_commit()`](Self::post_commit). Do **not** perform database work
233    /// here (the transaction is gone and there is no async context); hand work
234    /// to an out-of-band task via a channel send / flag set instead.
235    ///
236    /// Not called when `pre_commit` never ran (an operation dropped without
237    /// `commit()` produced no effects to compensate), nor for the hook whose
238    /// own `pre_commit` failed — that hook is consumed by the failing call and
239    /// must signal from its own error branch.
240    fn on_rollback(self) {
241        // Default: do nothing
242    }
243
244    /// Try to merge another hook of the same type into this one.
245    ///
246    /// Returns `true` if merged (other will be dropped), `false` if not (both execute separately).
247    fn merge(&mut self, _other: &mut Self) -> bool {
248        false
249    }
250
251    /// Execute the hook immediately, bypassing the hook system.
252    ///
253    /// Useful when [`AtomicOperation::add_commit_hook()`] returns `Err(hook)`.
254    fn force_execute_pre_commit(
255        self,
256        op: &mut impl AtomicOperation,
257    ) -> impl Future<Output = Result<Self, sqlx::Error>> + Send {
258        async {
259            let hook_op = HookOperation::new(op);
260            Ok(self.pre_commit(hook_op).await?.hook)
261        }
262    }
263}
264
265/// Wrapper around a database connection passed to [`CommitHook::pre_commit()`].
266///
267/// Implements [`AtomicOperation`] to allow executing database queries within the transaction.
268pub struct HookOperation<'c> {
269    now: Option<chrono::DateTime<chrono::Utc>>,
270    conn: &'c mut db::Connection,
271}
272
273impl<'c> HookOperation<'c> {
274    fn new(op: &'c mut impl AtomicOperation) -> Self {
275        Self {
276            now: op.maybe_now(),
277            conn: op.connection(),
278        }
279    }
280}
281
282impl<'c> AtomicOperation for HookOperation<'c> {
283    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
284        self.now
285    }
286
287    fn connection(&mut self) -> &mut db::Connection {
288        self.conn
289    }
290}
291
292/// Return type for [`CommitHook::pre_commit()`].
293///
294/// Use [`PreCommitRet::ok()`] to construct: `PreCommitRet::ok(self, op)`.
295pub struct PreCommitRet<'c, H> {
296    op: HookOperation<'c>,
297    hook: H,
298}
299
300impl<'c, H> PreCommitRet<'c, H> {
301    /// Creates a successful pre-commit result.
302    pub fn ok(hook: H, op: HookOperation<'c>) -> Result<Self, sqlx::Error> {
303        Ok(Self { op, hook })
304    }
305}
306
307// --- Object-safe internal trait ---
308trait DynHook: Send {
309    #[allow(clippy::type_complexity)]
310    fn pre_commit_boxed<'c>(
311        self: Box<Self>,
312        op: HookOperation<'c>,
313    ) -> BoxFuture<'c, Result<(HookOperation<'c>, Box<dyn DynHook>), sqlx::Error>>;
314
315    fn post_commit_boxed(self: Box<Self>);
316
317    fn on_rollback_boxed(self: Box<Self>);
318
319    fn try_merge(&mut self, other: &mut dyn DynHook) -> bool;
320
321    fn as_any(&self) -> &dyn Any;
322
323    fn as_any_mut(&mut self) -> &mut dyn Any;
324}
325
326impl<H: CommitHook> DynHook for H {
327    fn pre_commit_boxed<'c>(
328        self: Box<Self>,
329        op: HookOperation<'c>,
330    ) -> BoxFuture<'c, Result<(HookOperation<'c>, Box<dyn DynHook>), sqlx::Error>> {
331        Box::pin(async move {
332            let ret = self.pre_commit(op).await?;
333            Ok((ret.op, Box::new(ret.hook) as Box<dyn DynHook>))
334        })
335    }
336
337    fn post_commit_boxed(self: Box<Self>) {
338        (*self).post_commit()
339    }
340
341    fn on_rollback_boxed(self: Box<Self>) {
342        (*self).on_rollback()
343    }
344
345    fn try_merge(&mut self, other: &mut dyn DynHook) -> bool {
346        let other_h = other
347            .as_any_mut()
348            .downcast_mut::<H>()
349            .expect("hook type mismatch");
350        self.merge(other_h)
351    }
352
353    fn as_any(&self) -> &dyn Any {
354        self
355    }
356
357    fn as_any_mut(&mut self) -> &mut dyn Any {
358        self
359    }
360}
361
362/// Hooks are stored in a single flat insertion-ordered vec so that
363/// [`execute_pre`](Self::execute_pre) runs them in registration order — per hook,
364/// not per type. See the [module-level documentation](self#hook-ordering) for the
365/// ordering contract.
366pub(crate) struct CommitHooks {
367    hooks: Vec<(TypeId, Box<dyn DynHook>)>,
368}
369
370impl CommitHooks {
371    pub fn new() -> Self {
372        Self { hooks: Vec::new() }
373    }
374
375    pub(super) fn add<H: CommitHook>(&mut self, hook: H) {
376        self.push_or_merge(TypeId::of::<H>(), Box::new(hook));
377    }
378
379    /// Folds the hooks staged by a released [`SavepointOp`] into this buffer.
380    ///
381    /// Replays them through the same path as [`add`](Self::add), in their
382    /// staging order, so the result is indistinguishable from having registered
383    /// them on this operation directly: mergeable types accumulate into the
384    /// earlier instance (keeping its position), non-mergeable ones append.
385    ///
386    /// [`SavepointOp`]: super::SavepointOp
387    pub(super) fn absorb_staged(&mut self, staged: Self) {
388        for (type_id, hook) in staged.hooks {
389            self.push_or_merge(type_id, hook);
390        }
391    }
392
393    fn push_or_merge(&mut self, type_id: TypeId, mut new_hook: Box<dyn DynHook>) {
394        // Merge with the most recently added hook of the same type, keeping the
395        // existing hook's original (earlier) position in the execution order.
396        if let Some((_, existing)) = self.hooks.iter_mut().rev().find(|(t, _)| *t == type_id)
397            && existing.try_merge(new_hook.as_mut())
398        {
399            return;
400        }
401
402        self.hooks.push((type_id, new_hook));
403    }
404
405    pub(super) fn get_last<H: CommitHook>(&self) -> Option<&H> {
406        self.hooks
407            .iter()
408            .rev()
409            .find(|(t, _)| *t == TypeId::of::<H>())
410            .and_then(|(_, hook)| hook.as_any().downcast_ref::<H>())
411    }
412
413    /// Runs each hook's `pre_commit` in registration order.
414    ///
415    /// On failure the already-executed hooks travel back **with** the error (as
416    /// a [`PostCommitHooks`]) instead of being dropped, so the caller can fire
417    /// their [`CommitHook::on_rollback`] after rolling the transaction back.
418    /// Hooks after the failing one never ran their `pre_commit`, produced no
419    /// effects, and are simply dropped.
420    pub(super) async fn execute_pre(
421        self,
422        op: &mut impl AtomicOperation,
423    ) -> Result<PostCommitHooks, (sqlx::Error, PostCommitHooks)> {
424        let mut op = HookOperation::new(op);
425        let mut post_hooks = Vec::with_capacity(self.hooks.len());
426
427        for (_, hook) in self.hooks {
428            match hook.pre_commit_boxed(op).await {
429                Ok((new_op, hook)) => {
430                    op = new_op;
431                    post_hooks.push(hook);
432                }
433                Err(error) => {
434                    return Err((error, PostCommitHooks { hooks: post_hooks }));
435                }
436            }
437        }
438
439        Ok(PostCommitHooks { hooks: post_hooks })
440    }
441}
442
443impl Default for CommitHooks {
444    fn default() -> Self {
445        Self::new()
446    }
447}
448
449pub struct PostCommitHooks {
450    hooks: Vec<Box<dyn DynHook>>,
451}
452
453impl PostCommitHooks {
454    pub(super) fn execute(self) {
455        for hook in self.hooks {
456            hook.post_commit_boxed();
457        }
458    }
459
460    /// Fires [`CommitHook::on_rollback`] on each already-pre_committed hook in
461    /// registration order (same order as [`execute`](Self::execute)). Sync and
462    /// infallible, mirroring `execute`.
463    pub(super) fn execute_rollback(self) {
464        for hook in self.hooks {
465            hook.on_rollback_boxed();
466        }
467    }
468}