Subscribe a workflow to a reactor
This recipe wires a workflow to fire whenever a named reactor (a computation-graph reactor) fires. The wire is the DB-backed subscription fan-out shipped in CLOACI-I-0100 — durable across restarts, at-least-once delivery, optionally filtered by a CEL predicate (CLOACI-T-0602).
Use this when the workflow needs to run as a reaction to upstream event-driven activity that’s already being correlated by a reactor, and where durability matters (the subscription survives server restart and any subscriber-side outage).
Per CLOACI-S-0011 (post-2026-04-24 topology amendment), a reactor is a standalone publisher of firing events. Multiple subscribers can attach to one reactor: any number of computation graphs (in-process fast path) and any number of workflow triggers (this recipe — durable path) can declare the reactor as their upstream.
The two paths coexist:
| Path | Mechanism | Use when |
|---|---|---|
| In-process fast path | #[computation_graph(trigger = reactor("name"))] |
The downstream is a CG and you want minimum-latency firing. |
| DB-backed subscription (this recipe) | #[trigger(upstream = reactor("name"))] on a workflow |
The downstream is a workflow and you want durability. |
Both paths are dispatched off the same reactor firing — the in-process fast path fires synchronously inside the reactor’s loop; the DB path writes a row into reactor_firings and a separate poll loop dispatches the workflow subscribers. The reactor itself doesn’t know who its subscribers are; subscribers declare the reactor as their upstream by name. This is the upstream-declaration pattern.
For the conceptual rationale — why DB-backed, what at-least-once means, how it composes with the in-process path — see Subscription fan-out.
- A reactor exists somewhere in your deployment (declared in a packaged or embedded
#[reactor(...)]). You’ll reference it by string name. - A workflow you want to fire on the reactor’s firings.
- The reactor must be loaded by the time the workflow trigger registers — the subscription registers eagerly and will error if the reactor name doesn’t resolve at startup. (For cross-package deployments, package load order matters; see reactor-lifecycle.)
use cloacina::{trigger, workflow, task, Context, TaskError};
use serde_json::Value;
#[trigger(
name = "price_signal_workflow_trigger",
upstream = reactor("pricing_pipeline_reactor"),
)]
pub struct PriceSignalTrigger;
#[task]
pub async fn process_signal(ctx: &mut Context<Value>) -> Result<(), TaskError> {
// The reactor's firing payload is injected into the task context
// as the `reactor_payload` key. Extract whatever you need:
let payload = ctx.get("reactor_payload").cloned();
// ... do work ...
Ok(())
}
#[workflow(
name = "price_signal_processing",
triggers = [PriceSignalTrigger],
)]
pub mod price_signal_processing {
pub use super::process_signal;
}
The upstream = reactor("pricing_pipeline_reactor") clause registers the workflow’s trigger as a DB-backed subscriber of the named reactor. The reactor itself needs no modification — it just fires; the subscription writes a row per firing.
To fire the workflow only on a subset of reactor firings, register the subscription with a CEL predicate:
// Programmatic registration (alternative to the macro-only form above)
// when you need a filter predicate.
runner.subscribe_workflow_to_reactor(
"pricing_pipeline_reactor", // reactor name
"price_signal_processing", // workflow name
"price_signal_workflow_trigger", // trigger name
Some("payload.value > 100"), // CEL predicate (None = fire every time)
).await?;
CEL variables available to the predicate:
payload— the reactor firing’s payload (a JSON object).reactor— metadata about the firing reactor (name, etc.).tenant— the tenant the firing is scoped to.
CEL predicates are compiled at subscribe time — a malformed predicate errors at registration, not on every firing. Evaluation is fail-closed — if the CEL evaluation itself errors at firing time, the firing is skipped (logged with the error) rather than dispatched. See examples/features/computation-graphs/filtered-reactor for a runnable worked example.
cloacinactl --profile prod trigger list --tenant my-tenant | grep price_signal
The trigger should appear with upstream = reactor("pricing_pipeline_reactor") in its metadata.
Cause a reactor firing (push a boundary event, or use the WebSocket force-fire path per /v1/ws/reactor/{name} with {"type":"ForceFire"}), then check the workflow execution log:
cloacinactl --profile prod execution list --tenant my-tenant --workflow price_signal_processing
You should see one workflow execution per reactor firing (or per firing that passed the CEL filter, if you set one).
Per-runner knobs that govern subscription dispatch:
| Knob | Default | Notes |
|---|---|---|
reactor_poll_interval |
1s |
How often the subscription poll loop runs. Lower = lower fire-to-dispatch latency, more DB load. |
reactor_poll_batch_limit |
100 |
Max firings dispatched per poll tick. Bound on burst dispatch. |
reactor_firings_prune_interval |
1h |
How often the durable event log is pruned. |
reactor_firings_retention |
7days |
Retention window for reactor_firings rows. Past this, dispatched firings are GC’d. Increase for longer audit windows. |
These are set on the per-tenant DefaultRunner config. See Configuration Reference for the full knob set.
Two counters track subscription throughput (full catalog in Metrics Catalog):
cloacina_reactor_firings_total{graph, reactor}— per-reactor firings recorded in the durable log.cloacina_reactor_firings_pruned_total— firings GC’d by the prune loop.
For the workflow execution side, the existing cloacina_workflows_total{status, reason} counter is incremented per workflow execution as normal.
| You want | Reach for |
|---|---|
| A workflow that fires on a reactor firing, durable across restart | This recipe (upstream = reactor("name") on #[trigger]) |
| A computation graph that fires on a reactor firing, in-process minimum-latency | #[computation_graph(trigger = reactor("name"))] (see Tutorial 10) |
| A workflow that fires on a custom poll / event / file watch | Implement the Trigger trait — no reactor involved |
- Reactor authoring. This recipe assumes the reactor exists. See Tutorial 10 — Your First Computation Graph for the reactor side.
- Tearing down a subscription. Removing the
#[trigger]from the workflow and re-deploying the package unregisters the subscription. There is no separate CLI for it today. - Replaying past firings. The
reactor_firingslog is forward-looking; replay would require a separate mechanism (not currently shipped).
- Subscription fan-out — conceptual model.
- Filter reactor firings with CEL — focused recipe for the predicate side.
- Reactor-triggered workflows — the existing CG-side how-to with the dual-path topology overview.
- Tutorial 07 — Event Triggers — the in-process
Triggertrait alternative. - CLOACI-I-0100 — DB-backed reactor → workflow subscription fan-out.
- CLOACI-T-0602 — CEL predicate filtering on subscriptions.
- CLOACI-S-0011 — primitive nomenclature spec (post-2026-04-24 topology amendment).