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//! # Hook Ordering
20//!
21//! Hooks execute in **registration order** — per hook, not per type:
22//!
23//! 1. Hooks run in the order they were added to the operation, regardless of their
24//!    type. A hook that refuses to merge ([`CommitHook::merge()`] returns `false`)
25//!    executes at its own (later) registration position, even when an earlier hook
26//!    of the same type exists.
27//! 2. A hook that merges executes at the position of the hook it merged into (the
28//!    earlier one). For always-merging hook types this means the type's position is
29//!    anchored by its **first** registration in the operation; all later
30//!    registrations fold into that position.
31//! 3. [`CommitHook::post_commit()`] hooks run in the same order as their
32//!    [`CommitHook::pre_commit()`] counterparts (registration order).
33//! 4. The hook set is frozen when `commit()` starts (hooks cannot register further
34//!    hooks), so ordering is fully determined before the first `pre_commit` runs.
35//!
36//! Registration order is a determinism guarantee, not a priority mechanism — there
37//! is no way to reorder hooks independently of the order in which they were added.
38//!
39//! # Examples
40//!
41//! ## Hook with Database Operations and Channel-Based Publishing
42//!
43//! This example shows a complete event publishing hook that:
44//! - Stores events in the database during pre-commit (within the transaction)
45//! - Sends events to a channel during post-commit for async processing
46//! - Merges multiple hook instances to batch operations
47//!
48//! Note: `post_commit()` is synchronous and cannot fail, so it's best used for
49//! fire-and-forget operations like sending to channels. A background task can then
50//! handle the async work of publishing to external systems.
51//!
52//! ```
53//! use es_entity::{AtomicOperation, operation::hooks::{CommitHook, HookOperation, PreCommitRet}};
54//!
55//! #[derive(Debug, Clone)]
56//! struct Event {
57//!     entity_id: uuid::Uuid,
58//!     event_type: String,
59//! }
60//!
61//! #[derive(Debug)]
62//! struct EventPublisher {
63//!     events: Vec<Event>,
64//!     // Channel sender for publishing events to a background processor
65//!     // In production, this might be tokio::sync::mpsc::Sender or similar
66//!     tx: std::sync::mpsc::Sender<Event>,
67//! }
68//!
69//! impl CommitHook for EventPublisher {
70//!     async fn pre_commit(self, mut op: HookOperation<'_>)
71//!         -> Result<PreCommitRet<'_, Self>, sqlx::Error>
72//!     {
73//!         // Store events in the database within the transaction
74//!         // If the transaction fails, these inserts will be rolled back
75//!         for event in &self.events {
76//!             sqlx::query!(
77//!                 "INSERT INTO hook_events (entity_id, event_type, created_at) VALUES ($1, $2, NOW())",
78//!                 event.entity_id,
79//!                 event.event_type
80//!             )
81//!             .execute(op.as_executor())
82//!             .await?;
83//!         }
84//!
85//!         PreCommitRet::ok(self, op)
86//!     }
87//!
88//!     fn post_commit(self) {
89//!         // Send events to a channel for async processing
90//!         // This only runs if the transaction succeeded
91//!         // Channel sends are fast and don't block; a background task handles publishing
92//!         for event in self.events {
93//!             // In production, handle send failures appropriately (logging, metrics, etc.)
94//!             // The channel might be bounded to apply backpressure
95//!             let _ = self.tx.send(event);
96//!         }
97//!     }
98//!
99//!     fn merge(&mut self, other: &mut Self) -> bool {
100//!         // Merge multiple EventPublisher hooks into one to batch operations
101//!         self.events.append(&mut other.events);
102//!         true
103//!     }
104//! }
105//!
106//! // Separate background task for async event publishing
107//! // async fn event_publisher_task(mut rx: tokio::sync::mpsc::Receiver<Event>) {
108//! //     while let Some(event) = rx.recv().await {
109//! //         // Publish to Kafka, RabbitMQ, webhooks, etc.
110//! //         // Handle failures with retries, dead-letter queues, etc.
111//! //         match publish_to_external_system(&event).await {
112//! //             Ok(_) => log::info!("Published event: {:?}", event),
113//! //             Err(e) => log::error!("Failed to publish event: {:?}", e),
114//! //         }
115//! //     }
116//! // }
117//! ```
118//!
119//! ## Usage
120//!
121//! ```no_run
122//! # use es_entity::{AtomicOperation, DbOp, operation::hooks::{CommitHook, HookOperation, PreCommitRet}};
123//! # use es_entity::db;
124//! # #[derive(Debug, Clone)]
125//! # struct Event { entity_id: uuid::Uuid, event_type: String }
126//! # #[derive(Debug)]
127//! # struct EventPublisher { events: Vec<Event>, tx: std::sync::mpsc::Sender<Event> }
128//! # impl CommitHook for EventPublisher {
129//! #     async fn pre_commit(self, mut op: HookOperation<'_>) -> Result<PreCommitRet<'_, Self>, sqlx::Error> {
130//! #         for event in &self.events {
131//! #             sqlx::query!(
132//! #                 "INSERT INTO hook_events (entity_id, event_type, created_at) VALUES ($1, $2, NOW())",
133//! #                 event.entity_id, event.event_type
134//! #             ).execute(op.as_executor()).await?;
135//! #         }
136//! #         PreCommitRet::ok(self, op)
137//! #     }
138//! #     fn post_commit(self) { for event in self.events { let _ = self.tx.send(event); } }
139//! #     fn merge(&mut self, other: &mut Self) -> bool { self.events.append(&mut other.events); true }
140//! # }
141//! # async fn example(pool: db::Pool) -> Result<(), sqlx::Error> {
142//! let user_id = uuid::Uuid::nil();
143//! let (tx, _rx) = std::sync::mpsc::channel();
144//! let mut op = DbOp::init(&pool).await?;
145//!
146//! // Add first hook
147//! op.add_commit_hook(EventPublisher {
148//!     events: vec![Event { entity_id: user_id, event_type: "user.created".to_string() }],
149//!     tx: tx.clone(),
150//! }).expect("could not add hook");
151//!
152//! // Add second hook - will merge with the first
153//! op.add_commit_hook(EventPublisher {
154//!     events: vec![Event { entity_id: user_id, event_type: "email.sent".to_string() }],
155//!     tx: tx.clone(),
156//! }).expect("could not add hook");
157//!
158//! // Both hooks merge into one, events are stored in DB, then sent to channel
159//! op.commit().await?;
160//! # Ok(())
161//! # }
162//! ```
163
164use std::{
165    any::{Any, TypeId},
166    future::Future,
167    pin::Pin,
168};
169
170use crate::db;
171
172use super::AtomicOperation;
173
174/// Type alias for boxed async futures.
175pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
176
177/// Trait for implementing custom commit hooks that execute before and after transaction commits.
178///
179/// Hooks execute in order: [`pre_commit()`](Self::pre_commit) → database commit → [`post_commit()`](Self::post_commit).
180/// Multiple hooks of the same type can be merged via [`merge()`](Self::merge).
181///
182/// Hooks registered on the same operation execute in registration order — see the
183/// [module-level documentation](self#hook-ordering) for the full ordering contract
184/// and a complete example.
185pub trait CommitHook: Send + 'static + Sized {
186    /// Called before the transaction commits. Can perform database operations.
187    ///
188    /// Errors returned here will roll back the transaction.
189    fn pre_commit(
190        self,
191        op: HookOperation<'_>,
192    ) -> impl Future<Output = Result<PreCommitRet<'_, Self>, sqlx::Error>> + Send {
193        async { PreCommitRet::ok(self, op) }
194    }
195
196    /// Called after successful commit. Cannot fail, not async.
197    fn post_commit(self) {
198        // Default: do nothing
199    }
200
201    /// Try to merge another hook of the same type into this one.
202    ///
203    /// Returns `true` if merged (other will be dropped), `false` if not (both execute separately).
204    fn merge(&mut self, _other: &mut Self) -> bool {
205        false
206    }
207
208    /// Execute the hook immediately, bypassing the hook system.
209    ///
210    /// Useful when [`AtomicOperation::add_commit_hook()`] returns `Err(hook)`.
211    fn force_execute_pre_commit(
212        self,
213        op: &mut impl AtomicOperation,
214    ) -> impl Future<Output = Result<Self, sqlx::Error>> + Send {
215        async {
216            let hook_op = HookOperation::new(op);
217            Ok(self.pre_commit(hook_op).await?.hook)
218        }
219    }
220}
221
222/// Wrapper around a database connection passed to [`CommitHook::pre_commit()`].
223///
224/// Implements [`AtomicOperation`] to allow executing database queries within the transaction.
225pub struct HookOperation<'c> {
226    now: Option<chrono::DateTime<chrono::Utc>>,
227    conn: &'c mut db::Connection,
228}
229
230impl<'c> HookOperation<'c> {
231    fn new(op: &'c mut impl AtomicOperation) -> Self {
232        Self {
233            now: op.maybe_now(),
234            conn: op.connection(),
235        }
236    }
237}
238
239impl<'c> AtomicOperation for HookOperation<'c> {
240    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
241        self.now
242    }
243
244    fn connection(&mut self) -> &mut db::Connection {
245        self.conn
246    }
247}
248
249/// Return type for [`CommitHook::pre_commit()`].
250///
251/// Use [`PreCommitRet::ok()`] to construct: `PreCommitRet::ok(self, op)`.
252pub struct PreCommitRet<'c, H> {
253    op: HookOperation<'c>,
254    hook: H,
255}
256
257impl<'c, H> PreCommitRet<'c, H> {
258    /// Creates a successful pre-commit result.
259    pub fn ok(hook: H, op: HookOperation<'c>) -> Result<Self, sqlx::Error> {
260        Ok(Self { op, hook })
261    }
262}
263
264// --- Object-safe internal trait ---
265trait DynHook: Send {
266    #[allow(clippy::type_complexity)]
267    fn pre_commit_boxed<'c>(
268        self: Box<Self>,
269        op: HookOperation<'c>,
270    ) -> BoxFuture<'c, Result<(HookOperation<'c>, Box<dyn DynHook>), sqlx::Error>>;
271
272    fn post_commit_boxed(self: Box<Self>);
273
274    fn try_merge(&mut self, other: &mut dyn DynHook) -> bool;
275
276    fn as_any(&self) -> &dyn Any;
277
278    fn as_any_mut(&mut self) -> &mut dyn Any;
279}
280
281impl<H: CommitHook> DynHook for H {
282    fn pre_commit_boxed<'c>(
283        self: Box<Self>,
284        op: HookOperation<'c>,
285    ) -> BoxFuture<'c, Result<(HookOperation<'c>, Box<dyn DynHook>), sqlx::Error>> {
286        Box::pin(async move {
287            let ret = self.pre_commit(op).await?;
288            Ok((ret.op, Box::new(ret.hook) as Box<dyn DynHook>))
289        })
290    }
291
292    fn post_commit_boxed(self: Box<Self>) {
293        (*self).post_commit()
294    }
295
296    fn try_merge(&mut self, other: &mut dyn DynHook) -> bool {
297        let other_h = other
298            .as_any_mut()
299            .downcast_mut::<H>()
300            .expect("hook type mismatch");
301        self.merge(other_h)
302    }
303
304    fn as_any(&self) -> &dyn Any {
305        self
306    }
307
308    fn as_any_mut(&mut self) -> &mut dyn Any {
309        self
310    }
311}
312
313/// Hooks are stored in a single flat insertion-ordered vec so that
314/// [`execute_pre`](Self::execute_pre) runs them in registration order — per hook,
315/// not per type. See the [module-level documentation](self#hook-ordering) for the
316/// ordering contract.
317pub(crate) struct CommitHooks {
318    hooks: Vec<(TypeId, Box<dyn DynHook>)>,
319}
320
321impl CommitHooks {
322    pub fn new() -> Self {
323        Self { hooks: Vec::new() }
324    }
325
326    pub(super) fn add<H: CommitHook>(&mut self, hook: H) {
327        let type_id = TypeId::of::<H>();
328
329        let mut new_hook: Box<dyn DynHook> = Box::new(hook);
330
331        // Merge with the most recently added hook of the same type, keeping the
332        // existing hook's original (earlier) position in the execution order.
333        if let Some((_, existing)) = self.hooks.iter_mut().rev().find(|(t, _)| *t == type_id)
334            && existing.try_merge(new_hook.as_mut())
335        {
336            return;
337        }
338
339        self.hooks.push((type_id, new_hook));
340    }
341
342    pub(super) fn get_last<H: CommitHook>(&self) -> Option<&H> {
343        self.hooks
344            .iter()
345            .rev()
346            .find(|(t, _)| *t == TypeId::of::<H>())
347            .and_then(|(_, hook)| hook.as_any().downcast_ref::<H>())
348    }
349
350    pub(super) async fn execute_pre(
351        self,
352        op: &mut impl AtomicOperation,
353    ) -> Result<PostCommitHooks, sqlx::Error> {
354        let mut op = HookOperation::new(op);
355        let mut post_hooks = Vec::with_capacity(self.hooks.len());
356
357        for (_, hook) in self.hooks {
358            let (new_op, hook) = hook.pre_commit_boxed(op).await?;
359            op = new_op;
360            post_hooks.push(hook);
361        }
362
363        Ok(PostCommitHooks { hooks: post_hooks })
364    }
365}
366
367impl Default for CommitHooks {
368    fn default() -> Self {
369        Self::new()
370    }
371}
372
373pub struct PostCommitHooks {
374    hooks: Vec<Box<dyn DynHook>>,
375}
376
377impl PostCommitHooks {
378    pub(super) fn execute(self) {
379        for hook in self.hooks {
380            hook.post_commit_boxed();
381        }
382    }
383}