Ray-Native Workflows¶
django-ray workflows combine one durable Django task with low-overhead Ray-native steps. The outer task is queued, retried, cancelled, and recorded in the database. Internal workflow steps are submitted directly to Ray and exchange intermediate values through Ray object references without creating a database row per step.
This is unrelated to the removed upstream ray.workflow package. See the
Ray Ecosystem Support and Install Matrix for that
distinction and the durable boundary for optional Ray components.
This model is intended for fan-out workloads where database-backed dispatch would cost more than the individual units of work.
WorkflowSignature objects are reusable definition builders. They are not durable
execution plans, and a logically static chain or group is not automatically a
compiled graph. The maintained workflow-plan contract separates
definitions, immutable plans, per-run invocations, logical work, physical actors, and
execution strategies while preserving this public API.
Requirements¶
Cluster Ray Core is the lowest-latency remote path for bounded workflows while the task-manager Ray Client connection remains part of the workload lifetime. A disconnect beyond Ray's reconnect grace period terminates its in-flight work; an outer retry replays the workflow rather than resuming completed leaves. Ray Job mode also supports workflows and is the safer starting point for long or coarse work that must continue independently of the submitting connection: its isolated driver connects back to Ray lazily before submitting leaves. Local execution is available for sync workers and unit tests. See worker execution modes.
DJANGO_RAY = {
"RAY_ADDRESS": "ray://ray-head:10001",
"RUNNER": "ray_core", # Lowest submission overhead.
}
Start a worker normally, or select the cluster explicitly:
Complete Dynamic Fan-Out¶
The following is one complete myapp/workflows.py module. Every callable is defined at
module scope so Ray workers can import it:
from django.tasks import task
from django_ray.workflows import chain, map_step, step
def build_items(count: int) -> list[int]:
return list(range(count))
def calculate(value: int) -> dict[str, int]:
# Stand-in for one independently expensive API or compute operation.
checksum = sum((value * number) % 97 for number in range(500_000))
return {"value": value, "checksum": checksum}
def summarize(results: list[dict[str, int]]) -> dict[str, int]:
return {
"items": len(results),
"checksum": sum(result["checksum"] for result in results),
}
calculation = chain(
step(build_items),
map_step(calculate, ray_options={"num_cpus": 0.25}).with_limits(
max_concurrency=16,
max_items=10_000,
),
step(summarize),
)
@task(queue_name="default")
def calculate_batch(count: int) -> dict[str, int]:
return calculation.run(count)
Calling calculate_batch.enqueue(20) creates one durable RayTaskExecution. Once it
starts, build_items, every calculate call, and summarize are connected inside
Ray. After its input iterable resolves, the bounded map_step consumes that iterable
lazily, keeps at most 16 map items submitted at once, incrementally resolves them, and
returns results in input order.
For a Kubernetes sync, the same shape is typically list_namespaces → map(sync one
namespace) → summarize. Keep client creation or discovery outside the smallest inner
resource loop where possible, and batch resources when each API operation is shorter
than Ray submission overhead.
Bound Dynamic Fan-Out¶
Call with_limits() on a map signature when its input cardinality is data-dependent:
sync_namespaces = map_step(
sync_namespace,
ray_options={"num_cpus": 0.25},
).with_limits(
max_concurrency=8,
max_items=500,
cancel_timeout_seconds=1.0,
)
max_concurrency is an admission window. At most that many map-item result references
are retained while Ray work is pending, and results are collected as individual items
finish. Fast items can therefore make room without waiting for an earlier slow item,
while the final list remains in input order. The window counts map items, not every
physical task in a nested signature. A mapped group with three branches and a window
of eight can have up to 24 branch tasks plus its bounded per-item collectors submitted.
A nested dynamic map needs its own with_limits() contract; an outer window cannot cap
the number of leaves expanded inside one map item.
Lazy admission begins only after map_step calls executor.resolve() for its input. If
an upstream Ray task returns a list or inventory, that task still produces the complete
value and Ray transfers and deserializes it into the workflow coordinator before the
first map item is admitted. Only an iterator that is already available locally is pulled
one item at a time by the admission loop. Remote or paged input materialization is
tracked in GitHub issue #94.
For every admitted item, django-ray retains the terminal result reference and the
physical dependency references created by its nested chain or group. Those cleanup
references are released as soon as the item result is collected, so their peak remains
the admission window multiplied by the signature's fixed physical width.
max_items is an expansion safety limit. Sized inputs that exceed it fail before any
leaf is submitted. For a generator, detecting overflow requires reading item
max_items + 1; work completed before that discovery is not rolled back. Use idempotent
leaves when iteration, retries, or cancellation can overlap external side effects.
On the first leaf, iterator, or collection failure, django-ray stops reading new items,
requests cancellation for every retained physical reference belonging to the failed and
pending items, and waits up to cancel_timeout_seconds for them to become terminal. It
does not assume that cancelling a final task also cancels its Ray input dependencies.
The default deadline is one second. The original exception is always re-raised.
References still pending after the deadline are released rather than making cleanup wait
indefinitely; an uncooperative running leaf may consequently finish after the workflow
has failed. The deadline bounds only Ray cancellation and drain waiting. It does not
bound input deserialization or arbitrary user iterator cleanup such as a generator's
close() method.
Bounded maps use one aggregate kind="map" progress node with submitted, completed,
in-flight, and input-exhaustion counters. Their physical item nodes are intentionally
omitted from the live workflow graph. This keeps observability proportional to the
declared workflow rather than to a 10k- or 50k-item expansion. For the same reason,
report_progress() returns False inside those physical item leaves; the aggregate map
counters remain available.
Incremental collection bounds pending references, not total result bytes. The ordered result list is still materialized in the workflow coordinator before it is placed back in Ray, so coordinator memory remains proportional to total output size. A bounded in-Ray reduction or aggregation path is tracked in GitHub issue #91.
Opt-in Ray result buffer¶
When the next stage is a Ray step, a bounded map can keep its complete intermediate list out of the outer workflow coordinator:
sync_resources = chain(
step(list_namespaces),
map_step(sync_namespace, ray_options={"num_cpus": 0.25})
.with_limits(max_concurrency=8, max_items=500)
.with_result_buffer(
max_serialized_bytes=64 * 1024 * 1024,
actor_options={
"num_cpus": 0.25,
"memory": 128 * 1024 * 1024,
"resources": {"workflow_result_buffer": 1},
"scheduling_strategy": "SPREAD",
},
),
step(summarize_sync),
)
This is an explicit transport selection. max_concurrency, max_items, and
max_serialized_bytes must all be positive caller-declared limits. actor_options
must explicitly set num_cpus > 0 and an integer memory request at least as large
as max_serialized_bytes. The only optional version 1 actor fields are a bounded
positive resources mapping and scheduling_strategy="DEFAULT" or "SPREAD";
unknown fields are rejected instead of being forwarded to Ray.
Every requested custom resource must be advertised by an eligible cluster node. If
it is unavailable, the ready handshake intentionally remains pending while ordinary
progress flushing and cancellation stay live, and no map leaf effects begin.
The non-detached actor has fixed max_restarts=0, max_task_retries=0,
max_concurrency=1, and max_pending_calls=2. django-ray waits for its ready
acknowledgement through normal progress-flushing resolution before admitting leaf
side effects. The leaf admission window remains at max_concurrency, but the outer
coordinator uses ray.wait(..., fetch_local=False) to select a ready leaf without
decoding it. It then submits and completely resolves one small append acknowledgement
before issuing another actor call. The second pending-call slot only gives Ray room to
retire a completed prior call from the sender handle's bookkeeping before accepting its
successor. It does not permit concurrent protocol transitions, multiple unacknowledged
calls, or an extra retained result. Map payloads are never resolved by the coordinator.
The byte limit counts bytes actually retained by the version 1 ray.cloudpickle
codec using pickle protocol 5. An append serializes first, checks the prospective item
and byte totals, and mutates retained state only when both remain within their bounds.
This is not a limit on decoded Python heap size, transient serialization memory, Ray
wire bytes, or object-store bytes. The scheduler memory request accounts for actor
placement but is not a hard process-memory limit.
Finalization returns the ordered Python list and a small acknowledgement as two direct
Ray returns. It does not call ray.put() inside the actor and does not nest an
ObjectRef inside another return. The coordinator proves both returns are materialized,
resolves only the acknowledgement, kills the actor best effort, and passes the one
unresolved payload reference to the downstream Ray step. Append and finalization waits
continue flushing ordinary workflow progress.
If the buffered map is terminal, run() necessarily resolves that final reference and
the coordinator still uses O(total output) memory. A downstream step also materializes
the complete ordered list and therefore uses O(total output) consumer memory, while the
object store holds the final object. The actor itself transiently decodes the complete
list during finalization. Chunked/reducer transport and total-independent object-store
or consumer memory remain in GitHub issue #91,
and production-shaped throughput and memory validation remain in
GitHub issue #87.
A version 1 result-buffer map cannot be nested inside a dynamic map; plan
materialization rejects that topology before actor or leaf effects so actor
multiplication stays explicit. Local execution preserves input order, max_items, and
ordinary leaf failure behavior without creating an actor. It validates and fingerprints
the buffer selection but does not retain, cloudpickle, or measure results against
max_serialized_bytes; that byte limit is specifically the Ray actor's retained-byte
contract and is enforced only during Ray execution. Maps that do not call
with_result_buffer() retain their existing bounded or eager transport exactly.
Cleanup cancels pending or resolving leaf references plus their captured nested
dependencies, then discards the serialized bytes retained by the actor and kills it
best effort on failure or cancellation. Non-detached ownership also cleans the actor
when its owner dies, but the protocol makes no cleanup, recovery, or durability
guarantee after node loss.
Opt-in ordered result fold¶
When a workflow needs one compact summary rather than the complete mapped list, call
reduce() on a bounded map:
sync_resources = chain(
step(list_namespaces),
map_step(sync_namespace, ray_options={"num_cpus": 0.25})
.with_limits(max_concurrency=8, max_items=500)
.reduce(
step(
merge_sync_summary,
runtime_env="kubernetes-sync",
),
initial=empty_sync_summary,
max_serialized_bytes=8 * 1024 * 1024,
actor_options={
"num_cpus": 0.25,
"memory": 16 * 1024 * 1024,
"scheduling_strategy": "SPREAD",
},
),
step(store_sync_summary),
)
reduce() changes the map result from list[item] to one accumulator. It requires
positive max_concurrency, max_items, and max_serialized_bytes limits, an explicit
initial value (including explicit None when appropriate), the same narrow
resource-accounted actor_options accepted by with_result_buffer(), and exactly one
Step reducer. The reducer contract is a strict input-order left fold:
The reducer must be synchronous and return a concrete non-generator value. Reducer Ray
task options are rejected because it runs inside the fold actor, not as a separate Ray
task. Its importable callable identity, Django bootstrap flag, bound-argument schema,
and resolved effective RuntimeEnv are applied to and fingerprinted with that actor.
Actor-internal calls do not create per-item progress nodes, and report_progress() or
durable workflow task context inside the reducer is unsupported in version 1. Reducers
should be pure: retries or failures do not provide exactly-once reducer side effects.
The initial accumulator is invocation data. Its value is cloudpickled and validated by the actor before any mapper leaf is admitted, but it is never persisted in the effective plan or included in the plan fingerprint. The plan records only the required initial binding schema. Local execution also cloudpickle round-trips the initial value before leaves so every run receives a fresh decoded accumulator, and it imports and validates the reducer before mapper effects. A reducer supplied only by a RuntimeEnv is therefore Ray-only. Local mode does not create an actor or enforce the Ray retained-byte limit.
Leaves may finish in any order, but the actor keeps its accumulator serialized and folds
only the next contiguous input index. Admission credits are replenished only when items
are incorporated. If the earliest item is slow, no more than
min(max_items - 1, max_concurrency - 1) later results can be retained, and no more than
max_concurrency mapper references remain admitted. This head-of-line backpressure is
intentional: the reducer need not be associative or commutative, so a parallel tree
reduction would change behavior.
The actor still executes protocol transitions serially with max_concurrency=1, and the
coordinator completely resolves each small acknowledgement before issuing the next
transition. max_pending_calls=2 leaves one per-handle bookkeeping slot for Ray to retire
a completed prior call without rejecting its successor; it does not permit two
fold transitions to execute concurrently.
max_serialized_bytes covers the serialized accumulator plus serialized out-of-order
items retained by the actor. Initial, individual item, resulting accumulator, and
combined-state checks are transactional: state changes only after the complete
contiguous fold candidate succeeds. Overflow can still be completion-order-sensitive.
For example, an early large out-of-order result may fail the combined-state check before
the missing earlier item can reduce the accumulator. A schedule-independent byte limit
would require a different deferred/backpressure protocol. The limit does not hard-cap
temporary decoded actor heap, reducer working memory, serialization scratch space, Ray
wire bytes, or object-store bytes. Ray's scheduler memory request remains placement
accounting rather than process enforcement.
The coordinator waits for leaves with ray.wait(..., fetch_local=False), resolves only
small protocol acknowledgements, and never decodes mapped items or the intermediate
accumulator. Finalization returns the accumulator and acknowledgement as two direct Ray
returns; one unresolved accumulator reference becomes the single dependency of a
downstream Ray step. A terminal fold necessarily resolves that one accumulator in
run(), so its final size still matters, but no O(total item) list is constructed.
Fold mode and with_result_buffer() are mutually exclusive. A version 1 fold cannot be
nested inside a dynamic map, and its mapper cannot itself contain a dynamic map; plan
materialization rejects both shapes before actor or leaf effects. Static chain and
group mapper bodies remain supported. Mapper, reducer, actor, byte-limit, and
cancellation failures stop admission, recursively cancel pending nested dependencies
without fetching their payloads into the coordinator, discard actor state, and preserve
the original exception. Non-detached ownership and fixed no-restart semantics match the
result-buffer protocol. List-preserving chunk or hierarchical transport remains in
GitHub issue #91, and production
evidence remains in GitHub issue #87.
Calling map_step() without with_limits() retains the original eager behavior for
compatibility. New dynamic workloads should normally choose an explicit window and an
input cap. Local execution preserves inputs, ordered outputs, limits, and failures but
runs leaves sequentially.
Choose Concurrency and Batch Granularity¶
For a rate-limited Kubernetes or HTTP API, max_concurrency limits concurrent batches;
it does not enforce requests per second. Keep the API client's own token-bucket or
server-advertised retry policy enabled. Choose the mapped item deliberately:
- Map one namespace when discovery and reconciliation can share one client session and one failure boundary.
- Map one
(namespace, resource_kind)batch when namespaces contain enough resources to leave cluster capacity idle. - Batch several tiny resources into one item when a single API round trip is shorter than Ray submission overhead.
Start with a window no larger than the external client's connection pool, benchmark
throttling and retry rates, and increase it only while useful throughput improves. A
preceding step can construct batches; map_step passes each batch to one leaf and
returns one ordered result per batch.
Chains and Groups¶
chain passes each result as the first argument to the next signature. Reusing the
module above:
group sends the same input to every child and returns an ordered result list:
from django_ray.workflows import group
def minimum(values: list[int]) -> int:
return min(values)
def maximum(values: list[int]) -> int:
return max(values)
def total(values: list[int]) -> int:
return sum(values)
inspect_values = chain(
step(build_items),
group(
step(minimum),
step(maximum),
step(total),
),
)
Groups can contain chains, maps, or other groups.
Django-Aware and Native Steps¶
Workflow steps are Ray-native by default and skip Django initialization. This is the fast path for API clients, transformations, and compute that do not use Django models:
Set django=True when a step needs Django's app registry, ORM, settings-dependent
components, or another Django facility:
def load_account_name(account_id: int) -> str:
from myapp.models import Account
return Account.objects.values_list("name", flat=True).get(pk=account_id)
load_account = step(load_account_name, django=True)
Django initialization is guarded by the app registry, so a reused Ray worker does not initialize Django again for every step.
Use ray_options or Step.with_options() for Ray scheduling controls:
gpu_calculation = step(
calculate,
ray_options={"num_gpus": 1, "max_retries": 2},
)
two_cpu_calculation = step(calculate).with_options(num_cpus=2)
Use a named RuntimeEnv profile when a leaf needs different dependencies:
Leaves otherwise inherit the outer durable task's environment. See Runtime Environments.
Application Progress¶
Long-running leaves can report progress without writing to Django directly:
from django_ray.workflows import report_progress
def normalize_rows(rows: list[dict[str, str]]) -> list[dict[str, str]]:
normalized = []
for index, row in enumerate(rows, start=1):
normalized.append({key: value.strip() for key, value in row.items()})
if index % 100 == 0:
report_progress(
index,
len(rows),
message="Normalizing rows",
metrics={"last_row": index},
)
return normalized
report_progress() is a no-op that returns False during local or actor-free
workflow execution. In full-reporting Ray execution, True means that the validated
value was accepted into the leaf's best-effort producer session. It is not proof that
the progress actor processed the value or that Django persisted it.
Each reporting leaf invocation retains at most one outstanding application-progress
acknowledgement and one canonical latest-value replacement slot. When the
acknowledgement is still pending, another valid call replaces that slot instead of
adding another actor call. At leaf exit, the producer makes at most one bounded
latest-value handoff before sending COMPLETED or FAILED. Structural and lifecycle
events, including STARTED, COMPLETED, and FAILED, are never coalesced. Producer,
acknowledgement, or diagnostic failure remains observational and cannot replace the
callable's result or exception.
Metrics are bounded operational metadata: use at most 32 scalar string, number, boolean, or null values. Keys are capped at 64 UTF-8 bytes, strings at 256 UTF-8 bytes, and the normalized mapping at 4 KiB; sensitive or oversized values are redacted or replaced before a value can enter the replacement slot or cross Ray.
Graph and Progress Schema¶
During execution, full reporting writes a versioned schema-v2 compatibility snapshot suitable for a custom task-tracking UI:
{
"schema_version": 2,
"workflow_id": "django-ray:42",
"run_identity": {
"schema_version": 1,
"run_id": "2eb22ff3-5fd2-43a0-834c-d920737b584c",
"task_execution_pk": 42,
"attempt_number": 2,
"execution_generation": 5
},
"revision": 12,
"state": "RUNNING",
"progress_percent": 50.0,
"graph": {
"nodes": [
{
"node_id": "0.1.m0",
"kind": "task",
"label": "sync_resource",
"callable_path": "myapp.workflows.sync_resource",
"dependencies": ["0.0"],
"state": "RUNNING",
"progress": {"current": 50, "total": 100, "percent": 50.0},
"runtime_env": {"mode": "inherit", "hash": "..."},
"execution": {
"ray_task_id": "...",
"ray_job_id": "...",
"ray_node_id": "...",
"ray_worker_id": "..."
}
}
],
"edges": [{"source": "0.0", "target": "0.1.m0"}]
}
}
Node IDs are stable for one workflow expansion. Dynamic map nodes appear after
their input iterable resolves, so clients should redraw when revision changes.
Revisions are monotonic only within one run_identity.run_id and restart when a
new invocation claims progress ownership. Clients must reset their stored graph
before applying a revision from a different run ID, attempt, or execution
generation. Database writes occur only when the coordinator revision changes and
the task is still RUNNING with that exact attempt, generation, and run ID. The
independent task-monitor heartbeat still proves that the owning worker is alive.
When WORKFLOW_PROGRESS_SCHEMA_V3_PILOT=True, one complete terminal schema-v2 actor
snapshot may also be normalized into the bounded schema-v3 summary, immutable topology
pages, and latest-state node detail. A successful publication becomes the preferred
source for authorized bounded readers; it does not replace the live schema-v2 writes.
The task Admin can project one coherent terminal schema-v3 publication into an accessible
execution graph. The section stays collapsed and performs no graph request until an
operator opens it. One successful response is cached for that page; a pre-terminal
NOT_REPORTED response or transport failure can be retried by closing and reopening the
section. The graph is limited to 100 nodes, 256 edges, 100 detail records, and a 128 KiB
response. It reads only the first bounded page of each collection and refuses cycles,
unknown endpoints, count or publication mismatches, cursors, truncation, and over-limit
runs without rendering a partial graph.
Graph nodes are semantic links in dependency order, while the connecting artwork is
decorative and never carries outcome color. Each card labels its structural path as
Node ID, presents execution state separately, and has one independent Output row. A
node without an opted-in projector says that no preview was requested even while its
execution state is pending or running. A submitted opted-in node reports preview
PENDING until it publishes a terminal availability, and a failed application node is
UNAVAILABLE. When a preview is available, that same row labels the bounded JSON value
as a preview so it cannot be mistaken for the node identifier, execution state, or
complete task result. Labels, states, bounded progress messages, map fan-out counts,
bounded failure text, and the output-preview envelope described below are the complete
display allowlist. Callable paths, arguments, internal raw results, RuntimeEnv data, Ray
identifiers, raw metrics and events, and workflow-plan payloads are never returned by the
graph endpoint. A failed run marks each failure origin and its incoming ancestor path
without using color alone. Node links are pinned to the same retained task attempt as
the graph, and the graph summary names that page-rendered attempt. If live polling
advances the task to a newer attempt, reload the page before opening its graph. The
bounded topology and detail JSON routes remain available as explicit diagnostic
fallbacks.
When a retry succeeds after one or more failed attempts, Workflow execution presents one chronological attempt-graph stack: previous failures first and the current attempt last. Every panel is independently collapsible. Archived panels are independently lazy and pinned to their attempt across the graph, topology, and node-detail routes. Each archived panel makes at most one request per page, including when the bounded response is unavailable or rejected. The current graph appears exactly once. This keeps retry history comparable without eagerly loading hidden graphs or mixing a prior run with the current attempt.
That graph is a full-reporting detail surface. A terminal-only run instead shows its
terminal outcome and OMITTED_BY_POLICY status explicitly in the live and workflow
diagnostic panels. The Admin does not render or request an execution graph and does not
offer topology or node-detail links for that summary.
Opt-in node output previews¶
An importable workflow leaf may explicitly project its successful result into one small operator-facing JSON value:
from typing import Any
from django_ray.workflows import step
def fulfill_order(order_id: str) -> dict[str, Any]: ...
def preview_fulfillment(result: dict[str, Any]) -> dict[str, Any]:
return {
"order_id": result["order_id"],
"item_count": len(result["items"]),
"status": result["status"],
}
fulfillment = step(fulfill_order).with_output_preview(preview_fulfillment)
The projector must be a module-level importable callable. In full-reporting Ray
execution it runs beside the leaf only after the application callable succeeds. Its
return value may contain only exact JSON scalars, lists, and string-keyed objects. The
preview profile permits at most four nested levels, 32 aggregate items, 16 list items,
15 object entries, 64 UTF-8 bytes per key, 256 UTF-8 bytes per string, 512 canonical
JSON bytes, and 2 KiB of decoded value budget. Integer and integer-valued numbers must
fit the interoperable JSON safe range, and floating-point values must be finite. Byte
strings, tuples, sets, custom objects, callables, coroutines, Ray object references,
and actor handles are unsupported. django-ray never calls repr() or traverses an
unprojected task result.
The projector is trusted synchronous application code in the leaf process, so its work counts toward leaf runtime. Keep it cheap, deterministic, side-effect-free, and read-only with respect to the result it receives.
Terminal formatting is removed before configured redaction and before the event crosses
Ray. Formatting-only changes do not mark an output REDACTED or a progress event
truncated; actual policy redaction and size loss still do. Historical readers normalize
the stored preview for display while evaluating the current redaction policy against the
authenticated stored value, so formatting cannot hide a newly sensitive value.
The graph keeps one Output row and labels an available bounded JSON value explicitly
as a preview, separate from the Node ID and execution state. Its fixed envelope
reports one of NOT_REQUESTED, PENDING,
AVAILABLE, REDACTED, TOO_LARGE, UNSUPPORTED, FAILED, UNAVAILABLE, or
OMITTED_BY_POLICY; only AVAILABLE and REDACTED carry a value. A projector import,
execution, validation, reporting, or logging Exception cannot replace the successful
workflow result. Process-control exceptions such as KeyboardInterrupt and SystemExit
are not swallowed at preparation, projection, import, or logging seams. Failed
application leaves never receive a successful preview value.
This field is diagnostic only. It is not the complete task result, a checkpoint, retry input, selective-resume marker, idempotency receipt, or proof that an external effect committed. No executor or lifecycle path reads it to decide recovery or replay. Keep credentials, model artifacts, documents, large datasets, and effect receipts out of previews even when they would fit the limit; redaction is defense in depth, not an authorization or encryption boundary.
The projector and limits profile are part of the effective-plan fingerprint, while the
workflow's declared output remains only result. Events and durable detail are fenced
by task, attempt, execution generation, and run ID, so a stale attempt cannot replace a
current preview. Node-detail schema version 2 adds the preview envelope; stored version
1 detail remains readable and is shown as UNAVAILABLE without rewriting its bytes or
digest. Readers authenticate and validate stored schema-version-2 bytes before applying
the current REDACT_PATTERNS. If a newer policy matches any historical preview value,
both current- and archived-attempt readers return the existing REDACTED marker for
that preview only. They do not reveal the old value, rewrite its payload or digest, or
degrade the rest of the graph to CORRUPT. Terminal-only and disabled policies create
no node-detail graph and do not run the projector. Local execution also leaves
projection disabled so local workflow semantics stay identical to the callable result.
Local Execution¶
Signatures run locally when Ray is not initialized. This makes workflow logic easy to exercise in unit tests:
Set use_ray=True to fail instead of falling back when Ray is unavailable.
Workflow Progress Policy¶
Ray workflow progress defaults to the configured
WORKFLOW_PROGRESS_REPORTING_POLICY="full". Use terminal-only reporting when a
workflow needs one durable completion summary without live per-node telemetry:
Terminal-only reporting creates no workflow progress actor, sends no node or
application-progress RPCs, and writes no RayTaskExecution.progress_data. Calls to
report_progress() return False, and the workflow submits the same Ray work. The
accepted outer-task success or failure transition makes exactly one best-effort
schema-v3 summary publication after result preparation has succeeded or the durable
application error is known. It records the pinned strategy and plan fingerprint,
declared plan counts, terminal outcome, and bounded timestamps. Discovered, retained,
and node-state counts remain zero because no node execution evidence was collected.
Detail is OMITTED_BY_POLICY; there is no topology or node-detail revision, manifest,
page, or row.
Terminal-only publication is observational. Summary construction, validation, or database attachment failure cannot replace the workflow result or application error. A stale lifecycle fence accepts neither the terminal task transition nor its summary. The authorized summary API and Admin show the terminal record explicitly, while topology pages return empty omitted-by-policy responses and the Admin execution graph stays unavailable.
Use disabled reporting when no workflow-progress summary is needed:
Disabled mode has the same zero-actor, zero-progress-RPC, and zero-legacy-write behavior, but does not attempt the terminal schema-v3 summary. Both actor-free modes preserve the durable outer task's state, result/error, retry, cancellation, recovery, and monitor heartbeat behavior.
The bounded task summary exposes the effective policy. Authorized bounded workflow
progress readers return availability="DISABLED" with no fabricated summary or graph
for the current disabled run. The plan selection is the current-attempt signal, so an
active full or terminal-only run without schema v3 remains NOT_REPORTED, while a
terminal full or terminal-only run without its expected schema-v3 publication is
MISSING. Historical attempts without an archived schema-v3 summary remain
NOT_REPORTED. Local execution has no progress actor and is recorded as disabled
regardless of the configured Ray reporting default.
"full", "terminal_only", and "disabled" are executable policies. Sampled
reporting remains later #79 work. The schema-v3 pilot still applies only to full mode:
it collects live actor evidence and changes terminal publication, while terminal-only
mode publishes its bounded summary without enabling the pilot. Changing
WORKFLOW_PROGRESS_FLUSH_SECONDS only throttles full-mode database snapshots and does
not remove producer or actor overhead.
Full reporting prepares every data event as canonical identity-bound JSON before the
Ray call. Event payloads are capped at 16 KiB, complete wire and decoded envelopes at
32 KiB, and dependency-edge batches at 32 edges. The actor revalidates the complete
task, attempt, generation, and workflow-run fence before mutation. With the schema-v3
pilot disabled, its node, edge, recent-event, and retained-byte state uses the durable
V1 limits. Enabling the pilot narrows actor collection and publication to the fixed
schema-v3-pilot-v1 profile: 512 nodes, 2,048 edges, 2 MiB of topology, 1 MiB of
detail, and 4 MiB combined, with the byte ceilings applied to encoded and decoded
evidence. Descriptive metadata is redacted before it crosses Ray.
Full reporting is acknowledgement-driven, not time-sampled. When acknowledgements
keep up, it may submit every accepted application update. Under a slow acknowledgement,
one leaf invocation coalesces replaceable application progress into its one latest-value slot.
There is no selectable sampled policy or sampling interval.
These bounds close the individual envelope, retained collector, and per-leaf application-progress state gaps for full reporting. They do not bound the aggregate number of independently bounded sessions created by forked actor handles, their combined calls or bytes, or the actor mailbox across a whole workflow. Aggregate admission/coalescing must be proven before sampled reporting can be introduced. Bounded actor-to-preparation draining at the hard V1 ceilings also remains separate scale and default-activation work. The stricter full-detail producer pilot is therefore experimental and default-off rather than a general V1-scale claim. If actor ingress records any rejection or accepted truncation, the terminal adapter refuses schema-v3 publication instead of claiming that an incomplete graph is complete. A bounded event or actor snapshot is observational state, not a recovery protocol.
Durability Semantics¶
The outer Django task is the durability and retry boundary:
- Internal steps do not create individual Django tasks.
- In full reporting mode, an in-memory Ray coordinator collects node events. The outer
task writes a schema-v2 compatibility snapshot of the actor's retained bounded state
to
RayTaskExecution.progress_dataatWORKFLOW_PROGRESS_FLUSH_SECONDSintervals. Individual producer envelopes and retained actor nodes, edges, events, and bytes are bounded. Each reporting leaf invocation also holds no more than one outstanding mutable application-progress acknowledgement and one canonical latest-value slot during execution, followed by one bounded terminal handoff/report. Structural and lifecycle evidence is never coalesced. Actor ingress diagnostics report actor-side rejection counts, accepted events marked truncated, a versioned fixed-shape cost block, and an optional fixed-shape aggregate of accepted leaf producer reports. The producer aggregate counts valid offers, submissions, superseded and locally dropped values, producer-observed acknowledgement outcomes, and terminal-handoff outcomes. It contains no producer identities or application values. A pending acknowledgement means only that the leaf had not observed its result when it sealed the report; the actor may still process that application-progress call before the report. The actor-cost block uses saturating counters for actor-received logical calls/bytes, calls decoded under the exact run fence by fixed event kind, end-to-end processed delivery delay, ingest handler wall/process CPU time, and snapshot-build wall/process CPU time through the retained snapshot. It contains no application payloads or variable-cardinality producer labels. A producer-side failure before actor submission cannot appear in those counters, and processed delivery delay includes transport, scheduling, queueing, and clock effects rather than isolating mailbox lag. The aggregate Ray mailbox across forked handles, snapshot-to-preparer drain, and transient snapshot materialization are not yet governed by the later admission/backpressure contract. Terminal-only and disabled modes bypass that live coordinator, event codec, actor, producer session, and snapshot path while leaving the durable outer-task boundary intact. Terminal-only still makes its one bounded best-effort summary publication after the application reaches success or failure; disabled does not. - A separate nullable
workflow_progress_summary_jsonfield and schema-v3 codec are deployed reader-first. The field is fixed-shape and capped at 16 KiB of canonical UTF-8 JSON. Package-owned topology/detail tables and the internal storage writer can now publish a verified immutable topology, sparse normalized latest-state detail, and that summary pointer in one transaction. The standalone schema-v3 writer rejects topology/detail pointers; it is the only path for an intentional summary-onlyDISABLEDorOMITTED_BY_POLICYrecord, which creates no empty detail storage. Terminal-only execution now uses the omitted-by-policy path for one terminal summary without a live actor. Authorized public readers are implemented. The default-off pilot makes one best-effort terminal publication from an internally consistent full-reporting actor snapshot, revalidates the pinned plan and exact run fence, then stages and atomically promotes topology, detail, and summary. For a failed run, the coordinator keeps polling within the existing terminal flush deadline until every transitive ancestor of each failed node is reported succeeded. This closes cross-sender actor delivery races without inferring a completion event; if the causal fence does not close, schema v3 remains unpublished. Rejected or truncated ingress, invalid cross-field evidence, admission overflow, preparation truncation, a stale fence, or storage failure likewise leaves schema v3 unpublished and emits a stable bounded diagnostic; it never changes the application result. The periodic schema-v2 writer remains active for rolling compatibility. Terminal-only does not solve or weaken the remaining full-reporting boundaries: aggregate producer/mailbox admission and coalescing across forked handles, the sampled policy that depends on that aggregate bound, bounded actor-to-preparer draining, remaining live cost attribution, large-fan-out slow-consumer evidence, default schema-v3 activation, and old-writer drain remain #79 and its dependent deliveries. - A workflow invocation atomically claims
workflow_run_id. Retry, cancellation, timeout, LOST recovery, and a newer invocation prevent its old coordinator from writing again; rejected reporters drain later leaf events without persisting them. - Before the first leaf submission, the workflow stores a bounded, secret-free effective-plan manifest, fingerprint, and strategy selection independently from progress. A retry must reproduce the pinned fingerprint and have retry-safe RuntimeEnv bindings, or it fails closed before submitting changed work.
- Progress includes node paths, callable labels, node states, completion counts, dependency edges, percent complete, explicit leaf progress, Ray execution IDs, runtime environment identity, and recent events.
- A leaf failure fails the outer task.
- Retrying the outer task reruns the workflow from its entry node, including previously completed leaves. Workflow progress, successful nodes, and output previews are diagnostic evidence, not checkpoints, reusable results, selective-resume records, or authorization to skip an external side effect. Normalized node detail does not retain leaf return values.
TaskAttemptarchives terminal task diagnostics, not workflow graphs. If a run has already published a canonical terminal schema-v3 summary, the same bounded value is archived with its attempt before retry cleanup. Otherwise, lifecycle reconciliation derives a terminal envelope from the last accepted running summary under the row lock. The outer outcome and expiry are authoritative. Success marks every discovered node complete; interrupted outcomes retain the last accepted counters. Invalid summaries and legacy complete snapshots are not copied. This keeps retry history bounded;progress_data, the current summary, andworkflow_run_idotherwise describe only the current attempt or its latest terminal invocation.- Cancellation of a Ray Core outer task recursively cancels its child tasks through Ray's normal cancellation behavior.
- The final workflow result must satisfy the same result-serialization rules as any django-ray task.
ADR-0004 accepts the replacement storage contract: a dedicated always-bounded task-row summary points to immutable topology manifests/pages and normalized latest-state node-detail rows. Changed detail rows are batch-upserted before the
81-fenced summary pointer advances; each accepted detail-changing publication expires¶
prior cursors, while static topology is not duplicated across repeated publications or
state transitions within one run and topology version.
Authorized readers use revision-bound pagination. The ADR defines exact V1 limits,
availability states, legacy v1/v2 reads, retention, and cleanup. Its first delivery now
implements the nullable current/per-attempt summary fields, strict schema-v3 codec,
monotonic exact-run writer primitive, bounded rolling reader, and lifecycle archival.
Its second delivery adds the run-scoped topology manifests/pages, normalized
latest-state rows, bounded staging and integrity verification, sparse atomic
publication, terminal expiry, and retention/orphan cleanup. Public detail services are
implemented. The runtime producer continues to write schema-v2 compatibility snapshots
of retained actor state and may additionally publish one terminal schema-v3 record only
when the stricter pilot is explicitly enabled and admitted.
ADR-0005's production topology phase now externalizes exact node/edge identity,
duplicate, reference, and selection state into a private bounded SQLite workspace and
removes it before returning prepared evidence. The unchanged result still includes
complete observed_node_ids for the existing materialized detail API, so #142 must
complete the shared topology/detail lifetime before broader activation. See
ADR-0004: Bounded Workflow Progress Storage
and
ADR-0005: Bounded Workflow Progress Preparation.
Ray Core tasks already run inside an initialized Ray worker. Ray Job drivers initialize their cluster connection lazily when a workflow first requests Ray, and use the same durable context and progress graph protocol.
Future execution strategies must preserve this outer durability boundary. In
particular, Compiled Graph is a possible engine for a validated static actor region,
not a Django task type or a flag that makes data-dependent map_step expansion static.
See Workflow Plans and Execution Strategies.
Apply database migrations before starting upgraded workers. Migration
0012_workflow_progress_summary adds nullable summary fields, and migration
0013_workflow_progress_detail_storage adds package-owned topology and detail
tables. Neither migration rewrites existing progress_data; older writers continue to
work and upgraded readers retain the 64 MiB schema-v1/v2 compatibility cap. The
package-level pilot remains disabled unless explicitly configured. Deploy the
authorized public facade and bounded storage before opting in, keep old workflow
writers compatible through the rollout, and complete the remaining mailbox,
preparation, aggregate-spill, and migration work before enabling schema v3 by default
or admitting workloads near the hard V1 ceilings.
Existing rows start with workflow_run_id = NULL and a nullable plan identity so an
older writer can still insert during the rollout; the first upgraded, fully identified
workflow invocation claims a UUID and pins its plan. Prepared actors or graphs in later
strategies must drain when that fingerprint changes. Custom uses of
durable_task_execution() that omit the attempt or execution generation continue to
run their workflow but intentionally do not persist the plan or progress because their
writes cannot be fenced safely.
The drain statement is a contract for those later strategies. The current release provides fingerprint comparison and stale-owner helper functions but has no resident graph owner or prepared-graph cache to drain.
An identified context whose attempt or execution generation is stale is different: its plan claim fails closed before local application code, progress actors, or Ray leaves can run.
Use pure or idempotent steps when retries can repeat external side effects. An external operation should receive a stable application idempotency key and return a durable receipt; a database-only record cannot atomically cover a remote side effect. Split a side-effecting stage into a separate Django task when it needs an independent durable retry, result, cancellation, or audit boundary.
Durable selective stage resume remains a planned extension. It requires verified intermediate outputs and side-effect receipts bound to the plan, inputs, code, RuntimeEnv, and retry identity. Progress and future node-output previews are observational rather than recovery logs: after a cluster loss, the outer task retry remains the recovery boundary. Never infer that an external effect is safe to skip or repeat solely from the node color or terminal state.
Test Project Examples¶
The bundled test project exposes several workflow experiments in its Swagger UI:
POST /api/cluster/workflow-benchmark
GET /api/cluster/workflow-benchmark/{task_id}
POST /api/cluster/complex-workflow
POST /api/cluster/complex-workflow?failure_branch=slow&failure_item=0
POST /api/cluster/complex-workflow?reporting_policy=terminal_only
GET /api/cluster/complex-workflow/{task_id}
POST /api/cluster/workflow-showcase
POST /api/cluster/workflow-showcase?item_count=1&work_seconds=0.01
GET /api/cluster/workflow-showcase/{task_id}
POST /api/cluster/workflow-recovery-showcase
POST /api/cluster/workflow-recovery-showcase?item_count=1&work_seconds=0.01
GET /api/cluster/workflow-recovery-showcase/{task_id}
GET /api/cluster/workflows/{task_id}
GET /api/cluster/workflows/{task_id}/topology/nodes
GET /api/cluster/workflows/{task_id}/topology/edges
GET /api/cluster/workflows/{task_id}/nodes
GET /api/cluster/workflows/{task_id}/node-detail?node_id={node_id}
The pre-0.4.0 live-node test route at
/api/cluster/workflows/{task_id}/nodes/{node_id} is removed. Use the durable indexed
node-detail route after the same callable authorization as every other workflow read.
Applications that need live Ray state or logs can build a separately authorized surface
around the package get_workflow_node_snapshot() helper; the testproject does not imply
a tenant or task-owner policy for that data.
The complex example runs this nested shape:
chain(
build_config,
group(
chain(build_fast_items, map(fast_leaf), summarize_fast),
chain(build_slow_items, map(slow_leaf), summarize_slow),
),
summarize_workflow,
)
Every workflow poller above exposes only a bounded aggregate progress-summary envelope.
A published schema-v3 summary is preferred. Supported older stored progress may
contribute sanitized aggregate counts, but the poller never returns its complete
schema-v2 graph. Its exact database projection
excludes task input, RuntimeEnv snapshots, workflow plans, completion envelopes, and
other unrelated payloads. Current inline result and error diagnostics are each guarded
at 16,384 bytes before transfer, external result storage is never loaded, and the
complete response is at most 65,536 bytes. The fixed result omission
vocabulary is external_result_not_loaded, stored_result_exceeds_poll_limit,
malformed_inline_result, and encoded_response_limit; the error vocabulary is
stored_error_exceeds_poll_limit and encoded_response_limit. A null reason means
the corresponding value is available. Included values still pass through the normal
presentation-redaction policy.
The bundled testproject enables WORKFLOW_PROGRESS_SCHEMA_V3_PILOT by default, so an
admitted terminal run becomes available through the bounded summary, topology-node,
topology-edge, and node-detail routes above. The guarded local KubeRay gate exercises
those routes against the real producer path and requires non-empty, mutually consistent
topology and detail.
It runs both the small successful shape and a deterministic slow-branch failure,
requires the failed task to remain on its first attempt, and verifies the authenticated
Admin graph against the same bounded publication. Open either terminal
RayTaskExecution in the Admin and expand Workflow execution, then
Execution graph, to inspect the dependency order. The failed fixture identifies
the originating map node and its incoming ancestor path while retaining successful
sibling context.
Order-fulfillment showcase¶
The showcase endpoint is the richer visual example. It builds one order, validates
each item, joins customer and inventory context, splits reservation from commercial
analysis, joins the decision, and fans out to primary, audit, and notification sinks
before finalization. The default three-item invocation retains 25 runtime nodes and
36 edges while still using one outer RayTaskExecution:
POST /api/cluster/workflow-showcase?item_count=3&work_seconds=0.05
GET /api/cluster/workflow-showcase/{task_id}
Open that execution in the Admin to inspect the graph as longest-path layers. Every
card links to the bounded indexed node-detail reader for the same displayed attempt.
The validation map exposes exactly item_id and valid; the reservation map exposes
exactly item_id and reserved_units, while retaining max_retries=0. Customer-history
node 0.1.g1.0.g1 deliberately uses a failing projector so the real run proves a
successful application node remains successful while its Output row says the
preview failed. Other configured leaves label their bounded business summaries as
previews. The endpoint always selects full reporting; it deliberately has no
reporting-policy knob. Use the existing complex-workflow fixture when comparing full,
terminal-only, and disabled policy behavior.
The graph renderer is package-owned and extends stock Django Admin with scoped fallback styles; Unfold remains an optional testproject-only shell rather than a renderer dependency.
The guarded local KubeRay gate uses the smaller deterministic success:
That run must publish exactly 21 runtime nodes, 28 dependency edges, 12 derived longest-path layers, 21 usable detail targets, and one durable attempt. The gate checks the exact two map-leaf preview values and the deliberate projector-failure status rather than accepting arbitrary serialized results. The matching failure fixture is:
POST /api/cluster/workflow-showcase?item_count=1&work_seconds=0.01&failure_stage=reserve_inventory&failure_item=0
It fails only reservation leaf 0.5.m0. The fulfillment decision, three sinks, and
finalizer remain pending. Commercial analysis joins reservation preparation first, so
its price, risk, recommendation, and join nodes are structurally guaranteed to have
succeeded before the selected reservation fails. This makes the Admin failure path
useful without fabricating independent durable retries for Ray-native leaves. The gate
also requires the failed reservation's preview to be UNAVAILABLE, while the validation
preview and successful customer-history task retain their exact safe and diagnostic-only
statuses.
To keep that successful visual workload moving slowly through a local dashboard, name its opt-in Locust class explicitly:
export DJANGO_API_TOKEN="<local testproject token>"
uv run locust -f locustfile.py --host=http://localhost:30080 \
--headless -u 1 -r 1 -t 5m --stop-timeout 150 WorkflowShowcaseUser
WorkflowShowcaseUser is excluded from default class discovery, fixes the population
at one user, requires each three-item success and complete 25-node/36-edge publication
before submitting the next, and never injects the expected failure fixture. The
150-second graceful-stop window covers bounded enqueue, terminal polling, final detail
validation, and scheduling margin for the scenario active at the five-minute cutoff.
Three-attempt recovery showcase¶
The recovery endpoint demonstrates the outer-task retry boundary with one task ID and one fixed, server-owned failure sequence:
POST /api/cluster/workflow-recovery-showcase?item_count=1&work_seconds=0.01
GET /api/cluster/workflow-recovery-showcase/{task_id}
The bundled testproject allows three durable attempts. Attempt 1 fails at
build_order_batch, before the branches can run. Attempt 2 starts again at
build_order_batch, reruns its upstream work, and fails at join_order_inputs.
Attempt 3 starts from the entry again and completes the full workflow. The polling
response exposes the ordered FAILED, FAILED, SUCCEEDED attempt history and the
bounded successful outer result, whose recovery field identifies attempt 3. It reads
at most four ordered attempt rows and guards each archived attempt error at 4,096 bytes;
stored_error_exceeds_attempt_limit and encoded_response_limit distinguish omitted
errors. Current result and error values keep the common 16,384-byte diagnostic guard,
and the complete recovery response remains under 65,536 bytes. Callers cannot choose
the failure stage through the API, so the example remains deterministic.
The endpoint binds this task to the testproject's recovery-showcase task backend
and returns runtime_env_profile="recovery-showcase" while polling. Local and
Compose runs content-hash the sample's source import roots. Kubernetes builds a
deterministic archive containing those sources plus the locked Django task runtime;
the Ray Client task manager uploads it as a content-addressed GCS package before
submission, so the upstream generic Ray image needs no django-ray installation.
A missing or invalid backend/profile/archive, or a profile without an immutable
retry identity, is a configuration error and returns 503; the endpoint never falls
back to the default project profile. This matters because project contains
mutable package constraints and an opaque shared archive URI. Those are valid for a
first dynamic execution but cannot prove that a later durable attempt sees identical
bytes.
Inspect each retained attempt through the bounded readers by adding
?attempt_number=1, ?attempt_number=2, or ?attempt_number=3, for example:
GET /api/cluster/workflows/{task_id}?attempt_number=1
GET /api/cluster/workflows/{task_id}/nodes?attempt_number=2
The Admin attempt history links to each immutable attempt detail. Every archived
full-reporting attempt exposes an Open graph for attempt #N link pinned to that
run, so operators do not need to construct the query string themselves. A graph
identifier such as 0.3.g1.1 is the stable node path for that expansion, not a status
or task result.
The card's Output line reports availability only: pending and running work remains
pending, failed output is unavailable, and a succeeded value is not retained. Node
detail likewise reports execution state and bounded diagnostics rather than leaf return
values. Read the recovery endpoint's outer result for the durable workflow output.
This fixture deliberately uses pure, side-effect-free steps. It proves replay and attempt fencing, not checkpointed continuation: no successful node from attempt 1 or 2 is reused by attempt 3. Applications that call external systems must make replayed steps idempotent or implement their own durable effect/checkpoint protocol. Selective workflow resume remains future work.
Omitting reporting_policy preserves the existing full-reporting fixture. Set
reporting_policy=terminal_only to exercise the actor-free summary path with a small
success:
POST /api/cluster/complex-workflow?fast_items=2&slow_items=1&fast_seconds=0.01&slow_seconds=0.02&reporting_policy=terminal_only
Add failure_branch=slow&failure_item=0 for the deterministic terminal-only failure.
Poll the returned task ID through GET /api/cluster/workflows/{task_id}. The response
should report the terminal outcome, pinned strategy, declared plan counts, zero
discovered nodes, and detail.availability="OMITTED_BY_POLICY". Topology and
node-detail routes return empty omitted-by-policy responses, and the Admin shows no
execution graph for either fixture.
API Reference¶
| API | Behavior |
|---|---|
step(callable, *args, django=False, ray_options=None, runtime_env=None, **kwargs) |
Bind an importable callable as one workflow step |
step(...).with_output_preview(projector) |
Opt into one bounded, redacted diagnostic projection of a successful full-reporting Ray leaf result |
chain(*signatures) |
Run signatures sequentially |
group(*signatures) |
Fan out the same input and gather ordered results |
map_step(callable_or_signature, ...) |
Fan out over the preceding iterable; callable keyword arguments remain leaf arguments |
map_signature.with_limits(max_concurrency=None, max_items=None, cancel_timeout_seconds=1.0) |
Add bounded admission, expansion, and failure-cleanup controls |
bounded_map.with_result_buffer(max_serialized_bytes=..., actor_options=...) |
Opt into a resource-accounted Ray actor that forwards one ordered payload reference without coordinator decoding |
report_progress(current, total, message=None, metrics=None) |
Report progress from a running leaf |
signature.run(*args, use_ray=None, **kwargs) |
Execute with Ray when initialized, otherwise locally |
signature.with_progress_reporting(policy).run(...) |
Execute one invocation with explicit "full", "terminal_only", or "disabled" progress reporting without reserving an application keyword |