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