This version is in beta. Some features may change before release.

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:

NameFired byPayload
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

Code
rust
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.

Code
rust
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 from Manager::save and Manager::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 from bulk_create, update_values, update_expr, and QuerySet::delete with 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.

Code
rust
// 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::create and the INSERT branch of Manager::save never emit them.)
  • Their payload carries both rows: previous (the pre-UPDATE row read from the DB) and instance (the new value, post-UPDATE for post_update).
Code
rust
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>.

Code
rust
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:

Code
rust
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 - M2M operations that fire m2m_changed.
  • arch.md - thin-core / plugin-heavy design; signals are a foundational extensibility seam.
ormsignalshooksauditm2m