Skip to main content

Module hooks

Module hooks 

Source
Expand description

Commit hooks for executing custom logic before and after transaction commits.

This module provides the CommitHook trait and supporting types that allow you to register hooks that execute during the commit lifecycle of a transaction. This is useful for:

  • Publishing events to message queues after successful commits
  • Updating caches
  • Triggering side effects that should only occur if the transaction succeeds
  • Accumulating operations across multiple entity updates in a transaction

§Hook Lifecycle

  1. Registration: Hooks are registered using AtomicOperation::add_commit_hook()
  2. Merging: Multiple hooks of the same type may be merged via CommitHook::merge()
  3. Pre-commit: CommitHook::pre_commit() executes before the transaction commits
  4. Commit: The underlying database transaction is committed
  5. Post-commit: CommitHook::post_commit() executes after successful commit

If the commit fails instead — either a later hook’s pre_commit errors (the transaction is rolled back first) or the COMMIT itself errors — then CommitHook::on_rollback() is fired on every hook whose pre_commit had already completed, in registration order, in place of post_commit. It is a synchronous, infallible, signal-only callback (see its docs).

§Hook Ordering

Hooks execute in registration order — per hook, not per type:

  1. Hooks run in the order they were added to the operation, regardless of their type. A hook that refuses to merge (CommitHook::merge() returns false) executes at its own (later) registration position, even when an earlier hook of the same type exists.
  2. A hook that merges executes at the position of the hook it merged into (the earlier one). For always-merging hook types this means the type’s position is anchored by its first registration in the operation; all later registrations fold into that position.
  3. CommitHook::post_commit() hooks run in the same order as their CommitHook::pre_commit() counterparts (registration order).
  4. A hook’s pre_commit may itself register further hooks — see AtomicOperation::add_commit_hook() on the HookOperation it is handed. Those join the tail of the same commit pass rather than freezing the set before the first pre_commit runs. See “Re-entrant Registration” below.

Registration order is a determinism guarantee, not a priority mechanism — there is no way to reorder hooks independently of the order in which they were added.

§Re-entrant Registration

AtomicOperation::add_commit_hook() succeeds on the HookOperation passed to a pre_commit that is running as part of a real commit pass — a hook can register more hooks, and they join the pass instead of being silently dropped or forced to run outside the commit lifecycle:

  • A newly-registered hook merges into a still-pending hook of the same type, keeping that hook’s queue position — indistinguishable from having registered it there directly. An already-executed hook of that type is never a merge target (its pre_commit already ran), so registering that type again after its own execution always starts a fresh instance, which runs its own pre_commit later in the same pass.
  • A bound (MAX_HOOK_GENERATIONS re-entrant generations) guards against a registration cycle (A registers B, B registers A, …), which would otherwise grow the queue forever inside an open transaction. Exceeding it fails the commit loudly instead of hanging.
  • This only applies to a real commit pass. CommitHook::force_execute_pre_commit() — the escape hatch for ops that don’t support hooks at all — still returns Err from add_commit_hook, because there is no pass for a registered hook to join; its post_commit/on_rollback would simply never run.

§Savepoints

Hooks registered on a SavepointOp are staged and only enter the parent operation’s set — through the same registration/merge path — when the savepoint is released; a rolled-back savepoint discards them. No callback runs at a savepoint boundary, so the lifecycle above is unchanged: one pre_commit pass at the parent’s commit, post_commit only after a durable COMMIT. See SavepointOp for details.

§Examples

§Hook with Database Operations and Channel-Based Publishing

This example shows a complete event publishing hook that:

  • Stores events in the database during pre-commit (within the transaction)
  • Sends events to a channel during post-commit for async processing
  • Merges multiple hook instances to batch operations

Note: post_commit() is synchronous and cannot fail, so it’s best used for fire-and-forget operations like sending to channels. A background task can then handle the async work of publishing to external systems.

use es_entity::{AtomicOperation, operation::hooks::{CommitHook, HookOperation, PreCommitRet}};

#[derive(Debug, Clone)]
struct Event {
    entity_id: uuid::Uuid,
    event_type: String,
}

#[derive(Debug)]
struct EventPublisher {
    events: Vec<Event>,
    // Channel sender for publishing events to a background processor
    // In production, this might be tokio::sync::mpsc::Sender or similar
    tx: std::sync::mpsc::Sender<Event>,
}

impl CommitHook for EventPublisher {
    async fn pre_commit(self, mut op: HookOperation<'_>)
        -> Result<PreCommitRet<'_, Self>, sqlx::Error>
    {
        // Store events in the database within the transaction
        // If the transaction fails, these inserts will be rolled back
        for event in &self.events {
            sqlx::query!(
                "INSERT INTO hook_events (entity_id, event_type, created_at) VALUES ($1, $2, NOW())",
                event.entity_id,
                event.event_type
            )
            .execute(op.as_executor())
            .await?;
        }

        PreCommitRet::ok(self, op)
    }

    fn post_commit(self) {
        // Send events to a channel for async processing
        // This only runs if the transaction succeeded
        // Channel sends are fast and don't block; a background task handles publishing
        for event in self.events {
            // In production, handle send failures appropriately (logging, metrics, etc.)
            // The channel might be bounded to apply backpressure
            let _ = self.tx.send(event);
        }
    }

    fn merge(&mut self, other: &mut Self) -> bool {
        // Merge multiple EventPublisher hooks into one to batch operations
        self.events.append(&mut other.events);
        true
    }
}

// Separate background task for async event publishing
// async fn event_publisher_task(mut rx: tokio::sync::mpsc::Receiver<Event>) {
//     while let Some(event) = rx.recv().await {
//         // Publish to Kafka, RabbitMQ, webhooks, etc.
//         // Handle failures with retries, dead-letter queues, etc.
//         match publish_to_external_system(&event).await {
//             Ok(_) => log::info!("Published event: {:?}", event),
//             Err(e) => log::error!("Failed to publish event: {:?}", e),
//         }
//     }
// }

§Usage

let user_id = uuid::Uuid::nil();
let (tx, _rx) = std::sync::mpsc::channel();
let mut op = DbOp::init(&pool).await?;

// Add first hook
op.add_commit_hook(EventPublisher {
    events: vec![Event { entity_id: user_id, event_type: "user.created".to_string() }],
    tx: tx.clone(),
}).expect("could not add hook");

// Add second hook - will merge with the first
op.add_commit_hook(EventPublisher {
    events: vec![Event { entity_id: user_id, event_type: "email.sent".to_string() }],
    tx: tx.clone(),
}).expect("could not add hook");

// Both hooks merge into one, events are stored in DB, then sent to channel
op.commit().await?;

Structs§

HookOperation
Wrapper around a database connection passed to CommitHook::pre_commit().
PostCommitHooks
PreCommitRet
Return type for CommitHook::pre_commit().

Constants§

MAX_HOOK_GENERATIONS
Maximum number of re-entrant “generations” a single commit pass will execute. Generation 0 is the hook set registered on the operation before commit() starts; a hook staged by a generation-N hook’s pre_commit is generation N+1. Bounds a registration cycle (A registers B, B registers A, …), which would otherwise grow the queue forever inside an open transaction — see the module docs.

Traits§

CommitHook
Trait for implementing custom commit hooks that execute before and after transaction commits.

Type Aliases§

BoxFuture
Type alias for boxed async futures.