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), and per-row from QuerySet::delete / DynQuerySet::delete (full row when subscribed, else PK-only){ 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.

Excluding sensitive fields from payloads

The instance / previous payloads carry the whole serialized row. A subscriber that logs or persists the payload — the natural audit-log shape — would copy every column into its store, including password hashes, tokens, and PII. Mark such fields #[umbral(signal_skip)] and they are stripped from every signal payload before it fans out:

Code
rust
#[derive(sqlx::FromRow, umbral::orm::Model)]
struct Account {
id: i64,
email: String,
#[umbral(signal_skip)]
password_hash: String, // never appears in any signal payload
}

umbral-auth's AuthUser.password_hash already carries this. The per-row bulk signals (bulk_post_save / bulk_post_delete) are unaffected — they only carry primary keys, never field values.

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.

Transactional hooks: atomic "create A ⇒ B exists"

subscribe / subscribe_async handlers run after the write commits and cannot fail it — perfect for cache invalidation or an outgoing webhook, wrong for an invariant like "every AuthUser must have a Profile". If that handler runs post-commit and its own insert fails (or the process restarts first), you get a user with no profile, and nothing surfaces it.

subscribe_txn closes that gap. A transactional handler runs inside the creating transaction, receives &mut Transaction so it can write dependent rows on the same tx, and returns a Result — an Err rolls the whole create back. So either the parent and its dependents all commit, or none do.

Code
rust
use umbral::signals::subscribe_txn;
 
// Every AuthUser create also creates its Profile — atomically.
subscribe_txn("post_save:auth_user", |payload, tx| Box::pin(async move {
let uid = payload["instance"]["id"].clone();
let mut body = serde_json::Map::new();
body.insert("user_id".into(), uid);
umbral::orm::DynQuerySet::for_meta(&profile_meta)
.insert_json_in_tx(&body, tx)
.await?; // an Err here rolls the AuthUser create back too
Ok(())
}));

The typed form lives on on_model (from umbral-signals), which hands you the decoded instance and derives the signal name:

Code
rust
use umbral_signals::on_model;
 
on_model::<AuthUser>().post_create_txn(|user, tx| Box::pin(async move {
Profile::objects().on_tx(tx).create(Profile { user_id: user.id, ..Default::default() }).await?;
Ok(())
}));

Because the future has to borrow the transaction, the handler returns a Box::pin(async move { … }) rather than being a bare async closure. The post_save async subscribers above still fire (post-commit) as well — a model can have both.

Info
Fires from the typed `Manager::create` path (gaps4 #92). A model with a `subscribe_txn` handler runs its create in a transaction; models without one keep the fast, no-extra-transaction path. M2M links set on the created instance flush after commit, so they aren't inside the atomic hook window yet.

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). As of the delete-payload contract, post_delete:<t> also fires once per deleted row from QuerySet::delete and DynQuerySet::delete (REST/admin) - carrying the full row when a subscriber exists, PK-only otherwise (see the Signals plugin page for the exact shape).
  • 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). A bulk QuerySet::delete fires bulk_post_delete once and the per-row post_delete for each row.

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 - the engine checks this internally with has_subscribers(name) before snapshotting. 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