14 — Packaged Triggers
In this tutorial, you’ll learn how to define event-driven triggers alongside your Python workflows so they can be packaged and deployed to the Cloacina daemon. While Tutorial 07 introduced triggers running directly in your Python process, this tutorial focuses on the packaging story — how triggers and workflows are declared, bundled, and auto-registered when loaded by the reconciler (the daemon component that discovers, validates, and registers packages).
- Define triggers alongside tasks in a packaged workflow
- Understand that
@cloaca.triggerdecorators are the declaration — there is no manifest trigger section - Package a trigger-bearing workflow as a
.cloacinaarchive - See how the reconciler wires triggers to workflows on load
- Completion of Tutorial 07: Event Triggers
- Familiarity with the daemon (see Running the Daemon)
20-25 minutes
Start with a workflow that the trigger will fire. In your entry module
(workflow/data_ingest/tasks.py), declare tasks with bare @cloaca.task
decorators at module level — in a packaged workflow the loader builds the
workflow context from workflow_name in package.toml, so you do not wrap
tasks in a WorkflowBuilder (doing so would shadow the loader’s context and the
package would load with no tasks):
import cloaca
from datetime import datetime
@cloaca.task(dependencies=[])
def validate(context):
"""Validate the incoming data file."""
filename = context.get("filename", "unknown")
print(f" Validating: {filename}")
context.set("valid", True)
return context
@cloaca.task(dependencies=["validate"])
def load(context):
"""Load validated data into the warehouse."""
filename = context.get("filename", "unknown")
print(f" Loading: {filename}")
context.set("loaded_at", datetime.now().isoformat())
return context
This is a standard workflow — nothing special about triggers yet.
Add a trigger at module level alongside the tasks. Triggers are registered independently from the workflow’s task graph:
@cloaca.trigger(
name="inbox_watcher",
on="data_ingest", # the workflow this trigger fires (its workflow_name)
poll_interval="5s",
allow_concurrent=False
)
def inbox_watcher():
"""
Poll for new files in the inbox directory.
Returns TriggerResult.fire() with context when a new file
is detected, or TriggerResult.skip() when nothing is found.
"""
# In a real trigger, you'd check a filesystem, API, queue, etc.
import os
inbox = os.environ.get("INBOX_PATH", "/data/inbox/")
new_files = [f for f in os.listdir(inbox) if f.endswith(".parquet")]
if new_files:
filename = new_files[0]
ctx = cloaca.Context({
"filename": filename,
"trigger_name": "inbox_watcher",
"triggered_at": datetime.now().isoformat(),
})
return cloaca.TriggerResult.fire(ctx)
return cloaca.TriggerResult.skip()
Notice the parameters: name identifies the trigger, on names the workflow
the trigger fires (here data_ingest, matching workflow_name in
package.toml), poll_interval controls how often the function is called, and
allow_concurrent=False prevents overlapping executions. The on binding is the
one to get right — if you omit it, the reconciler falls back to the trigger’s own
name (inbox_watcher) as the target workflow, which won’t match data_ingest
and nothing will fire. The decorator is the only place these are declared —
there is no separate manifest entry to keep in sync.
There is no trigger section in the package manifest. The @cloaca.trigger
decorator is the declaration: when the reconciler imports your entry_module,
the decorator registers the trigger (name, poll interval, config), and the
reconciler reads the trigger’s on value to persist a schedule row binding it to
that workflow (data_ingest). The manifest (package.toml) carries only package
identity + [metadata]; adding triggers to it (or a package_type key) is
rejected at upload.
So the only thing to get right for triggers is that the @cloaca.trigger
decorator runs at import time (module level) — same rule as @cloaca.task.
A .cloacina package is a top-level package.toml plus your module tree under
workflow/:
data-ingest/
├── package.toml
└── workflow/
└── data_ingest/
├── __init__.py
└── tasks.py # @cloaca.task + @cloaca.trigger here
package.toml:
[package]
name = "data-ingest"
version = "1.0.0"
interface = "cloacina-workflow-plugin"
interface_version = 1
extension = "cloacina"
[metadata]
language = "python"
workflow_name = "data_ingest"
entry_module = "data_ingest.tasks"
description = "File ingestion workflow with inbox watcher trigger"
requires_python = ">=3.10"
entry_module is the dotted path relative to workflow/ that the loader
imports for task + trigger discovery. See
Packaging Python Workflows
for the full format and the build/deploy steps.
Before packaging, test the trigger and workflow in library mode. Because the
module uses bare decorators, wrap the import in a WorkflowBuilder named to
match workflow_name — this stands in for the context the packaged loader
supplies (it is for in-process testing only, not part of the package):
import cloaca
with cloaca.WorkflowBuilder("data_ingest"):
import data_ingest.tasks # registers validate/load + inbox_watcher
def test_trigger_and_workflow():
"""Simulate what the daemon does on trigger fire."""
runner = cloaca.DefaultRunner(":memory:")
try:
# Simulate a trigger poll that fires
result = data_ingest.tasks.inbox_watcher()
if result.is_fire_result():
# Execute the workflow with the trigger's context
context = cloaca.Context({
"filename": "orders_20260328.parquet",
"trigger_name": "inbox_watcher",
})
wf_result = runner.execute("data_ingest", context)
print(f"Workflow status: {wf_result.status}")
finally:
runner.shutdown()
Copy your .cloacina package into the daemon’s watch directory. The reconciler will import your entry_module, which runs your @cloaca.trigger decorator at import time — registering the trigger and starting the poll loop automatically.
cp data-ingest-1.0.0.cloacina ~/.cloacina/packages/
No Manifest Sync NeededBecause the@cloaca.triggerdecorator is the declaration, there is notriggersarray inpackage.tomlto keep in agreement — and adding one (or apackage_typekey) is rejected at upload. Just make sure the decorator runs at import time.
@cloaca.triggeris the declaration — it registers the trigger when the module is imported, and itson=names the workflow the trigger fires- The manifest (
package.toml) carries package identity +[metadata]only; no trigger section - Triggers are packaged alongside tasks in the same
.cloacinaarchive - The reconciler imports your
entry_moduleon load, which wires everything together
This is an optional, packaging-focused follow-on to
07 — Event Triggers — it takes the
in-process trigger you learned there and ships it inside a .cloacina package. It’s
the end of the tutorial track.
- Packaging Python Workflows — the full
package.tomlformat and build steps - Running the Daemon — deploy your package
- Package Format — what’s inside a
.cloacinaarchive