Skip to main content
Cloacina Documentation
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

07 — Event Triggers

07 — Event Triggers

Cron fires workflows on a clock. A trigger fires one when a condition you define becomes true — a file arrives, a queue fills, a service goes unhealthy. The runner polls your trigger on an interval; you return Fire (with optional context) to run the workflow, or Skip to keep waiting.

Define the workflow

The trigger fires this workflow. process_file reads the filename the trigger passes in via context.

#[workflow(name = "file_processing", description = "Process incoming files")]
pub mod file_processing {
    use super::*;

    #[task]
    pub async fn validate_file(ctx: &mut Context<serde_json::Value>) -> Result<(), TaskError> {
        let filename = ctx.get("filename").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
        ctx.insert("validated", serde_json::json!(true))?;
        info!("File '{}' validated", filename);
        Ok(())
    }

    #[task(dependencies = ["validate_file"])]
    pub async fn process_file(ctx: &mut Context<serde_json::Value>) -> Result<(), TaskError> {
        let filename = ctx.get("filename").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
        ctx.insert("processed", serde_json::json!(true))?;
        info!("File '{}' processed", filename);
        Ok(())
    }
}
with cloaca.WorkflowBuilder("file_processing") as builder:
    builder.description("Process incoming files")

    @cloaca.task()
    def validate_file(context):
        filename = context.get("filename", "unknown")
        context.set("validated", True)
        print(f"Validated: {filename}")
        return context

    @cloaca.task(dependencies=["validate_file"])
    def process_file(context):
        filename = context.get("filename", "unknown")
        print(f"Processing: {filename}")
        context.set("processed", True)
        return context

Define the trigger

A trigger checks a condition and returns a TriggerResult: Fire(Some(ctx)) to run the workflow with that context, or Skip to poll again. Here a file watcher “finds” a file every fifth poll.

use cloacina::trigger;
use cloacina::{Context, TriggerError, TriggerResult};
use std::sync::atomic::{AtomicUsize, Ordering};

// The poll fn has no `self`, so polling state lives in a module-level static.
static FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);

// `#[trigger]` generates the `Trigger` impl plus a zero-arg constructor and
// submits it to the runtime inventory at compile time, so `Runtime::new()`
// (inside `DefaultRunner`) auto-registers the *polling function* — you never
// hand-construct the trigger. `on = "..."` declares which workflow it fires.
//
// Auto-registration only makes the poll loop aware of the trigger. Binding it
// to `file_processing` as a persisted schedule still takes one explicit
// `scheduler.register_trigger(trigger, "file_processing")` call (shown below) —
// pass the same name you put in `on`. Packaged workflows skip even that: the
// daemon's reconciler reads `on` and creates the schedule row for you.
#[trigger(
    name = "file_watcher",
    on = "file_processing",
    poll_interval = "2s",
    allow_concurrent = false
)]
async fn file_watcher() -> Result<TriggerResult, TriggerError> {
    // Pretend a new file lands on disk every fifth poll. Real code would
    // `std::fs::read_dir` or call an object-storage API.
    let count = FILE_COUNTER.fetch_add(1, Ordering::SeqCst);
    if count % 5 != 4 {
        return Ok(TriggerResult::Skip);
    }

    let filename = format!("data_{}.csv", chrono::Utc::now().timestamp());
    let mut ctx = Context::new();
    ctx.insert("filename", serde_json::json!(filename))
        .map_err(|e| TriggerError::PollError { message: e.to_string() })?;
    ctx.insert("watch_path", serde_json::json!("/data/inbox"))
        .map_err(|e| TriggerError::PollError { message: e.to_string() })?;
    Ok(TriggerResult::Fire(Some(ctx)))
}
import cloaca
import random

@cloaca.trigger(
    name="file_watcher",
    on="file_processing",       # the workflow this trigger fires
    poll_interval="5s",
    allow_concurrent=False
)
def file_watcher():
    # Check for new files (simulated)
    if random.randint(1, 10) == 5:
        ctx = cloaca.Context({"filename": "data_123.csv"})
        return cloaca.TriggerResult.fire(ctx)
    return cloaca.TriggerResult.skip()

In Python, on= is what binds the trigger to its workflow — it mirrors the Rust #[trigger(on = "...")]. If you leave it off, the scheduler falls back to the trigger’s own name as the target workflow (which usually won’t match your workflow), so always set on to the WorkflowBuilder name you want to fire.

With allow_concurrent = false, the runner hashes the fired context and skips a fire whose (trigger_name, context_hash) is already running — so the same file isn’t processed twice. Put identifying data (a filename, an order id) in the context so dedup can tell fires apart.

Register and run

Enable trigger scheduling on the runner, then register the trigger so the runner polls it. In Rust you bind the trigger to its target workflow explicitly with scheduler.register_trigger(trigger, "file_processing"). In Python the @cloaca.trigger decorator registers file_watcher at import time and its on= already names the target workflow, so constructing the runner is enough to start polling and dispatching — after that you manage the trigger at runtime.

use cloacina::runner::{DefaultRunner, DefaultRunnerConfig};
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Enable trigger scheduling so the runner spins up its poll loop.
    let config = DefaultRunnerConfig::builder()
        .enable_trigger_scheduling(true)
        .trigger_base_poll_interval(Duration::from_secs(1))
        .build()
        .unwrap();

    let runner = DefaultRunner::with_config("sqlite://triggers.db?mode=rwc", config).await?;

    // `#[trigger]` already auto-registered `file_watcher` into the runtime
    // inventory, so look it up by name rather than constructing it.
    let trigger = runner
        .runtime()
        .get_trigger("file_watcher")
        .ok_or("trigger 'file_watcher' not in runtime inventory")?;

    // Persist the trigger -> workflow schedule row so the unified scheduler
    // dispatches `file_processing` whenever the trigger fires.
    let scheduler = runner
        .unified_scheduler()
        .await
        .ok_or("unified scheduler not enabled — check enable_trigger_scheduling()")?;
    scheduler
        .register_trigger(trigger.as_ref(), "file_processing")
        .await?;

    info!("Trigger registered. Running for 30 seconds...");
    tokio::time::sleep(Duration::from_secs(30)).await;

    runner.shutdown().await?;
    Ok(())
}
import cloaca

# The @cloaca.trigger decorator already registered file_watcher at import
# time, so constructing the runner starts the poll loop for it.
runner = cloaca.DefaultRunner("sqlite://triggers.db")

# List the registered trigger schedules
for schedule in runner.list_trigger_schedules():
    print(f"{schedule['trigger_name']} -> {schedule['workflow_name']}")

# Enable or disable a trigger at runtime
runner.set_trigger_enabled("file_watcher", False)
runner.set_trigger_enabled("file_watcher", True)

# Inspect execution history
for execution in runner.get_trigger_execution_history("file_watcher"):
    print(f"Started: {execution['started_at']}")

runner.shutdown()

The runner polls file_watcher on its interval. In the Rust example the register_trigger call binds it to file_processing, so on the fifth poll the trigger fires that workflow with the new filename in context and validate_fileprocess_file run with it. In Python the on="file_processing" in the decorator supplies that same binding, so the moment the runner starts the trigger both polls and dispatches to file_processing; list_trigger_schedules, set_trigger_enabled, and get_trigger_execution_history then let you inspect and control it at runtime.

Next