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.

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(),
},
)
.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 methods fire bulk signals, not per-row signals

The following write paths fire bulk signals (bulk_post_save: or `bulk_post_delete:

`), not per-row signals:

  • 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 }`.

  • QuerySet::delete() - fires `bulk_post_delete:

once with{ ids }`.

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

If you need per-row signals on a bulk delete path: fetch the rows first, then iterate calling delete_instance per row inside a transaction:

Code
rust
umbral::db::transaction(|tx| Box::pin(async move {
let posts = Post::objects().on_tx(tx).filter(...).fetch().await?;
for post in posts {
Post::objects().delete_instance(&post).await?;
}
Ok::<_, MyError>(())
})).await?;

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. Subscribe to `bulk_post_delete:

` instead when working from a list of PKs is sufficient.

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> }

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