How to chain tasks and fan them out¶
dura has no built-in concept of a "workflow" or a parent task. This
guide shows you how to build both a chain and a fan-out/fan-in out of the
same two primitives: a handler that calls spawn_task, and an
idempotency key that makes calling it twice safe.
Chain one task into another¶
To run task B once task A finishes, have A's handler spawn B as its last
step. Wrap the spawn in a checkpoint so a retry of A (after a crash, or
after fail_run) does not spawn a second B:
def fetch_report(engine, task):
rows = fetch_rows_from_somewhere()
engine.checkpoint(
task_id=task.task_id,
step_name="spawn_next",
fn=lambda: engine.spawn_task(
name="summarize_report",
params={"rows": rows},
).task_id,
)
return {"fetched": len(rows)}
def summarize_report(engine, task):
return {"summary": summarize(task.params["rows"])}
The checkpoint stores the child's task_id (a plain string, so it's
JSON-serializable). Any later call for the same (task_id, "spawn_next")
returns that same id instead of calling spawn_task again. See How to
checkpoint steps for the underlying mechanism.
Fan a task out into many sub-tasks¶
To have one task create several independent sub-tasks, spawn them in a
loop. Give each an idempotency key derived from the parent's task_id
plus something that varies per child, so a retry of the parent doesn't
spawn the batch twice:
def start_batch_import(engine, task):
uris = list_files(task.params["bucket"])
batch_id = task.task_id
for uri in uris:
engine.spawn_task(
name="import_file",
params={"uri": uri, "batch_id": batch_id},
idempotency_key=f"import:{batch_id}:{uri}",
)
return {"queued": len(uris)}
dura doesn't track a parent/child relationship between these tasks
itself. Passing batch_id through params, the way this example does,
is how the sub-tasks find their way back to the batch they belong to.
Fan back in: know when every sub-task is done¶
dura has no built-in join or wait-group. Build one out of durable state
(as a countdown) and an event (to signal completion), the same primitives
as the rest of this guide:
def start_batch_import(engine, task):
uris = list_files(task.params["bucket"])
batch_id = task.task_id
engine.set_state(namespace=f"batch:{batch_id}", key="remaining", value=len(uris))
for uri in uris:
engine.spawn_task(
name="import_file",
params={"uri": uri, "batch_id": batch_id},
idempotency_key=f"import:{batch_id}:{uri}",
)
return {"queued": len(uris)}
def import_file(engine, task):
batch_id = task.params["batch_id"]
do_import(task.params["uri"])
remaining = engine.update_state(
namespace=f"batch:{batch_id}", key="remaining", fn=lambda n: n - 1
)
if remaining == 0:
engine.emit_event(event_name=f"batch_done:{batch_id}")
return {"done": True}
Set the count before spawning any child, not after, so a child that finishes unusually fast never decrements a counter that hasn't been written yet. Any other task can then wait on the batch the normal way:
def notify_batch_complete(engine, task):
engine.wait_for_event(
run_id=task.run_id,
task_id=task.task_id,
step_name="wait",
event_name=f"batch_done:{task.params['batch_id']}",
timeout_secs=3600,
)
send_notification(task.params["batch_id"])
Because emit_event is first-write-wins, it doesn't matter whether the
waiter registers before or after the last import_file finishes, or
which task gets claimed first. See How to wait for
events and How to use durable
state for the primitives this pattern is built
from.