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
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(), }, ) .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.
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:
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:
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:
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>:
| Signal | Table |
|---|---|
pre_save:post | post |
post_save:auth_user | auth_user |
pre_delete:comment | comment |
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:
| Signal | Payload |
|---|---|
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-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/clear each 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.