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}