Signals
Lifecycle hooks fired by the ORM (per-row, bulk, m2m) plus the actor task-local that flows through every payload.
Signals
Umbral ships a name-keyed in-process pub/sub. Subscribers register handlers against a signal name; ORM write paths fire those names with a JSON payload. Plugins (audit logs, notifications, cache invalidation) hang functionality off the events without touching the framework's write code.
The facade module umbral::signals re-exports the full application surface: subscribe, subscribe_async, emit, and the actor helpers with_actor / current_actor.
Signal names
ORM signals use a <event>:<table> shape so the built-in set is namespaced away from anything an app defines:
| Name | Fired by | Payload |
|---|---|---|
pre_save:<table> | Manager::save (typed) AND DynQuerySet::insert_json (dynamic), before INSERT or UPDATE | { instance, created, actor } |
post_save:<table> | Manager::save (typed) AND DynQuerySet::insert_json (dynamic), after, with the DB read-back row | { instance, created, actor } |
pre_update:<table> | Manager::save (typed), before UPDATE only (never INSERT) | { previous, instance, actor } |
post_update:<table> | Manager::save (typed), after UPDATE only (never INSERT) | { previous, instance, actor } |
pre_delete:<table> | Manager::delete_instance (before) | { instance, actor } |
post_delete:<table> | Manager::delete_instance (after) | { instance, actor } |
bulk_post_save:<table> | bulk_create, update_values, update_expr (typed) AND DynQuerySet::update_json (dynamic) | { ids, created, actor } |
bulk_post_delete:<table> | QuerySet::delete (typed) AND DynQuerySet::delete (dynamic) | { ids, actor } |
m2m_changed:<junction> | M2M::add/remove/set/clear | { action, parent_id, added, removed, actor } |
User-defined signals keep their own namespace. The <event>:<table> prefix on ORM signals just exists so a plugin that subscribes to post_save:user doesn't accidentally collide with an app-level user event.
Typed and dynamic paths fire the same signals (gaps #77)
DynQuerySet is the runtime-typed ORM surface that REST endpoints (umbral-rest) and admin form submits (umbral-admin) write through. Every signal in the table above fires from BOTH the typed Manager / QuerySet paths AND the dynamic DynQuerySet::insert_json / update_json / delete paths, so an audit-log subscriber on post_save:order sees writes from a typed handler, a REST POST /api/order/, and an admin form-create alike. The signal name + payload shape are identical across surfaces.
PK shape: the instance JSON and ids array carry the row's primary key in whatever shape the model declares (i64, String, Uuid). Subscribers that index on PK should read the value as a serde_json::Value rather than assuming i64. The framework's planned PrimaryKey refactor will lift the rest of the i64-hardcoded surfaces, and signal subscribers should already be forward-compatible.
Subscribing
use umbral::signals::{subscribe, subscribe_async}; // Sync handler: runs inline during emit().subscribe("post_save:post", |payload| { let actor = &payload["actor"]; let id = payload["instance"]["id"].as_i64(); tracing::info!(?actor, ?id, "post saved");}); // Async handler: awaited in series after the sync handlers.subscribe_async("post_delete:post", |payload| async move { let id = payload["instance"]["id"].as_i64(); redis::invalidate(&format!("post:{:?}", id)).await;});Handlers stack in registration order. Multiple handlers per name all run.
The actor envelope
Every signal payload carries an "actor" key whose value is whatever JSON the nearest enclosing with_actor(...) scope set. With no scope active, the value is Value::Null. This is how an audit-log plugin learns who triggered a write without each plugin reinventing the same middleware.
use umbral::signals::with_actor;use serde_json::json; // Typical auth middleware: resolve the request's identity into JSON,// then run the rest of the handler under with_actor(...) so every// ORM write the handler triggers picks up the same actor.async fn handle_request(req: Request) -> Response { let user = resolve_session(&req).await; let actor = json!({ "id": user.id, "username": user.username }); with_actor(actor, async move { // Any ORM write inside this future fires signals with the // actor embedded in the payload. let post = Post::objects().create(new_post).await?; Ok::<_, MyError>(post) }).await.into_response()}A subscriber reads payload["actor"]["id"] (or whatever shape the auth layer chose) to identify the actor. Nested with_actor calls shadow the outer scope and restore it on the way out; concurrent tasks see their own actors.
If a caller wants to identify itself independently of the ambient scope, an explicit "actor" key in the payload supplied to emit() wins over the task-local. Useful for system-task wrappers that want to be { "source": "scheduled-task", "job_id": "..." } regardless of who started them.
Per-row vs bulk
The framework deliberately splits row-level and bulk signal contracts:
- Per-row signals (
pre_save:<t>,post_save:<t>,pre_delete:<t>,post_delete:<t>) fire fromManager::saveandManager::delete_instance. Use these when the handler needs the full instance to do its work (slug auto-generation, cache key derivation, notification subject lines). - Bulk signals (
bulk_post_save:<t>,bulk_post_delete:<t>) fire once per call frombulk_create,update_values,update_expr, andQuerySet::deletewith the list of affected primary keys. Use these when the handler can work from just the PKs (audit logs, bulk cache invalidation, downstream re-indexing).
The split avoids fanning out N handler invocations on a 10k-row bulk import. A subscriber that needs more than the PKs from a bulk write can issue a single follow-up SELECT on the captured ids.
// Audit subscriber for bulk inserts: log the actor + ids tuple.subscribe("bulk_post_save:order", |payload| { let actor = &payload["actor"]; let ids = payload["ids"].as_array().map(|v| v.len()).unwrap_or(0); tracing::info!(?actor, "bulk INSERT touched {} orders", ids);});The created field on bulk_post_save is true for INSERT terminals (bulk_create) and false for UPDATE terminals (update_values, update_expr). Subscribers that only care about one direction filter on that field.
Update signals: the old row, snapshotted
pre_save / post_save fire on both INSERT and UPDATE, and carry only the new instance. A whole class of subscribers - audit-log diffs, change tracking, and umbral-storage's file replace-cleanup - also need the row as it looked before the UPDATE. That's what pre_update:<table> / post_update:<table> add:
- They fire on UPDATE only - never INSERT. (
Manager::createand the INSERT branch ofManager::savenever emit them.) - Their payload carries both rows:
previous(the pre-UPDATE row read from the DB) andinstance(the new value, post-UPDATE forpost_update).
use umbral::signals::subscribe_async; // Audit a price change with the before/after values.subscribe_async("post_update:product", |payload| async move { let old = payload["previous"]["price_cents"].as_i64(); let new = payload["instance"]["price_cents"].as_i64(); if old != new { tracing::info!(?old, ?new, "product price changed"); }});The old-row read is subscriber-gated
Snapshotting previous costs an extra SELECT ... WHERE pk = ? on every UPDATE. The save path only pays that cost when a pre_update:<table> or post_update:<table> subscriber actually exists - checked via umbral::signals::has_subscribers(name). With nobody listening (the common case) the read is skipped entirely and the signal fires to zero handlers at no extra cost. Registering a single *_update handler turns the read on.
The snapshot is read just before the UPDATE, so a concurrent writer between the snapshot and the UPDATE is a best-effort TOCTOU window - previous reflects the row at snapshot time, not necessarily the exact row the UPDATE overwrote. For audit/cleanup use cases this is acceptable.
These signals currently fire from the typed Manager::save path. The dynamic DynQuerySet::update_json path (REST/admin) is a filter-chain bulk update that matches a PK set and fires bulk_post_save instead; per-row *_update on the dynamic path is a planned follow-up.
M2M signals
M2M::add/remove/set/clear each fire m2m_changed:<junction_table> with an action label and the lists of added/removed child PKs. The junction table name follows the macro-derived convention <parent_table>_<field_name>.
subscribe("m2m_changed:post_tags", |payload| { let action = payload["action"].as_str().unwrap_or(""); let added = payload["added"].as_array().map(|v| v.len()).unwrap_or(0); let removed = payload["removed"].as_array().map(|v| v.len()).unwrap_or(0); tracing::info!(action, added, removed, "post tags changed");});set reports the SQL-level reality: every prior child lands in removed (the DB DELETE wiped them all) and every supplied child lands in added (the DB INSERTs re-built the set). Subscribers that want a minimal added/removed diff compute it in their handler.
clear on an empty relation skips emission: there is nothing to report.
When emit() itself merges actor
The actor merge lives inside emit(), not the per-helper emitters. That means user-defined signals fired through emit("my_event", payload).await also pick up the actor automatically:
use umbral::signals::emit;use umbral::signals::with_actor; with_actor(json!({ "id": 7 }), async { emit("payment_captured", json!({ "amount_cents": 4200 })).await; // Subscribers see: { "amount_cents": 4200, "actor": { "id": 7 } }}).await;Object payloads gain an "actor" key. Non-object payloads (rare) are wrapped as { "data": <payload>, "actor": ... } so the envelope shape stays predictable for subscribers.
Pitfalls
Signals are in-process only. They do not cross process boundaries. A signal fired on one app instance is invisible to a sibling instance. For cross-instance pub/sub, layer Redis (or another broker) on top of a subscriber that re-publishes the event.
Sync handlers run on the caller's task. A slow sync handler blocks the write that fired it. Use subscribe_async for any handler that awaits I/O.
Bulk signals only fire when at least one row was affected. Every typed bulk emit is guarded by if !ids.is_empty(): bulk_create([]) short-circuits before the SQL, and an update_values / delete that matches zero rows stays silent (a "zero-row" event is usually noise). Subscribers therefore never see an empty ids array.
The actor task-local is in-process only too. It does not propagate across tokio::spawn boundaries automatically: tokio::task_local resets when a new task starts. If you spawn from inside a with_actor(...) scope and want the actor to survive, capture current_actor() into a let and re-establish via with_actor(actor, ...) inside the spawned task.
See also
- Transactions -
.atomic()and.on_tx()wrap writes that fire signals on commit. - Relationships -
M2Moperations that firem2m_changed. arch.md- thin-core / plugin-heavy design; signals are a foundational extensibility seam.