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
cargo add umbral-signalsRegister the plugin
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.
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
| Method | Fires | created flag |
|---|---|---|
pre_save(handler(instance, created)) | before INSERT or UPDATE | true = 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.
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- firespre_save+post_save.Model::objects().delete_instance(&instance).await- firespre_delete+post_delete.
Handler signature
All four methods take an async closure:
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":
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.
Manager::bulk_create(vec) and QuerySet::update_values(map) fire ONLY a bulk signal, never per-row post_save:
Manager::bulk_create(vec)- firesbulk_post_save:once with
{ ids, created: true }.QuerySet::update_values(map)- firesbulk_post_save:once with
{ ids, created: false }.Manager::create(instance)firespost_save(per-row). Use it instead ofbulk_createwhen you need a per-row signal.QuerySet::delete()andDynQuerySet::delete()(REST/admin) fire BOTH:bulk_post_delete:once with
{ ids }, AND a per-rowpost_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 shapeManager::delete_instanceuses. 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_deleteis stillManager::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:
Coderustuse 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:
Coderustuse 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>:Signal Table pre_save:postpostpost_save:auth_userauth_userpre_delete:commentcommentThe
on_model::<M>()API handles naming automatically. You only need the raw names if you're subscribing at the registry level withsubscribe/subscribe_async.Payload shape
For reference - the
on_modelAPI deserialises these for you, so you only need this when subscribing via the rawsubscribe/subscribe_asyncfunctions:Signal Payload 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)(insidedb::transaction(...),db::transaction_on(...),db::transaction_sqlite(...), ordb::transaction_pg(...)) fires itspost_save/post_delete/bulk_post_save/bulk_post_deletesignals 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, eachon_tx()write buffers its signal on the transaction, and the runner flushes that buffer only oncecommit()succeeds - a rollback simply drops the buffer, so a rolled-back write fires nothing.Coderustdb::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 youbegin()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 theTransactionvalue is dropped. Usedb::transaction(...)(or its_on/_sqlite/_pgsiblings) 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-tasksfor 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/cleareach fire it automatically. See the ORM signals page for the payload shape and subscription example.See also
umbral-tasksplugin docs - the durable side of fire-and-forget work.- Settings and environment - reading config inside a signal handler.
- Plugin trait -
on_readyis the canonical place to register subscriptions. arch.md- design rationale for the core-vs-plugin dependency inversion that makes the registry live inumbral-core.
pluginssignalseventsorm