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}