Skip to content

Performance Guide

django-ray has two layers with different costs:

  1. A durable Django task is enqueued, claimed, retried, and recorded through the database.
  2. A Ray-native leaf is submitted inside an active task and does not create its own database row.

Most performance decisions come down to choosing the correct durability boundary.

Choose an Execution Model

Workload Prefer Why
One short operation that does not benefit from parallelism One batched Django task, or ordinary request code Avoids fan-out overhead
Independent expensive items with one final result parallel_map() inside one Django task Small API and one durable boundary
Multiple dependent fan-out/fan-in stages chain(), group(), and map_step() Ray-native dependencies plus graph/progress metadata
Every item needs an independent retry, schedule, cancellation, or audit row Separate Django tasks Durability is worth the database traffic
Short, high-throughput cluster work whose task manager stays connected Ray Core cluster mode Reuses workers and avoids a driver per task
Long or coarse job requiring driver or submitter-lifetime isolation Ray Job mode Isolation is worth higher startup cost
Different Python packages on one trusted cluster Stable RuntimeEnv profiles Avoids rebuilding the Ray image for every application version
Large dependencies used by nearly every task Base Ray image Avoids node-local RuntimeEnv installation

Queues organize durable tasks; they do not replace Ray scheduling. RuntimeEnv packages code; it does not provide tenant security.

Cluster Ray Core uses Ray Client, so the task-manager connection is part of the workload lifetime. A prolonged disconnect can terminate in-flight Ray work even though the outer Django task remains durable and may later be retried. Prefer Ray Job when execution must continue independently of that submitting connection, and keep external effects idempotent in either mode.

Pick Useful Task Granularity

There is no universal item-count threshold. Measure these separately:

  • database enqueue, claim, and result-write time;
  • Ray submission and scheduling time;
  • RuntimeEnv cold and warm setup time;
  • useful work per leaf;
  • serialization and object-transfer time.

If one leaf's useful work is comparable to its submission overhead, batch several items into one leaf. For example, prefer 20 namespace tasks that each fetch several resource types over hundreds of tiny resource tasks when every resource call takes only a few milliseconds.

Increase granularity until:

  • leaves run long enough to amortize submission;
  • enough leaves remain to occupy available CPUs or I/O concurrency;
  • retries do not repeat an unacceptably large batch.

The best batch size sits between those constraints.

Keep Database Traffic at the Outer Boundary

A workflow creates one RayTaskExecution for the outer Django task. Internal leaves exchange Ray object references. With the default WORKFLOW_PROGRESS_REPORTING_POLICY="full", they also report events to an in-memory progress actor and django-ray writes a schema-v2 compatibility snapshot of retained actor state when a changed revision is flushed. Each data event is canonical, identity-fenced, and bounded before its Ray call: payloads are at most 16 KiB, complete wire and decoded envelopes are at most 32 KiB, and dependency edges are sent in batches of at most 32. The actor revalidates the envelope and caps retained nodes, edges, recent events, and bytes. Its snapshots also carry a versioned, fixed-shape cost block with saturating counters for logical event calls/bytes received, decoded calls by the fixed event-kind enum, producer-to-actor delivery delay, handler wall/process CPU time, and snapshot-build wall/process CPU time. The block retains no producer identifiers, payloads, labels, errors, metric names, or other application data. Package-default execution uses the durable V1 profile and remains a schema-v2 writer. A terminal snapshot can also carry a fixed-shape aggregate of actor-accepted leaf-invocation producer reports: valid offers, submissions, superseded and locally dropped values, producer-observed acknowledgement outcomes, and terminal-handoff outcomes. It retains neither producer identities nor application values.

WORKFLOW_PROGRESS_FLUSH_SECONDS limits write frequency, but it does not bound the aggregate Ray actor mailbox, queued bytes, or transient snapshot and drain allocations. During one leaf invocation, full reporting retains at most one outstanding application-progress acknowledgement and one canonical latest-value slot. A slow acknowledgement coalesces later application values into that slot; leaf exit makes at most one bounded terminal handoff before its non-coalesced terminal event. When acknowledgements keep up, every accepted update may still be submitted. This is acknowledgement-driven containment, not time-based sampling.

A physical Ray leaf retry or another forked actor handle within the same run can still contribute another independently bounded leaf session. An outer durable-task retry uses a new run identity and actor. Aggregate workflow-wide admission, call/byte limits, and mailbox coalescing therefore remain separate scale work, as does the bounded actor-to-preparer drain. Those gaps prevent treating the hard V1 ceilings as a safe default production profile or offering a truthful sampled policy.

WORKFLOW_PROGRESS_SCHEMA_V3_PILOT=True selects the intentionally smaller schema-v3-pilot-v1 profile for both actor retention and one terminal schema-v3 publication attempt: at most 512 nodes, 2,048 edges, 2 MiB of topology, 1 MiB of detail, and 4 MiB combined, with encoded and decoded byte ceilings. It does not add a schema-v3 database write to every live flush. A rejected or truncated event, invalid snapshot, admission/preparation failure, stale fence, or storage failure refuses the terminal publication while preserving the application result and schema-v2 compatibility snapshots. This strict fail-closed path is a bounded integration pilot, not evidence for hard-V1-scale or arbitrary concurrent workloads.

The bundled testproject enables the pilot so its nested workflow and the guarded local KubeRay gate exercise real topology publication. Benchmark package-default behavior with the setting explicitly disabled unless the pilot itself is the subject of the measurement. The gate also runs terminal-only success and failure separately to prove null legacy progress, one summary-only publication, and no retained detail. Focused unit tests prove the actor is not constructed and progress transport metadata is not sent; those checks do not turn on pilot detail for the terminal-only invocation.

For workloads that need a durable terminal outcome but not live per-node telemetry, select "terminal_only" globally or call WorkflowSignature.with_progress_reporting("terminal_only").run(...). This path creates no progress actor, emits no node or application-progress RPCs, and writes no legacy progress_data. It makes one best-effort fenced schema-v3 summary write on durable success or failure, with the pinned strategy and plan fingerprint, declared plan counts, terminal outcome, and bounded timestamps. Discovered and node-state counts remain zero; detail is OMITTED_BY_POLICY, and no topology or node-detail rows exist. Measure it as one terminal database write rather than as zero observability work.

For workloads where even that summary is unnecessary, set the policy to "disabled" globally or call WorkflowSignature.with_progress_reporting("disabled").run(...) for one invocation. The disabled path creates no progress actor, emits no node or application-progress RPCs, and writes no progress_data; task lifecycle, retry, cancellation, result/error persistence, and monitor heartbeats remain active. Measure before making this a deployment-wide default because node-level live progress is intentionally unavailable.

Terminal-only avoids the current full-mode producer and mailbox cost; it does not make those costs workflow-wide bounded for workflows that still choose full reporting. Aggregate producer/mailbox admission and coalescing across forked handles, the sampled policy that depends on that aggregate bound, bounded actor-to-preparer draining, the still-unavailable network/database and lifetime-resource cost layers, and large-fan-out slow-consumer evidence remain separate #79 work.

The bundled ObservabilityDemoUser makes the three shipped policies directly comparable without becoming a load benchmark. make loadtest-demo runs one tiny nested workflow at a time under full, terminal-only, and disabled reporting, waits for durable completion, and validates the bounded summary contract before continuing. Stable per-policy Locust labels separate enqueue, terminal polling, and summary-read latency. The one-user, one-user-per-second defaults remain intentionally conservative. Each workflow terminal-poll endpoint is limited to a 65,536-byte response, returns a bounded aggregate summary envelope, and guards current inline result and error diagnostics at 16,384 bytes each without resolving external result storage. Its fixed result omission vocabulary is external_result_not_loaded, stored_result_exceeds_poll_limit, malformed_inline_result, encoded_response_limit; errors use stored_error_exceeds_poll_limit or encoded_response_limit. The one-user tour exercises ordinary small-value responses; the focused adapter tests, rather than Locust timings, prove the limit and omission branches. After the five-minute active window, Locust schedules no new scenario and grants the current one a bounded 150-second drain window. That covers the client-bounded enqueue, the longest 120-second poll, the final detail read, and scheduling margin, so an already-enqueued task still reaches terminal and summary validation instead of disappearing from an otherwise green run.

WorkflowShowcaseUser serves a different purpose: it keeps one realistic order-fulfillment graph moving through the Admin and Ray dashboard at a readable pace. It is excluded from default Locust discovery and must be named explicitly. The class fixes the population at one user, submits one successful three-item, full-reporting workflow with work_seconds=0.05, waits for its durable result, and requires its complete 25-node/36-edge publication before repeating. It never submits the deterministic reservation failure or exposes a reporting-policy control. Treat this as a visualization and integration exercise, not a throughput or reporting-cost benchmark; use the counterbalanced command below for policy measurements.

Attribute Live Workflow Reporting Policies

The Locust demo proves that the three policy contracts remain usable, but HTTP timings cannot attribute their internal cost. Use the testproject's opt-in command for a counterbalanced, sequential comparison on the guarded local KubeRay stack. Run it only after the exact committed source tree has passed the required cold-Ray local gate:

kubectl --context docker-desktop -n django-ray exec deployment/django-web -- `
  env DJANGO_RAY_RUN_WORKFLOW_REPORTING_BENCHMARK=1 `
  python /app/testproject/manage.py `
  django_ray_benchmark_workflow_reporting `
  --repetitions 6

The three-repetition default runs nine tiny workflows. Each cycle rotates the order of full, terminal_only, and disabled; higher repetition counts must be a multiple of three so they repeat that complete Latin square without introducing concurrent benchmark tasks. Progress is written to stderr and one versioned JSON report is written to stdout, so redirect stdout to an ignored artifact when retaining evidence. The benchmark report schema is version 3. It records the command implementation digest, package and database versions, the one stable workflow-plan fingerprint, exact workload fingerprint, and whether the testproject's workflow-progress schema-v3 pilot was enabled. These are independent schema versions. Checkout-to-deployment source-tree attestation remains the responsibility of the guarded local KubeRay gate and is labeled that way rather than fabricated by the in-pod command.

Primary comparisons use the durable database timestamps for queue wait, outer execution, and end-to-end time. The workload also returns its bounded coordinator wall time and summed leaf work. Client polling duration is retained only as a diagnostic and is excluded from policy aggregates. Each policy aggregate reports the sample count, median, nearest-rank p95, minimum, and maximum; it does not rank a winner, calculate a speedup, or claim that an elapsed-time difference was caused by reporting. Full-mode actor-cost aggregates retain the fixed initialization, ingest, delivery-delay, and snapshot groups. Every numeric field, including per-kind decoded counts and negative-clock samples, has its own distribution. Full-mode producer_progress aggregates do the same for fixed producer counters and terminal-handoff outcomes. Terminal-only and disabled report producer progress as not_applicable.

The fixed cost fields retain their source units in both samples and aggregates:

Cost field Unit
initialization.wire_bytes, ingest.wire_bytes_received Logical bytes received by the actor
ingest.calls_received, ingest.decoded_calls, ingest.post_disable_calls Calls
ingest.decoded_by_kind.* Calls decoded under the exact run fence
delivery_delay.samples, delivery_delay.negative_clock_samples Samples
delivery_delay.total_us, delivery_delay.max_us Microseconds
initialization.handler_wall_ns, initialization.handler_cpu_ns Nanoseconds
ingest.handler_wall_ns_total, ingest.handler_wall_ns_max Nanoseconds
ingest.handler_cpu_ns_total, ingest.handler_cpu_ns_max Nanoseconds
snapshot.calls Calls
snapshot.build_wall_ns_total, snapshot.build_wall_ns_max Nanoseconds
snapshot.build_cpu_ns_total, snapshot.build_cpu_ns_max Nanoseconds

All producer_progress values use the explicit unit "count":

Producer field Meaning
reports Fixed-shape producer reports accepted and aggregated by the collector; at most one per reporting leaf invocation with a valid offer
offered Valid application-progress values accepted into leaf producer sessions
submitted Application-progress actor calls submitted by those sessions
superseded Canonical latest-slot values replaced before submission
locally_dropped Accepted values that could not be submitted locally
acknowledged Submitted calls whose successful acknowledgement the producer observed
actor_rejected Submitted calls whose actor rejection the producer observed
ack_failed Submitted calls whose failed acknowledgement the producer observed
pending_acknowledgements Submitted calls whose result was not observed when the producer report was sealed
terminal_handoffs.not_needed, .submitted, .failed, .actor_unavailable Exactly one terminal latest-value handoff outcome per accepted producer report

The benchmark validates the unsaturated reconciliation equations offered = submitted + superseded + locally_dropped and submitted = acknowledged + actor_rejected + ack_failed + pending_acknowledgements. A full sample also requires acknowledged <= actor-accepted application progress <= submitted - actor_rejected. Failed and still-pending acknowledgements remain intentionally uncertain within that range. A pending acknowledgement is producer-local evidence at report time; actor ordering can still process that application-progress call before it processes the report.

For full mode, the report exposes only allowlisted terminal ingress counters: accepted events by kind, rejections by reason, truncation, retained logical bytes/nodes/edges, the actor-authored cost block, and the fixed producer aggregate. The collector's accepted total includes its constructor's one initialized event, so processed_ingest_events subtracts exactly that event. Each accepted producer_report is also an ingress event, but it contains only fixed-cardinality saturating counters. Actor-observed ingress calls and logical wire bytes include malformed and rejected calls that reached the handler. Calls successfully decoded under the exact run fence are counted by the fixed event-kind enum. Delivery delay is the producer event timestamp to actor-handler entry; it includes serialization, transport, scheduling, queueing, and clock effects, so it is neither a pure mailbox-lag measurement nor a network-timing claim. Negative clock samples are counted and excluded from the non-negative delay total.

Handler and snapshot-build wall/process CPU totals cover only work observed through the retained terminal snapshot. Snapshot-call counts include that returned snapshot; later calls and a disable made after it cannot appear. Every counter saturates at the signed 64-bit protocol ceiling, and the benchmark fails instead of aggregating a saturated run. Before recording the evidence, the command reuses the bounded terminal-publication validator even when the schema-v3 pilot is disabled, then checks the exact run identity, terminal success, plan fingerprint, expanded fixture topology, zero ingress rejection/truncation, fixed cost shape and arithmetic, and retained node/edge agreement.

Durable evidence separates reporting-specific storage from the shared task lifecycle:

  • reporting storage includes only UTF-8 byte lengths for progress_data, the execution and archived-attempt copies of the bounded summary, plus normalized run, topology, link, and node-detail row/byte counters for the exact run identity;
  • shared storage reports only row counts and encoded lengths for task arguments, result, RuntimeEnv envelope, workflow plan/selection, and the archived attempt row;
  • raw arguments, result items, progress, summaries, RuntimeEnv content, topology payloads, node identifiers, and payload/node digests are never emitted. The allowlisted implementation, workload, and workflow-plan fingerprints remain available for evidence identity.

WorkflowProgressTopologyManifest.encoded_bytes is already the complete logical topology total, including its linked pages. Likewise, WorkflowProgressRunStorage.detail_encoded_bytes already aggregates its node-detail rows. The report exposes child totals for integrity checking, but those pairs must not be added together. None of these logical protocol sizes represents PostgreSQL table, index, MVCC, statement, latency, or WAL bytes.

Application-progress submissions are now partially attributable through accepted leaf reports, including producer-observed synchronous/acknowledgement failures and still-pending acknowledgements. A terminal latest-value handoff is an application-progress submission and is included. Structural and lifecycle events, producer reports, and coordinator snapshot/disable calls are excluded; a producer report that never reaches the actor is also absent. Actual Ray/network traffic, aggregate mailbox depth or pure queue latency, complete actor-lifetime RSS/process CPU, disable calls after the last snapshot, and PostgreSQL statement/latency/WAL attribution remain unavailable. Canonical event bytes and malformed actor-received bytes are logical wire bytes, not network traffic; actor-handler timings are likewise not relabeled as any of those missing layers. The report distinguishes directly measured, partial, derived, not-applicable, and unavailable scopes instead of estimating them from workflow wall time.

Successful runs retain their bounded task rows by default and include an Admin path for each execution. This makes the policy-specific summaries available for inspection; the full workflow graph is also available when the testproject's schema-v3 pilot is enabled. Pass --cleanup --output-json <new-path> when evidence should be ephemeral. The command first writes the complete report, then deletes only the exact execution primary keys it created and their cascading attempt/progress rows, and finally replaces the artifact atomically with the completed cleanup receipt. Cleanup is intentionally unavailable through the read-only task Admin. A timeout, validation failure, or initial artifact-write failure deliberately preserves the task rows for diagnosis; a cleanup failure leaves the pre-cleanup report at the requested path.

This command is local evidence, not a production SLO or an ordinary public-CI job. It does not enable Compiled Graph, change the compatible full-reporting default, add sampled reporting, or replace the isolated PostgreSQL publication/read/preparation benchmarks below.

This is faster than making every leaf a Django task, but it changes semantics:

  • a leaf does not have an independent durable retry record;
  • failure fails the outer task;
  • retrying the outer task can repeat successful leaves;
  • progress is observational, not a recovery log.

Use idempotent leaves and external checkpoints when repeated side effects would be unsafe.

Benchmark Workflow Progress Persistence

Use the opt-in workflow-progress benchmark to compare the current complete-row snapshot with bounded alternatives before changing the production persistence contract. The command builds deterministic logical node/payload records and exercises Django plus a disposable PostgreSQL database. It does not import Ray, start a Ray runtime, execute native Compiled Graphs, or add any candidate representation to production.

Install the PostgreSQL dependency set, activate that environment, and point tests.postgres_settings at an isolated PostgreSQL database. Never use a production database: the benchmark creates and removes transient representative rows and measures database-wide effects. Prepare the schema, then run the fixed comparison locally:

export DJANGO_SETTINGS_MODULE=tests.postgres_settings
export DATABASE_NAME=django_ray
export DATABASE_USER=django_ray
export DATABASE_PASSWORD=django_ray
export DATABASE_HOST=127.0.0.1
export DATABASE_PORT=5432

python testproject/manage.py check
python testproject/manage.py migrate --noinput
mkdir -p artifacts
python testproject/manage.py django_ray_benchmark_workflow_progress \
  --nodes 1000 10000 50000 100000 \
  --change-rates 0.01 0.10 1.0 \
  --repetitions 5 \
  --warmups 1 \
  --seed 20260720 \
  --database-deployment local-postgresql \
  --output-json artifacts/workflow-progress-benchmark.json \
  --output-markdown artifacts/workflow-progress-benchmark.md

The nodes values are logical workflow-node counts. A change rate of 0.01 or 0.10 models a sparse publication after 1% or 10% of nodes change; 1.0 models a terminal publication in which every node has changed. Each case retains its first measurement as cold_sample, then discards the requested warm-up measurements before recording its five warm_samples. A fixed seed makes payload content and modeled change distribution reproducible; this command does not construct dependency edges.

The benchmark-only representations have these meanings:

Representation What the benchmark models
current_full_row Legacy/full-row persistence baseline: re-encode and replace one whole logical graph row. This is a benchmark model, not a claim that the bounded runtime actor can retain every modeled node.
bounded_inline A fixed-size task-row summary plus count- and byte-bounded invocation detail retained inline.
chunked_database A bounded task-row summary with bounded detail split into database chunks.
normalized A bounded summary with node detail stored as independently addressable relational rows.
append_delta A bounded summary plus revision deltas, with reads reconstructing detail from retained changes.
external_chunk A bounded database summary and opaque pointer while encoded detail is modeled as externally stored chunks.
live_only Bounded durable summary data with invocation detail available only from the live producer.

These are comparison models, not supported storage modes. In particular, the external_chunk case does not turn a benchmark artifact into a public storage reference, and the live_only case does not promise that detail survives producer loss.

The versioned JSON has these top-level fields: schema_version, benchmark, environment, configuration, profiles, candidates, cases, and database_evidence. Together they retain the configured matrix and seed, core dependency and platform identity, PostgreSQL identity and schema state, payload profiles, and candidate descriptions. Each cases entry identifies profile, candidate, nodes, change_rate, changed_nodes, and a deterministic workload_fingerprint. Its write_amplification records changed items, typed modeled storage units, estimated database statements, and task, detail, external, and total encoded bytes. External operations are not counted as database statements. cold_sample and warm_samples retain snapshot and serialization measurements, encoded and decoded bytes, parse/redaction work, summary/page/single-node read timings and response bytes, and best-effort process memory evidence.

The same workload_fingerprint is intentionally shared by all seven candidates for a given profile, node count, and change rate. It identifies a comparable workload, not a unique case.

Payload profiles cover short and maximum-size metadata, multibyte UTF-8, secret-bearing values, metric-cardinality limits, compressible content, and seeded incompressible content. database_evidence holds the best-effort PostgreSQL temporary table measurements, including pg_column_size, relation growth, and WAL evidence when the server can provide them. Optional database evidence may be unavailable with an explicit reason without failing the benchmark; an unavailable metric remains distinct from a measured zero.

Treat the JSON file as the source of truth for analysis. It retains environment data, the cold and individual warm samples, full-precision values, and unavailable-metric information needed to recompute comparisons. The Markdown file is a concise, rounded view derived from that JSON. Its first table pools heterogeneous cases into candidate-wide medians across the complete matrix; the sparse-focus table is one specific comparison. Use it to orient a decision and the GitHub Actions job summary, not to replace the raw repetitions or compare runs with different environments.

The environment also records the SHA-256 digest of the benchmark implementation. Manual GitHub Actions runs record their exact GITHUB_SHA; local evidence reports the source revision as unavailable instead of guessing. The configured matrix records that the command touches only Django and its configured database, never Ray or Kubernetes.

Python timings cover construction, serialization, parsing, redaction, and in-process response shaping of bounded representative structures. They exclude ORM queries, database and network round trips, and external-object operations. Write bytes and database statement counts are analytical. The PostgreSQL probe inserts one bounded representative document per candidate to capture JSONB row size, relation growth, and WAL evidence; it does not execute seven production candidate schemas or establish throughput. Real schema/query benchmarks remain required with the implementation.

The ADR-0004 decision uses a committed Windows 11/PostgreSQL 17 summary and its authoritative raw JSON. That local run used a disposable Docker database; the command did not start Ray or access Kubernetes. It therefore does not validate the image or package version in any Kubernetes deployment.

For the modeled uniformly distributed sparse update, immutable 256-item detail pages touched most pages even when only 1% of nodes changed. ADR-0004 therefore retains immutable pages only for normally static topology and selects normalized latest-state rows for mutable node detail. The benchmark compares storage shapes; it does not add that schema or change production persistence.

To collect the reference Ubuntu/PostgreSQL 17 series, open Actions, select Workflow Progress Benchmark, choose the branch or commit under test, and select Run workflow. The manual job runs the same fixed command and uploads both files in the workflow-progress-benchmark-<run>-<attempt> artifact, including any files that exist when the benchmark step fails. It is intentionally absent from pull-request CI.

Do not turn its elapsed-time values into required pull-request thresholds. Shared runner load, CPU scheduling, filesystem cache state, PostgreSQL checkpoints, autovacuum, and fsync behavior add noise that correctness tests cannot normalize. Compare candidates within one run, repeat material decisions on representative infrastructure, and retain the raw artifacts with the ADR evidence.

PostgreSQL byte metrics answer different questions. pg_column_size describes a value or row representation, not the table's complete disk cost. Relation growth can include indexes, TOAST data, free space, and dead tuples, and it need not fall when rows are deleted. WAL deltas vary with server settings, checkpoints, full-page writes, compression, and concurrent activity. Measure on an otherwise idle disposable database, record unavailable WAL evidence explicitly, and compare WAL or relation deltas only between cases collected under the same server configuration.

Benchmark Workflow Progress Preparation

ADR-0005's opt-in preparation benchmark has two explicit implementations. The default prototype-composite mode measures the issue-#140 non-production topology/detail prototype. production-topology measures the issue-#141 package path through legacy topology detachment. Each sparse or high-edge scenario runs in a fresh subprocess and removes its private workspace before the parent accepts the result. Neither mode starts Ray, writes Django database rows, accesses Kubernetes, or activates schema-v3 production.

Run the required decision matrix on Linux from an otherwise idle checkout:

mkdir -p artifacts /tmp/django-ray-issue140-benchmark
python scripts/benchmark_workflow_progress_preparation.py \
  --implementation prototype-composite \
  --required-scale \
  --timeout-seconds 7200 \
  --workspace-parent /tmp/django-ray-issue140-benchmark \
  --output artifacts/workflow-progress-preparation.json

Repeat the topology-only production matrix separately:

python scripts/benchmark_workflow_progress_preparation.py \
  --implementation production-topology \
  --required-scale \
  --timeout-seconds 7200 \
  --workspace-parent /tmp/django-ray-issue141-benchmark \
  --output artifacts/workflow-progress-production-topology.json

--required-scale expands to 25,000, 100,000, and 250,000 observed nodes for both a sparse chain and a deterministic high-edge graph. The latter crosses the retained 100,000-edge cap without constructing a quadratic graph. Smaller explicit --nodes values are smoke checks only and cannot satisfy the ADR evidence gate. The explicit two-hour per-case timeout accommodates the deliberately expensive exact high-edge path; it is a watchdog, not a performance target.

Report schema v2 is additive. It retains tracemalloc_peak_bytes and peak_rss_bytes for schema-v1 consumers, adds explicit end_to_end_* aliases, and adds the production-only bounded_phase_* checkpoint fields. Consumers that already accept additive JSON can continue reading the legacy peak fields and should ignore unknown fields. New consumers must check schema_version; the runner rejects a missing or inconsistent required v2 field rather than interpreting a schema-v1 or future-version report as current evidence.

The JSON records the selected implementation, exact source and implementation identity, environment and filesystem characteristics, effective SQLite settings, configured cache/batch/item/spill limits, observed and retained cardinalities, wall and CPU time, Python allocation peaks, explicitly scoped RSS evidence, spill high-water bytes, query plans, and child plus parent-watchdog cleanup outcomes. A separate forced-termination control proves that the parent removes a killed child's workspace. An unavailable RSS metric is null, never zero. Cleanup failure, missing phase evidence, mismatched legacy aliases, and non-monotonic phase/end-to-end peaks fail the run. Unknown extra fields remain valid so the schema can grow additively.

When required_scale is true, validation requires each combination of 25,000, 100,000, and 250,000 observed nodes with sparse and high-edge exactly once. A missing, duplicate, or extra matrix case is not decision evidence even when the individual cases completed.

The committed WSL2 Linux summary and authoritative JSON join the focused canonical and lifecycle tests to satisfy ADR-0005's issue-#140 evidence gate. They are local contract evidence, not a production latency SLO. Compare resident growth only after the retained node, edge, and detail caps are reached, and keep #79's wire, mailbox, and producer allocations separate from this preparation-only measurement.

The production adapter's required WSL2 Linux summary and JSON record all six production-topology cases at the exact issue-#141 implementation revision. Bounded-phase memory plateaued after V1 retention caps while spill grew with observed node and edge identity state. The separate end-to-end fields and legacy-ID counts preserve #142's remaining O(observed) compatibility boundary.

In production-topology reports, detail fields are null, spill-item counts cover only nodes and edges, and legacy_observed_node_ids records the compatibility set materialized after the bounded candidate is sealed. The bounded_phase_* fields are captured after observation, selection, and page construction but before that set is materialized. The legacy peak fields and their end_to_end_* aliases include the later O(observed) compatibility detachment. Compare those two phases separately. Issue #141 removes complete Python node/edge validation collections, but it does not claim that the legacy returned value is O(retained); issue #142 owns that final bound.

Control Fan-Out

Do not equate maximum submission count with maximum throughput.

from django_ray.runtime.distributed import parallel_map


def process_one(item_id: int) -> int:
    return item_id * item_id


item_ids = list(range(100))
results = parallel_map(
    process_one,
    item_ids,
    num_cpus=0.25,
    max_concurrency=32,
)

For workflows, set per-leaf ray_options:

from django_ray.workflows import map_step


def process_one(item_id: int) -> int:
    return item_id * item_id


fanout = map_step(
    process_one,
    ray_options={"num_cpus": 0.25},
).with_limits(
    max_concurrency=32,
    max_items=10_000,
)

The map window limits submitted map items and retained result references. Results are collected incrementally but returned in input order. Nested groups can submit multiple physical tasks per item, so include their fixed branch count when sizing the window. Input admission starts only after executor.resolve(): an upstream Ray task still produces its complete list, which is transferred and deserialized into coordinator memory before fan-out begins. Only an iterator already available locally is pulled lazily. Remote or paged input materialization is tracked in GitHub issue #94.

By default, the ordered result payload is still materialized in coordinator memory. An explicitly bounded map can use with_result_buffer() to retain versioned serialized bytes in a resource-accounted Ray actor and forward one direct payload reference to a downstream Ray step. The actor, object store, and final consumer still have O(total output) boundaries. When only a compact summary is needed, use the explicit reduce() ordered fold. Its actor retains one serialized accumulator and at most max_concurrency - 1 out-of-order results, while incorporation-based admission deliberately applies head-of-line backpressure to preserve strict input order. Benchmark the reducer cost and accumulator size as well as map throughput. List-preserving chunk or hierarchical transport remains tracked in GitHub issue #91. Calling map_step() without with_limits() retains the legacy eager behavior.

Use fractional CPUs only when a leaf is mostly waiting or deliberately shares a core. CPU-bound Python or native work should request realistic resources. An oversized concurrency value can increase memory pressure, API throttling, and retries without reducing wall-clock time.

For an API-limited workload, map batches rather than individual tiny requests and keep the client's rate limiter enabled: a concurrency window bounds simultaneous batches, not requests per second. For Kubernetes synchronization, compare namespace-sized batches with (namespace, resource_kind) batches. Choose the smallest batch that still makes Ray scheduling overhead minor, and set max_items to reject accidental discovery explosions. See Bound Dynamic Fan-Out for failure and cleanup semantics.

Avoid passing large repeated values to every leaf. Put shared immutable data in Ray's object store once, or load it in a preceding workflow step and pass references through the graph.

Measure distributed helper preparation

parallel_map(), parallel_starmap() and scatter_gather() prepare the next request as a submission slot opens. Strict operations share validated invariant wire fragments; map/starmap reuse one callable digest. This reduces sender-side work before the first submission and avoids a request-sized object for every unscheduled item. It does not stream caller input lists, result lists or Ray argument transport, and leaves still perform full strict-boundary validation.

Measure that boundary from a clean committed checkout on Linux:

uv run python scripts/benchmark_distributed_preparation.py \
  --baseline-ref origin/main --items 100 1000 --window 8 --repetitions 3 \
  --case-timeout 120 --output artifacts/distributed-preparation.json

Both complete src trees are extracted from Git into owned temporary directories. Each baseline/candidate case uses a fresh Python process importing the selected tree, with a 3 GiB address-space cap, CPU-time cap and parent-enforced wall timeout. BLAS thread settings are one. No Ray service, database or application callable runs. Cases are limited to 5,000 items; the default profile compares 100 and 1,000 items. The parent removes its temporary source directories after the run.

The report records revisions, tree identity, environment, first-submission delay, CPU/wall time, traced Python peak bytes, encoding/hash counts and maximum prepared calls ahead of consumption. It verifies identical request bytes and ordered results. Timings include tracemalloc overhead; memory includes helper-created input-reference copies and results, but excludes caller input allocation and Ray. A historical scatter baseline may have no window: that difference is explicit in effective_window. These measurements do not establish end-to-end throughput or remote memory consumption.

The existing manual Workflow Progress Benchmark workflow also accepts target=distributed-preparation and a baseline_ref. That target runs only the sender benchmark on a hosted Linux runner, with a ten-minute job limit and a JSON artifact. The default remains the PostgreSQL workflow-progress benchmark. The new target does not add benchmark work to ordinary PR CI. Real-Ray success, failure cleanup and the applicable cold deployment gate remain separate evidence.

Runtime Environment Cost

RuntimeEnv caches are per Ray node. A four-node cold fan-out may prepare the same environment on four nodes before useful work begins. Repeated tasks can then reuse each node's cache.

For predictable latency:

  • use a bounded set of named profiles;
  • use immutable code archive URIs;
  • pin production package versions;
  • warm each node before directing latency-sensitive work to it;
  • bake system libraries and large common Python dependencies into the Ray image;
  • reserve RuntimeEnv for application code and genuinely variable dependencies.

Always compare cold and warm runs. A single first-run measurement mostly benchmarks packaging and installation.

Ray Core, Ray Job, and Compiled Graphs

Ray Core is django-ray's low-latency path. Ray Job adds an isolated driver and is a better fit for coarse work.

django-ray workflow signatures currently build ordinary Ray task graphs. They do not use Ray Compiled Graph. Compiled Graph is aimed at repeated execution of a fixed graph from a long-lived Ray driver; django-ray's dynamic maps, durable outer-task lifecycle, and per-run graph metadata have different requirements. It may become useful for a future reusable fixed-DAG execution mode, but it is not a switch that removes RuntimeEnv, database, or first-run costs.

The workflow-plan contract defines how static, dynamic, and fixed-width workloads are classified and which plan, invocation, ownership, and eligibility data a future strategy must use.

The first experimental ownership boundary is deliberately limited to compiling once and invoking repeatedly inside one Ray Core durable task. For a scheduled Kubernetes sync, one generic fixed-width kernel may process several namespaces during that one schedule. The graph is still rebuilt for the next schedule, so this is within-run reuse and has a zero cross-schedule graph-cache hit rate. The current probe covers the direct-ray-core submission transport; the production Ray Client-submitted nested owner has a distinct compatibility identity and remains gated on separate live-cluster lifetime evidence. See ADR-0002 for the process matrix, resource budget, cancellation, and drain requirements. Compatibility evidence must also pin the specific container and immutable deployment/image identity and record explicit shared-memory and Ray object-store configuration. Measurements from a generic host, docker, or unresolved environment cannot be promoted as a native-capability row.

Benchmark the Real Shape

The testproject provides:

POST /api/cluster/workflow-benchmark?num_items=20&seconds_per_item=0.25
GET  /api/cluster/workflow-benchmark/{task_id}

POST /api/cluster/runtime-env/benchmark?profile=numpy-2-3&package=numpy&repeats=3
GET  /api/cluster/runtime-env/{task_id}

Run at least:

  1. cold environment, cold workers;
  2. warm environment with the same profile;
  3. one large batch;
  4. several leaf batch sizes;
  5. Ray Core versus Ray Job when isolation is optional.

Record queue wait, environment setup, leaf runtime, and total duration separately. Optimizing only the function body can miss the dominant cost.

Benchmark Worker Polling

Run the polling benchmark against a disposable PostgreSQL database with no production workers consuming the generated benchmark queue:

python manage.py django_ray_benchmark_polling \
  --workers=4 \
  --tasks=100 \
  --idle-seconds=2 \
  --enqueue-interval-seconds=0.05 \
  --base-interval-seconds=0.1 \
  --max-interval-seconds=0.5 \
  --seed=53 \
  --json

The command runs fixed-100-ms and adaptive policies sequentially through the production worker claim loop. Both execute its real SELECT ... FOR UPDATE SKIP LOCKED query on isolated queues. Independent phases report idle claim and total SQL queries per worker-second, peak distinct-worker overlap and a sliding-window overlap ratio, spaced enqueue-to-claim p50/p95, and preloaded-burst claim throughput. The enqueue timestamp is captured before the task row is inserted, so latency includes enqueue database time. Generated rows are validated for exact, unique ownership before deletion. The command refuses non-PostgreSQL databases because SQLite cannot represent the multi-worker locking behavior being measured.

The additive protocol_predicate_evidence block measures the execution-protocol filter separately from those polling-policy phases. It creates at most 256 exact owned rows at the package's active write protocol (currently protocol 1) on a private queue, intercepts the real production priority claim SELECT before execution, and verifies that the measured production variant has the same normalized SQL shape. A control removes only the inclusive protocol-range predicates. Both variants must return the same rows in the same order. One warm-up pair is followed by 12 deterministic AB/BA timing pairs; the report retains each bounded sample, p50/p95, and signed production-minus-control deltas, plus fixed-vocabulary bounded EXPLAIN ANALYZE plan summaries. Raw SQL, parameters, queue/task/row identities, and arbitrary index names are not retained.

Do not turn one shared-runner delta into a latency threshold. Compare repeated artifacts from the same PostgreSQL version and task shape, confirm production_claim_sql_shape_verified=true, inspect both plan shapes, and interpret a positive delta only alongside run-to-run variance. A persistently different or regressed plan is evidence for a focused index investigation; this benchmark does not by itself authorize a model or migration change.

For a scaling series, keep the database and task shape fixed and run --workers=1, 4, and 8. Repeat each case at least five times after a warm-up run. Record PostgreSQL version, host resources, connection-pool settings, database distance, worker count, task count, enqueue interval, both polling intervals, schema version, and seed with every result. Use the same seed when comparing policies; repeat runs still capture scheduler and database variance.

The manually dispatched Polling Benchmark workflow runs a warm-up and five recorded repetitions for each worker count. Launch it from GitHub Actions when polling behavior or its operating environment changes, then download the polling-benchmark-json artifact for exact environment metadata and individual measurements. Normal pull-request CI keeps the PostgreSQL coordination and polling correctness tests but does not run this time-based matrix. Performance varies with shared runner capacity, so the benchmark checks claim integrity, exact protocol-variant row parity, verified production SQL shape, bounded plan summaries, and finite metrics rather than imposing noisy latency or throughput thresholds. Do not substitute SQLite or simulated timings.

The following values are medians from five repetitions after warm-up in GitHub Actions run 29703449242 on 2026-07-19. The environment was PostgreSQL server_version_num=170010, Python 3.12.13, Django 6.0, and schema 0008_raytaskexecution_priority_constraint on a shared Azure Linux runner. Each policy used 100 tasks per phase, a 50 ms enqueue interval, a 2-second idle window, and a 25 ms overlap window.

Policy/workers Claim p50 (ms) Claim p95 (ms) Idle queries/worker/s Idle overlap Throughput (claims/s)
Fixed 100 ms / 1 2620.0 4931.5 9.99 0% 9.7
Adaptive 100-500 ms / 1 2412.7 4283.8 3.50 0% 10.7
Fixed 100 ms / 4 56.9 99.2 9.49 100% 38.5
Adaptive 100-500 ms / 4 30.6 89.6 3.25 68.2% 43.3
Fixed 100 ms / 8 50.9 104.8 9.49 100% 73.4
Adaptive 100-500 ms / 8 15.7 51.9 3.19 93.0% 85.1

The single-worker latency phase deliberately offers 20 tasks per second to a worker that claims about 10 per second, so its growing queue produces multi-second latency. Treat that row as saturation behavior, not idle wake-up latency. In this run, adaptive polling cut idle claim queries by roughly two-thirds without reducing burst throughput. Shared-runner values are evidence for comparison and tuning, not service-level objectives.