Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

38. Transactional Outbox & Durable Effects

Use a transactional outbox when a committed database change must eventually produce an external effect even if the application process stops between the two operations. Typical examples are a webhook, an e-mail request, a search projection or a message published to another service.

after_commit is the simpler choice for a best-effort, process-local effect. Outbox is the durable choice. It stores an event in the same relational transaction as the domain mutation, then lets an independent worker claim and deliver it later.

1. Register the schema as a migration

Applications using the built-in SQLx migration runner can register the versioned migration supplied by the ORM:

#![allow(unused)]
fn main() {
use rullst_orm::OutboxMigration;
use rullst_orm::schema::migration::Migration;

fn migrations() -> Vec<Box<dyn Migration>> {
    vec![Box::new(OutboxMigration)]
}
}

In a generated project, add Box::new(rullst::db::OutboxMigration) to the vector returned by get_migrations() in src/migrations/mod.rs, then run cargo rullst db:migrate.

Outbox::install() applies the same idempotent DDL directly and is useful in tests or explicit local setup. It never runs automatically. A production team should review and track the migration through the same deployment process as its domain schema; do not create tables opportunistically while serving a request.

The built-in contract supports the SQLx relational backends: SQLite, PostgreSQL, MySQL and MariaDB. It does not span MongoDB, DuckDB, Turso or SurrealDB transactions.

2. Commit domain state and event together

Choose a stable stream boundary and a deterministic event key. A stream can represent an application or tenant, but it is only a namespace: the framework does not infer authorization from it.

#![allow(unused)]
fn main() {
use rullst_orm::{Error, Orm, Outbox};
use serde_json::json;

async fn create_invoice() -> Result<(), Error> {
Orm::transaction(|_| Box::pin(async move {
    // `?` placeholders are SQLite/MySQL syntax; PostgreSQL uses `$1, $2`.
    let insert = sqlx::query(
        "INSERT INTO invoices (id, status) VALUES (?, ?)"
    )
    .bind(123_i64)
    .bind("issued");
    rullst_orm::execute_query!(insert, execute, pool)?;

    Outbox::enqueue(
        "tenant-42",
        "invoice:123:issued:v1",
        "invoice.issued",
        &json!({"invoice_id": 123}),
    ).await?;

    Ok::<(), Error>(())
})).await?;
Ok(())
}
}

If the transaction rolls back, neither row survives. This also holds when a helper wraps enqueue in its own Orm::transaction and is called from inside another one: the nested call joins the outer transaction through a savepoint, so its outbox row commits or rolls back with the outer transaction. Calling enqueue outside Orm::transaction fails instead of silently opening an unrelated transaction. When the application already owns a raw SQLx transaction, use enqueue_with_tx(&mut transaction, ...).

(stream, event_key) is unique. Repeating the same kind and an equal JSON payload returns the existing event ID with inserted == false, also when a concurrent transaction committed that key after your transaction’s first read (MySQL/MariaDB read the row back with a locking read). Payloads are compared as JSON values, so an object serialized with its keys in another order (for example one built from a HashMap) is the same content; array order and every value still count. Reusing that key for different content is an error; it does not overwrite the original event, and the error does not echo the stream or key.

Streams and event keys are case-sensitive on every backend: order:aB3x and order:Ab3X are different keys, and a worker for tenant-a does not claim events of tenant-A. MySQL/MariaDB declare the key columns with the binary ascii_bin collation for this. A MySQL/MariaDB table created by an earlier version used the server’s case-insensitive default collation; run Outbox::install() once, for example from a new migration, to convert it in place. The conversion rebuilds the table, so schedule it like other DDL.

3. Claim, deliver and acknowledge

Each worker supplies a stable identifier, lease duration and maximum number of claims:

#![allow(unused)]
fn main() {
use rullst_orm::{Error, Outbox};

async fn deliver() -> Result<(), Error> {
if let Some(event) = Outbox::claim_next(
    "tenant-42",
    "webhook-worker-1",
    30, // lease seconds
    8,  // maximum claims
).await? {
    let payload = event.payload()?;

    // Perform the external effect. Pass event.event_key to a provider that
    // supports idempotency, or deduplicate it in the receiving service.
    let delivered = payload.get("invoice_id").is_some();

    if delivered {
        let owned = Outbox::acknowledge(event.id, event.claim_key).await?;
        if !owned {
            // The lease expired or another worker reclaimed the event.
            // Do not assume ownership or mutate the newer claim.
        }
    } else {
        Outbox::fail(
            event.id,
            event.claim_key,
            "provider temporarily unavailable",
            8,  // maximum attempts
            15, // retry delay seconds
        ).await?;
    }
}
Ok(())
}
}

An ACK or failure transition succeeds only for the exact, unexpired random claim token. A failed delivery becomes pending after the bounded delay, or dead_letter when its attempt limit is reached. If a worker dies during its final claim, the next claim sweep moves that expired event to dead-letter instead of retrying forever.

Claims, acknowledgements and failures commit on their own. Calling them inside Orm::transaction returns a validation error, because an acknowledgement there would already be durable if the handler’s transaction later rolled back. Commit the handler’s database work first, then acknowledge.

4. Understand the guarantee

The delivery guarantee is at least once:

  1. the worker claims an event;
  2. the external provider accepts the effect;
  3. the process stops before the database ACK;
  4. the lease expires and the event is delivered again.

No local database design can atomically commit an arbitrary remote HTTP side effect. Make the consumer idempotent using the stable stream/event key. Do not use a random key on every retry.

Other explicit limits:

  • key fields are 1–128 characters from a bounded ASCII grammar;
  • JSON payloads are at most one MiB;
  • leases are 1–3,600 seconds, attempts are 1–100 and retry delays are 0–86,400 seconds;
  • a failure message is 1–512 bytes without control characters;
  • ordering is not guaranteed across retries or concurrent workers;
  • generated observers are not automatically converted into outbox events;
  • cleanup, retention, tenant authorization and the worker supervision loop belong to the application;
  • monitor retry and dead-letter state through your normal database operations.

This separation keeps model saves predictable while giving applications a durable primitive where the event schema and operational policy are explicit.

5. Relay into rullst-messaging

With the umbrella messaging-orm-outbox feature, OrmOutboxRelay<B> maps one exact outbox stream to one topic on any static MessageBroker. It validates the claimed JSON, uses the stream-scoped event_key (the lowercase hex SHA-256 of the stream, /, then the event key) as the broker idempotency key, publishes and then acknowledges the exact ORM claim. Its executable crash-window test stops after the first publish, reclaims the expired event and observes an exact broker replay with only one retained message.

That bridge improves composition; it does not change the guarantee. ORM commit, broker publish and ORM ACK are not one distributed atomic transaction. Operate the claim loop, retry/dead-letter policy, cleanup, authorization and final consumer deduplication explicitly. See Bounded Brokered Messaging for the relay code.