This version is in beta. Some features may change before release.

Signals

Per-model lifecycle hooks (pre_save, post_save, pre_delete, post_delete) that fire automatically from the ORM, plus a generic name-keyed pub/sub escape hatch for app-defined events.

umbral-signals lets you subscribe to per-model lifecycle events: the ORM fires your handler automatically on every save or delete. It's strictly in-process at v1 - no cross-process broker, no persistence, no replay. For work that must survive a process crash, pair signals with umbral-tasks: the signal handler enqueues a task; the worker runs it.

Install

Code
bash
cargo add umbral-signals

Register the plugin

Code
rust
use umbral::prelude::*;
use umbral_signals::SignalsPlugin;
 
App::builder()
.plugin(SignalsPlugin)
.build()?;

SignalsPlugin carries no models or routes. It exists so other plugins can declare "signals" as a dependency and be confident the registry is alive before their on_ready fires.

Per-model signals - the primary API

Subscribe per model type using on_model::<M>(). The ORM fires the handler automatically - no manual emit calls needed.

Code
rust
use umbral_signals::on_model;
use my_app::models::Post;
 
// In Plugin::on_ready, or anywhere before App::build:
on_model::<Post>().post_save(|post, created| async move {
if created {
tracing::info!(id = post.id, title = %post.title, "new post created");
}
});

The lifecycle hooks

MethodFirescreated flag
pre_save(handler(instance, created))before INSERT or UPDATEtrue = INSERT, false = UPDATE
post_save(handler(instance, created))after INSERT or UPDATE (DB row)same
pre_update(handler(previous, instance))before UPDATE only-
post_update(handler(previous, instance))after UPDATE only-
pre_delete(handler(instance))before per-row DELETE-
post_delete(handler(instance))after per-row DELETE-

pre_update / post_update fire on UPDATE only (never INSERT) and hand the handler both the old row (previous) and the new one (instance) - ideal for audit diffs and change tracking. The ORM reads the old-row snapshot only when an *_update subscriber exists, so they cost nothing when nobody listens.

Code
rust
on_model::<Product>().post_update(|previous: Product, instance: Product| async move {
if previous.price_cents != instance.price_cents {
tracing::info!(old = previous.price_cents, new = instance.price_cents, "price changed");
}
});

Trigger methods:

  • Model::objects().save(instance).await - fires pre_save + post_save.
  • Model::objects().delete_instance(&instance).await - fires pre_delete + post_delete.

Handler signature

All four methods take an async closure:

Code
rust
on_model::<M>().post_save(|instance: &M, created: bool| async move {
// ...
});
 
on_model::<M>().pre_delete(|instance: &M| async move {
// ...
});

M must implement serde::Serialize + serde::Deserialize. Any model with #[derive(Model, Serialize, Deserialize)] already satisfies this.

Worked example - welcome email on user signup

The canonical pattern for "send a welcome email when a new user registers":

Code
rust
use umbral::task;
use umbral_signals::on_model;
use umbral_tasks::enqueue;
use umbral_auth::AuthUser;
use serde::{Deserialize, Serialize};
 
#[derive(Serialize, Deserialize)]
struct WelcomeEmailPayload {
user_id: i64,
email: String,
}
 
#[task]
async fn send_welcome_email(payload: WelcomeEmailPayload) -> Result<(), String> {
// ... send the email ...
Ok(())
}
 
// In Plugin::on_ready or main, before App::build:
register_send_welcome_email();
 
on_model::<AuthUser>().post_save(|user, created| async move {
if created {
let _ = enqueue(
"send_welcome_email",
WelcomeEmailPayload {
user_id: user.id,
email: user.email.clone(),
},
EnqueueOptions::default(),
)
.await;
}
});

The signal handler's only job is to enqueue a row. The task worker handles retry, backoff, and dead-letter accounting. See umbral-tasks for the worker side.

Bulk save methods fire bulk signals, not per-row signals

Manager::bulk_create(vec) and QuerySet::update_values(map) fire ONLY a bulk signal, never per-row post_save:

  • Manager::bulk_create(vec) - fires bulk_post_save: once with { ids, created: true }.
  • QuerySet::update_values(map) - fires bulk_post_save:
  • once with { ids, created: false }.

    Manager::create(instance) fires post_save (per-row). Use it instead of bulk_create when you need a per-row signal.

    QuerySet::delete() and DynQuerySet::delete() (REST/admin) fire BOTH: bulk_post_delete:

    once with { ids }, AND a per-row post_delete:
    for every deleted row. The per-row payload is PK-only ({ "instance": { "": } }) unless something is actually subscribed to post_delete:
    - a subscriber present means the DELETE pays for RETURNING * and the payload carries the FULL row instead, same shape Manager::delete_instance uses. The full row still honors #[umbral(signal_skip)] on both paths - a skipped field never reaches the payload, whether the delete went through the typed or the dynamic (REST/admin) path. pre_delete is still Manager::delete_instance-only; there's no single moment to fire it for a multi-row DELETE.

    This is intentionally verbose - bulk deletes are usually hot paths where the cost of serialising every row to JSON and calling N signal handlers would be a surprise when nobody's listening. That's why the full-row cost is gated on post_delete:

    actually having a subscriber.

    Generic pub/sub - the escape hatch

    For application-defined events that aren't tied to a model lifecycle, the lower-level name-keyed API is still available:

    Code
    rust
    use umbral_signals::{subscribe_async, emit};
     
    subscribe_async("order_placed", |payload| async move {
    let order_id = payload["id"].as_i64().unwrap_or(0);
    // ...
    });
     
    // Anywhere:
    emit("order_placed", serde_json::json!({ "id": 42 })).await;

    Use any name except the ORM-reserved <event>:<table> format (pre_save:post, post_save:auth_user, etc.) to avoid collisions.

    A sync variant is also available for cheap, non-I/O handlers:

    Code
    rust
    use umbral_signals::subscribe;
     
    subscribe("page_view", |payload| {
    // Runs inline on the emitter's task. No await.
    tracing::debug!("page viewed: {:?}", payload["path"]);
    });

    Signal name format

    ORM signals use <event>:<table>:

    SignalTable
    pre_save:postpost
    post_save:auth_userauth_user
    pre_delete:commentcomment

    The on_model::<M>() API handles naming automatically. You only need the raw names if you're subscribing at the registry level with subscribe / subscribe_async.

    Payload shape

    For reference - the on_model API deserialises these for you, so you only need this when subscribing via the raw subscribe / subscribe_async functions:

    SignalPayload
    pre_save, post_save{ "instance": <M as JSON>, "created": bool }
    pre_delete, post_delete{ "instance": <M as JSON> }

    Transactional writes (on_tx)

    A write made through Model::objects().on_tx(tx) (inside db::transaction(...), db::transaction_on(...), db::transaction_sqlite(...), or db::transaction_pg(...)) fires its post_save / post_delete / bulk_post_save / bulk_post_delete signals after the transaction commits, not while it's open. This matters because a naive "emit inline, mid-transaction" implementation is a phantom-signal bug: the signal (and any side effect a handler triggers) would fire even if the transaction later rolls back, telling subscribers about a write that never actually happened. Instead, each on_tx() write buffers its signal on the transaction, and the runner flushes that buffer only once commit() succeeds - a rollback simply drops the buffer, so a rolled-back write fires nothing.

    Code
    rust
    db::transaction(|tx| Box::pin(async move {
    Order::objects().on_tx(tx).create(new_order).await?;
    // post_save:order fires only after this closure returns Ok and the
    // transaction commits - never if it returns Err and rolls back.
    Ok::<_, MyError>(())
    })).await?;

    Boundary: this only applies to a transaction opened through one of the db::transaction* helpers, because those are the ones that own the commit call. A transaction you begin() and commit yourself (let mut tx = db::begin().await?; ...; tx.commit().await?;) is outside umbral's lifecycle - it has no hook to flush from, so any signals buffered on it are silently dropped when the Transaction value is dropped. Use db::transaction(...) (or its _on / _sqlite / _pg siblings) whenever you need signals to fire.

    What's not shipped at v0.0.1

    • Signal disconnect / per-call disable. Testing a handler today requires umbral_signals::clear_for_tests() to wipe all handlers and re-register. Targeted disconnect lands when tests need finer control.
    • Cross-process broadcast. A Redis or NATS adapter for horizontally-scaled deployments. Use umbral-tasks for cross-process work until then.
    • Typed events. An enum AppEvent { ... } shape with associated types so emitter and subscriber agree at compile time. Lands when a real plugin chain needs the guarantee.

    m2m_changed:<junction_table> is shipped. M2M::add/remove/set/clear each fire it automatically. See the ORM signals page for the payload shape and subscription example.

    See also

pluginssignalseventsorm