Dynamic routing

Rank eligible workers with a declarative scoring policy — route executions by load, latency, locality, or any metric a worker can measure.

Resource requests and worker affinity are hard constraints: they filter the workers that can run a workflow. Dynamic routing adds a soft preference on top — a scoring policy that ranks the workers that survived the filter, by queue depth, event-loop lag, payload locality, live utilization, or anything else you can express as a number. Without a policy, Flux picks the least-loaded eligible worker.

Declaring a policy

Pass routing=score(...) to workflow.with_options. The DSL lives in flux.routing:

from flux import ExecutionContext, workflow
from flux.routing import score, prefer, least, most, sticky, label, metric, resource, load, input


@workflow.with_options(
    routing=score(
        prefer(label("region") == input("region"), weight=10),  # payload locality
        prefer(metric("temp") < 60, weight=2),                   # threshold preference
        least(metric("queue_depth"), weight=5),                  # minimize a worker metric
        most(resource("memory_available")),                      # maximize a resource field
        sticky(weight=3),                                        # opt the relay hint into the score
        least(load()),                                           # built-in: active executions
    ),
)
async def train(ctx: ExecutionContext[dict]):
    ...

A policy is a weighted combination of terms over selectors.

Term types

Selectors

SelectorReadsFreshness
label("key")worker labels (--label key=value)static (set at registration)
metric("key")worker-advertised metrics (built-in flux.* or your provider’s)refreshed every metrics_interval
resource("field")cpu_total, cpu_available, memory_total, memory_available, disk_total, disk_freeregistration-time snapshot (prefer metric("flux.cpu_percent") etc. for live values)
load()active executions on the workerlive, computed at dispatch

input("path") resolves against the execution’s input at dispatch time — dotted paths (input("customer.region")) descend nested dictionaries. This is how payload-driven locality works: the same workflow routes each execution by its own data.

How scoring works

  1. Hard constraints filter first — a policy can never route to a worker that fails requests/affinity/runner matching, is unhealthy, or has no free capacity slot.
  2. Each term is normalized to 0–1 across the eligible workers (so an unbounded load term cannot drown a boolean prefer), multiplied by its weight, and summed.
  3. The highest total wins; ties break deterministically (lower load, then name).

Degradation is deliberate: a worker missing a metric scores 0 for that term; a metric absent on every worker makes the term a no-op; a malformed policy falls back to least-loaded selection. A routing policy can never strand an execution.

Policies are data, not code

The score(...) expression compiles to a JSON spec that is extracted statically at registration — the same AST mechanism as requests — and evaluated natively by the server. Nothing user-supplied executes in the dispatcher. The flip side: the policy must be declared with literal values (or input(...)); a policy the parser cannot extract fails registration with a clear error rather than silently routing differently than written.

Built-in worker metrics

Every worker publishes a standard metric set under the reserved flux. prefix on its heartbeat — no configuration needed ([flux.workers] builtin_metrics = true by default):

MetricMeaning
flux.running_executions / flux.slots_freelive occupancy / headroom (slots_free only with bounded capacity)
flux.loop_lag_seconds / flux.loop_lag_p95_secondslatest / p95 event-loop lag
flux.cpu_percent / flux.memory_available_bytes / flux.load_avg_1mlive utilization (EWMA-smoothed / quantized)
flux.failure_rate / flux.crash_ratefailed / child-crashed fraction of recent executions
flux.executions_per_minuteobserved completion throughput
flux.execution_duration_p95_secondscompletion-time tail
flux.startup_overhead_secondsmedian dispatch→first-checkpoint gap (runner spawn/load cost)
flux.warm_modulesworkflow modules warm in the inprocess runner’s cache

So these work with zero setup:

# Steer latency-sensitive work away from degraded-but-not-unhealthy workers
routing=score(least(metric("flux.loop_lag_p95_seconds"), weight=5), least(load()))

# Quarantine workers that accept work and fail it (full disk, sick GPU, ...)
routing=score(prefer(metric("flux.crash_rate") < 0.1, weight=10), least(load()))

Aggregates are computed on the worker over fixed windows and published as single scalars — the server stores only the latest snapshot per worker, never a time series. For history and trending, use the observability pipeline (see Worker observability).

Custom metrics providers

For anything the built-ins don’t cover, point the worker at your own callable (sync or async) returning dict[str, float]:

# myapp/routing.py — runs inside the worker process
import psutil


async def collect() -> dict[str, float]:
    return {
        "gpu_queue_depth": gpu_queue.qsize(),
        "shard_latency_ms": await probe_local_shard(),
        "scratch_free_gb": psutil.disk_usage("/scratch").free / 1e9,
    }
[flux.workers]
metrics_provider = "myapp.routing:collect"
metrics_interval = 10.0

The worker refreshes the provider on that cadence (sync providers run in a thread; a failure keeps the previous snapshot), merges the result with the built-ins, and advertises the snapshot on its heartbeat pong. This is the intended home for arbitrary routing logic: measure anything worker-side — including windowed aggregates like a rolling p95 you compute yourself — and publish it as a number the server can rank on declaratively.

Guardrails: a provider may publish up to 32 metrics (string keys ≤ 64 characters, finite numbers; the server caps the merged set at 64); invalid payloads are dropped with a warning, never an error. Provider keys under the reserved flux. prefix are stripped, so user values can never impersonate a built-in signal.

Observing routing decisions

Relationship to sticky routing

Relayed call()s tag their child executions with the calling worker’s name (the X-Flux-Preferred-Worker hint), and workflows without a policy prefer that worker when eligible — keeping mesh hops on warm module caches. A workflow with a policy takes full ownership of the score stage: include sticky(weight=...) to blend the hint into your ranking, or omit it to override the hint entirely.

What’s next