Execution¶
Assets execute in a DAG driven by a runner. The DAG orders operations by dependency; the runner schedules ready operations, tracks their state, and returns a result. The engine is async-native; every entry point has a sync form for scripts and notebooks.
Building a DAG¶
DAG(*items) accepts any workload: source or asset instances, definitions (instantiated for
you), and jobs. Each is flattened into its operations. Construction validates the
graph and raises on duplicates, missing or mismatched dependencies, cycles, and an unpartitioned
asset downstream of a partitioned one (see Dependencies).
dag.operations # every node, in insertion order
dag.operation_map # id -> node
dag.topological_generations() # lists of nodes that may run in parallel
dag.get_predecessors(asset.id) # upstream ids
dag.get_successors(asset.id) # downstream ids
dag.mini_dag(asset.id) # one node plus read-only parents
Non-enabled nodes stay in the graph as dependencies but never execute and never appear in the generations.
Materializing¶
result = dag.materialize() # default AsyncRunner
result = dag.materialize(il.TimePartition(dt.date(2026, 1, 15)))
result = await dag.materialize_async(window)
For a specific runner or its options:
runner = il.AsyncRunner(max_workers=8, fail_fast=False)
result = il.run(runner.run(dag, window, metadata={"run_id": "..."})) # sync code
result = await runner.run(dag, window) # async code
window is a PartitionWindow, or a bare partition for the window of one; metadata is carried
onto every event and span of the run; a run_id is generated when absent.
The sync bridge¶
il.run(coro) drives any framework coroutine to completion from synchronous code. Unlike
asyncio.run, it works where a loop is already running (Jupyter) and reuses one persistent
background loop across calls, so loop-bound state such as an AsyncRESTClient cached on a
connection stays valid from one call to the next. Ctrl-C cancels the coroutine. Calling it from
code already on that loop raises RuntimeError; await there instead.
asset.run(), asset.materialize() and dag.materialize() are built on it.
Runners¶
| Runner | Concurrency | Where operations run |
|---|---|---|
AsyncRunner(max_workers=4) |
asyncio tasks bounded by a semaphore | in-process, on the event loop; sync data() offloaded to threads |
SerialRunner() |
one at a time | AsyncRunner with a single slot |
Companion packages register Docker and Kubernetes runners under the same interface, each operation in its own container or Job; see Ecosystem.
A runner is configuration. Each runner.run(dag) makes a run from it that walks the DAG once, so
one runner serves any number of runs, concurrently too. Operations are scheduled dynamically: as
soon as every predecessor has completed, an operation becomes ready and starts while a slot is
free. A failed operation cancels everything downstream of it.
Options¶
| Option | AsyncRunner |
Meaning |
|---|---|---|
max_workers |
4 |
Concurrency ceiling. SerialRunner pins it to 1. |
fail_fast |
True |
Stop submitting after the first failure: work already running finishes and is recorded, everything still queued is canceled. False runs everything that can still run. |
reraise |
False |
Re-raise the first failed operation's exception after the run is finalized. False returns a failed RunResult instead. |
on_event |
None |
Callback receiving this run's events. Subscribed for the duration of the run only. |
Runners are Serializable, so they can be configured from settings:
il.Runner.from_settings(settings.runner) resolves runner.type in the RUNNERS registry and
constructs it with runner.config.
Results¶
result.status # ExecutionStatus.COMPLETED or FAILED
result.window
result.execution_time # seconds
result.executions # id -> ExecutionInfo
result.completed_ids, result.failed_ids, result.canceled_ids
print(result)
# RunResult(status=failed, window=2026-01-15, completed=2, failed=1, canceled=1, time=1.20s, failed=[...], canceled=[...])
ExecutionInfo carries component_id, component_key, status, attempt, start_time,
end_time, execution_time, and result: the OperationResult the operation came to, with its
effects (what it asked the platform to persist) and, on failure, its error and traceback,
which ExecutionInfo.error and ExecutionInfo.traceback read. ExecutionStatus is QUEUED, READY, RUNNING, COMPLETED, FAILED, SKIPPED
(disabled), CANCELED (downstream of a failure).
Failure handling¶
Asset failures are absorbed into the result: the node is marked failed with its message and
traceback, its dependents are canceled, and the run continues or stops depending on
fail_fast. With reraise=True the original exception is re-raised once the run is finalized,
so events and results are complete before it surfaces. A failure of the walk machinery itself
(a deadlock, an invalid graph) raises RunnerError.
Before anything executes, the runner asks every enabled operation's partitioning to check the
window and lists every failure in one PartitionError, so a run no asset can serve fails without
executing anything.
Retrying¶
An operation that fails is attempted again when it carries a RetryPolicy, without the run seeing
the intermediate failure. Nothing retries by default: a policy is in force only where a component
declares one.
The policy is declared on the component whose unit it governs, so one budget never means several things:
| declared on | governs |
|---|---|
| an asset, or any operation | that operation's execution |
| a source | the default its assets inherit |
| a job | that job's runs |
max_attempts counts the first attempt, and the wait before each further one grows geometrically
from delay by backoff, capped at max_delay and spread by jitter so operations failing
together do not retry in lockstep. Whether a given error is worth another attempt is behaviour
rather than configuration: override retryable() to recognise one that never will be.
class Orders(il.Asset):
retry = il.RetryPolicy(max_attempts=3)
def retryable(self, error: Exception) -> bool:
return not isinstance(error, PermissionError)
A retried attempt is not a verdict. The node keeps its status, nothing downstream is canceled, and
fail_fast is not tripped; only an exhausted or declined failure marks the node failed. Each
attempt is recorded as an operation_retried event, and operation_failed means the budget ran
out.
A job's policy works one level up: when a run fails, the platform queues the next attempt after the backoff. Those attempts form a stack, and the rest of the system reads the stack rather than any one attempt. An automatic retry always re-runs only what failed, carrying forward every operation that already succeeded anywhere in the stack; the manual retry endpoint additionally offers re-running everything, for when the earlier success is the thing you distrust. Either way a retry continues from the stack's latest attempt: retrying an earlier attempt retries the stack, and a stack whose latest attempt is still pending, or succeeded, has nothing to retry.
Running single assets¶
asset.run() and asset.materialize() bypass the runner. Pass the DAG when the asset has
dependencies, so upstream data can be read: