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.
Basic Usage
Section titled “Basic Usage”import cloaca
@cloaca.task()def my_task(context): """Example task that processes data.""" # Task implementation context.set("result", "Task completed successfully") return contextDecorator Parameters
Section titled “Decorator Parameters”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 runsretry_attempts(int): Number of retry attempts on failureretry_backoff(str): Backoff strategy: “fixed”, “linear”, or “exponential”retry_delay_ms(int): Initial delay between retries in millisecondsretry_max_delay_ms(int): Maximum delay between retriesretry_condition(str): When to retry: “never”, “transient”, or “all”retry_jitter(bool): Add random jitter to retry delayson_success(callable): Callback function called when task succeedson_failure(callable): Callback function called when task failsinvokes(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 referencedwith ComputationGraphBuilder(...)block must run before the decorator, or the decorator raisesValueError. Python parity with Rust’s#[task(invokes = …)].post_invocation(callable): Callback run after the invoked graph completes; only meaningful withinvokes=(setting it alone raisesValueError).trigger_rules(rule): Conditional gate — when the rule evaluates false (and the task’s dependencies are otherwise satisfied), the task lands in the realSkippedstate and its body never runs. Python parity with Rust’s#[task(trigger_rules = …)]. Default: always run.
Trigger rules
Section titled “Trigger rules”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 contextSee Trigger rules for evaluation semantics and skip propagation.
Documenting a task: what: / why:
Section titled “Documenting a task: what: / why:”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 contextThe 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.
Example with Dependencies
Section titled “Example with Dependencies”@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 contextTask Functions Are Synchronous
Section titled “Task Functions Are Synchronous”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.
Error Handling
Section titled “Error Handling”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 contextTask Callbacks
Section titled “Task Callbacks”Use on_success and on_failure callbacks for monitoring, alerting, or cleanup:
Callback Signatures
Section titled “Callback Signatures”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.""" passExample with Callbacks
Section titled “Example with Callbacks”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 contextCallback Error Isolation
Section titled “Callback Error Isolation”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 contextCommon Callback Patterns
Section titled “Common Callback Patterns”# Alerting patterndef 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 patterndef record_metrics(task_id, context): duration = context.get("duration_ms", 0) statsd.timing(f"task.{task_id}.duration", duration)
# Cleanup patterndef 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)Context Usage
Section titled “Context Usage”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 contextBest Practices
Section titled “Best Practices”Idempotency
Section titled “Idempotency”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 contextClear Error Messages
Section titled “Clear Error Messages”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 contextTaskHandle
Section titled “TaskHandle”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.
Requesting a TaskHandle
Section titled “Requesting a TaskHandle”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 contextThe parameter name matters: it must be exactly handle or task_handle. Any other name will not trigger handle injection.
TaskHandle Methods
Section titled “TaskHandle Methods”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:
- Releases the executor concurrency slot (freeing it for other tasks)
- Polls
condition()everypoll_interval_msmilliseconds - Reclaims a slot when
condition()returnsTrue - 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 contextis_slot_held()
Section titled “is_slot_held()”Returns whether the handle currently holds a concurrency slot.
Returns: bool
Raises: ValueError if the handle has already been consumed.
Mixing Handle and Non-Handle Tasks
Section titled “Mixing Handle and Non-Handle Tasks”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 contextDirect Calls (Testing)
Section titled “Direct Calls (Testing)”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.
See Also
Section titled “See Also”- Context - Data passed between tasks
- WorkflowBuilder - Combine tasks into workflows
- DefaultRunner - Execute workflows containing tasks
- Task Deferral Architecture - Internal mechanics of slot tokens and defer_until
- Tutorial 08 - Task Deferral - Rust walkthrough of the deferred-tasks example