Skip to content

Task Decorator

The @task decorator is used to define individual tasks that can be executed as part of workflows. Tasks are the fundamental building blocks of Cloaca workflows.

import cloaca
@cloaca.task()
def my_task(context):
"""Example task that processes data."""
# Task implementation
context.set("result", "Task completed successfully")
return context

All parameters are keyword-only and optional (crates/cloacina-python/src/task.rs):

  • id (str): Unique identifier for the task within the workflow. Defaults to the decorated function’s name.
  • dependencies (list): List of task IDs (strings or task function references) that must complete before this task runs
  • retry_attempts (int): Number of retry attempts on failure
  • retry_backoff (str): Backoff strategy: “fixed”, “linear”, or “exponential”
  • retry_delay_ms (int): Initial delay between retries in milliseconds
  • retry_max_delay_ms (int): Maximum delay between retries
  • retry_condition (str): When to retry: “never”, “transient”, or “all”
  • retry_jitter (bool): Add random jitter to retry delays
  • on_success (callable): Callback function called when task succeeds
  • on_failure (callable): Callback function called when task fails
  • invokes (ComputationGraphBuilder): A trigger-less computation graph this task invokes: after the task body succeeds, the engine executes the graph with the task’s context. The referenced with ComputationGraphBuilder(...) block must run before the decorator, or the decorator raises ValueError. Python parity with Rust’s #[task(invokes = …)].
  • post_invocation (callable): Callback run after the invoked graph completes; only meaningful with invokes= (setting it alone raises ValueError).
  • trigger_rules (rule): Conditional gate — when the rule evaluates false (and the task’s dependencies are otherwise satisfied), the task lands in the real Skipped state and its body never runs. Python parity with Rust’s #[task(trigger_rules = …)]. Default: always run.

A task’s trigger_rules gate whether it runs once its dependencies resolve. When the rule is unsatisfied the task is skipped (not failed), and the skip propagates to downstream dependents like any other branch-not-taken.

Build rules with the cloaca helpers:

Builder Meaning
cloaca.always() Always run (the default).
cloaca.task_success(name) / task_failed(name) / task_skipped(name) Gate on an upstream task’s outcome.
cloaca.context_value(key, op, value) Gate on a context value; op is "Equals", "NotEquals", "GreaterThan", …
cloaca.all_of(*rules) / any_of(*rules) / none_of(*rules) Combine conditions.
@cloaca.task(
dependencies=["poll"],
trigger_rules=cloaca.context_value("do_audit", "Equals", True),
)
def audit(context):
# Runs only when the upstream set do_audit = True; otherwise Skipped.
return context

See Trigger rules for evaluation semantics and skip propagation.

A task’s docstring is a documentation surface. Line-leading, case-insensitive what: / why: markers route the following text into the task’s what and why fields; with no markers the whole docstring becomes what and why is empty.

@cloaca.task(id="extract")
def extract(context):
"""
what: pulls rows from the source
why: the rest of the graph needs them staged
"""
return context

The compiler parses this from source at build time (see crates/cloacina-compiler/src/doc_parse.rs); parsing is best-effort and never fails the build.

@cloaca.task()
def fetch_data(context):
"""Fetch raw data from source."""
data = {"raw_data": [1, 2, 3, 4, 5]}
context.set("raw_data", data)
return context
@cloaca.task(dependencies=["fetch_data"])
def process_data(context):
"""Process the fetched data."""
raw_data = context.get("raw_data")
processed = {"processed_data": [x * 2 for x in raw_data["raw_data"]]}
context.set("processed_data", processed)
return context

Task bodies are plain (synchronous) functions. The executor runs each body on a blocking worker thread and expects the return value to be the Context (or None to keep the incoming context unchanged) — async def bodies are not awaited, so an async task fails when its coroutine return value cannot be converted back into a Context (crates/cloacina-python/src/task.rs).

To wait on an external condition without holding a concurrency slot, use a TaskHandle with defer_until instead of await.

Tasks should handle errors gracefully and return appropriate results:

@cloaca.task()
def safe_task(context):
"""Task with error handling."""
try:
# Potentially failing operation
result = risky_operation()
context.set("success", True)
context.set("result", result)
except Exception as e:
context.set("success", False)
context.set("error", str(e))
return context

Use on_success and on_failure callbacks for monitoring, alerting, or cleanup:

def on_success_callback(task_id: str, context: Context) -> None:
"""Called when the task completes successfully."""
pass
def on_failure_callback(task_id: str, error: str, context: Context) -> None:
"""Called when the task fails."""
pass
import cloaca
def log_success(task_id, context):
"""Log successful task completion."""
print(f"Task '{task_id}' completed successfully")
# Send metrics, update monitoring, etc.
def alert_failure(task_id, error, context):
"""Alert on task failure."""
print(f"ALERT: Task '{task_id}' failed: {error}")
# Send to Slack, PagerDuty, etc.
@cloaca.task(
id="monitored_task",
on_success=log_success,
on_failure=alert_failure
)
def monitored_task(context):
"""Task with monitoring callbacks."""
result = perform_operation()
context.set("result", result)
return context

Errors in callbacks are isolated and logged - they don’t affect task execution:

def buggy_callback(task_id, context):
raise Exception("Callback error!") # Won't fail the task
@cloaca.task(on_success=buggy_callback)
def resilient_task(context):
"""Task completes even if callback fails."""
return context
# Alerting pattern
def slack_alert(task_id, error, context):
webhook_url = os.environ.get("SLACK_WEBHOOK")
requests.post(webhook_url, json={
"text": f"Task {task_id} failed: {error}"
})
# Metrics pattern
def record_metrics(task_id, context):
duration = context.get("duration_ms", 0)
statsd.timing(f"task.{task_id}.duration", duration)
# Cleanup pattern
def cleanup_temp_files(task_id, error, context):
temp_dir = context.get("temp_dir")
if temp_dir and os.path.exists(temp_dir):
shutil.rmtree(temp_dir)

Tasks receive a Context object for data flow:

@cloaca.task()
def context_example(context):
"""Demonstrate context usage."""
# Get data from previous tasks
input_value = context.get("input_key", "default_value")
# Process the data
result = input_value.upper()
# Set results for subsequent tasks
context.set("output_key", result)
context.set("processing_complete", True)
return context

Design tasks to be idempotent when possible:

@cloaca.task()
def idempotent_task(context):
"""Task that can be safely retried."""
# Check if already processed
if context.get("already_processed"):
return context
# Perform operation
result = perform_operation()
# Mark as processed
context.set("result", result)
context.set("already_processed", True)
return context

Provide meaningful error information:

@cloaca.task()
def validation_task(context):
"""Task with clear validation."""
data = context.get("data")
if not data:
context.set("error", "Required 'data' field is missing")
context.set("valid", False)
return context
if not isinstance(data, dict):
context.set("error", f"Expected dict, got {type(data).__name__}")
context.set("valid", False)
return context
context.set("valid", True)
return context

Tasks can accept an optional second parameter named handle or task_handle to gain access to concurrency slot management. The @task decorator inspects the function signature at registration time and, when a handle parameter is detected, arranges for the executor to provide a TaskHandle instance at runtime.

Add a second parameter named handle or task_handle to the task function:

@cloaca.task()
def wait_for_ready(context, handle):
"""Task that defers until an external condition is met."""
def check_ready():
# Return True when the task should resume
return some_external_check()
handle.defer_until(check_ready, poll_interval_ms=1000)
context.set("ready", True)
return context

The parameter name matters: it must be exactly handle or task_handle. Any other name will not trigger handle injection.

defer_until(condition, poll_interval_ms=1000)

Section titled “defer_until(condition, poll_interval_ms=1000)”

Release the concurrency slot while polling an external condition.

Parameters:

Parameter Type Default Description
condition callable required A function returning bool. Called repeatedly at poll_interval_ms intervals.
poll_interval_ms int 1000 Milliseconds between condition checks.

Behavior:

  1. Releases the executor concurrency slot (freeing it for other tasks)
  2. Polls condition() every poll_interval_ms milliseconds
  3. Reclaims a slot when condition() returns True
  4. Returns control to the task

Raises: ValueError if the handle has already been consumed or the semaphore is closed.

@cloaca.task()
def wait_for_file(context, handle):
import os
target = "/data/input.csv"
handle.defer_until(
lambda: os.path.exists(target),
poll_interval_ms=5000,
)
context.set("file_path", target)
return context

Returns whether the handle currently holds a concurrency slot.

Returns: bool

Raises: ValueError if the handle has already been consumed.

Handle-aware and regular tasks work together in the same workflow:

with cloaca.WorkflowBuilder("mixed_pipeline") as builder:
@cloaca.task()
def normal_task(context):
context.set("step_1", True)
return context
@cloaca.task(dependencies=["normal_task"])
def deferred_task(context, handle):
handle.defer_until(lambda: True, poll_interval_ms=10)
context.set("step_2", True)
return context

When calling a handle-aware task directly (outside the executor), pass None for the handle parameter:

ctx = cloaca.Context()
result = my_handle_task(ctx, None)

The defer_until call will fail if there is no real TaskHandle, so direct calls are useful only for testing the non-deferral parts of the function logic.