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(), }, ) .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 ( If you need per-row signals on a bulk delete path: fetch the rows first, then iterate calling 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 For application-defined events that aren't tied to a model lifecycle, the lower-level name-keyed API is still available: Use any name except the ORM-reserved A sync variant is also available for cheap, non-I/O handlers: ORM signals use The For reference - the 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.delete_instance per row inside a transaction:bulk_post_delete: instead when working from a list of PKs is sufficient.
Generic pub/sub - the escape hatch
<event>:<table> format (pre_save:post, post_save:auth_user, etc.) to avoid collisions.Signal name format
<event>:<table>:Signal Table pre_save:postpostpost_save:auth_userauth_userpre_delete:commentcommenton_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
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
umbral_signals::clear_for_tests() to wipe all handlers and re-register. Targeted disconnect lands when tests need finer control.umbral-tasks for cross-process work until then.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-tasks plugin docs - the durable side of fire-and-forget work.on_ready is the canonical place to register subscriptions.arch.md - design rationale for the core-vs-plugin dependency inversion that makes the registry live in umbral-core.