--- title: Tune routing capacity polling description: Cap a workload cluster's TrafficMap weight at the replicas that its endpoint reports it can serve, with an alpha poll set in the chart or for one InferenceService. status: preview since: v1.3 --- A capacity poll lets a workload cluster cap its share of an [InferenceService](../../concepts/serving/inference-services.md)'s traffic at what it can serve. The control plane asks the cluster how many of the service's replicas can serve now, and counts no more than that in the cluster's [TrafficMap](../../concepts/rollouts-and-traffic/traffic-map.md) weight. Use it when a cluster knows better than the control plane what can serve: for example, how many of its [engine and decoder](../../concepts/serving/inference-services.md#components) pairs work together during a rollout. A report can lower a cluster's weight but never raise it, and a failed poll leaves the weight as it would be without one. Multi-cluster routing is alpha, still in development, and off by default. Its API, the capacity settings included, can change without notice. You turn it on with the `ome-resources` chart's values `ome.multicluster.enabled`, `ome.multicluster.role` and `ome.multicluster.config.routing.enabled`, as [Step 1](#step-1-set-the-operator-defaults) shows.
- A control-plane cluster running OME v1.3 or later, installed with the `ome-resources` chart as the release `ome` in the namespace `ome`, with multi-cluster on in the control-plane role. You need Helm and `kubectl` access to it. - Workload clusters registered on the control plane as [WorkloadClusters](../../reference/api/ome.v1beta1.md#ome-io-v1beta1-WorkloadCluster). The examples call them `worker-a` and `worker-b`. - The InferenceService `qwen3-0-6b` in the namespace `qwen3-0-6b` on the control plane, placed on both workload clusters with the `placement` block in Step 2's manifest. Check it with `kubectl get inferenceservice qwen3-0-6b -n qwen3-0-6b -o jsonpath='{.status.placement.phase}'`, which prints `Placed`. - A capacity reporter for the InferenceService on each workload cluster, reachable from the controller's pods at the URL in [The report format](#the-report-format). OME doesn't provide one.
## How the poll caps a cluster's weight A workload cluster that has admitted the InferenceService and reports an endpoint for it is a home, with an entry in the TrafficMap. OME polls a home every `period`, keeps its last `samples` readings, and takes the `quorum`-th lowest as its ceiling. With `quorum: 2`, the readings 4, 1 and 4 give a ceiling of 4, since one low reading can't set it alone, and 1, 4 and 1 give 1. The entry's `capacity.allocated` becomes the lower of the admitted replicas and the ceiling, and [the weight follows from it](../../concepts/rollouts-and-traffic/traffic-map.md#how-ome-sets-the-weights). A ceiling of 0 takes all of the home's traffic away, even with a probe's `allFailedPolicy: PreserveTraffic`. After a failed poll, the home has no ceiling, and goes back to its admitted replicas until `quorum` polls in a row succeed. So a network fault can leave a home with more traffic than it can serve for a while, but can't take its traffic away. A home also starts over with no readings when the controller restarts or changes leader, when you change a capacity setting, or when its endpoint changes. ## The report format A poll is one request, with the poll's `method` and no body, to the entry's `endpoint` followed by the poll's `path`. The endpoint is the `status.url` of the service's copy on that workload cluster, so the poll URL depends on the cluster's ingress. For `worker-a`: | Workload cluster's ingress | Poll URL | | --- | --- | | Gateway API with [per-service subdomains](../networking/gateway-host-schemes.md#per-service-subdomains) | `http://qwen3-0-6b.qwen3-0-6b.worker-a.example.com/capacity` | | Gateway API with the default [shared host](../networking/gateway-host-schemes.md#shared-host-with-a-path-prefix) | `http://llm.worker-a.example.com/qwen3-0-6b/qwen3-0-6b/capacity` | | Kubernetes Ingress | `http://qwen3-0-6b.qwen3-0-6b.worker-a.example.com:8080/capacity`, on the engine Service's port, not your Ingress controller's | OME's HTTPRoute or Ingress sends all paths under the endpoint to the model server, which answers the poll with a 404. So add a route of your own that sends the poll URL to your reporter. For an HTTPRoute on the same Gateway, the Gateway API gives your longer path precedence over OME's. For a separate Ingress, precedence depends on your Ingress controller. Your reporter answers with status 200 and a JSON body in the `Report` format, the only one that OME reads: ```json {"servable": 4, "observedAt": "2026-09-28T10:00:00Z"} ``` | Field | What it holds | | --- | --- | | `servable` | Required. How many replicas the home can serve now, as a whole number, 0 or more. For separate engine and decoder components, count the pairs that work together. | | `observedAt` | Required. When the reporter worked out the count, in RFC 3339 with a time zone. A report fails once it's `maxAge` old, or when it's from the future. | - Count replicas, not utilization or request slots: OME compares the count with the admitted replicas. - Answer at the path itself, with no login: the poll sends no credentials, and OME doesn't follow redirects. - Keep the reporter's clock in step with the control plane's. ## Step 1: Set the operator defaults Set the poll for all routed InferenceServices under `ome.multicluster.config.routing.capacity`, in your `ome-resources` values file. Do this once they all have reporters: a service without one falls back on all its homes, and keeps its `CapacityFallback` condition `True`. To poll only some services, leave the block out, and give each a poll as in [Step 2](#step-2-set-a-poll-for-one-service). This example turns routing on, with the poll from the chart's comments: ```yaml title="values.yaml" ome: multicluster: enabled: true role: control-plane config: routing: enabled: true capacity: path: /capacity method: GET format: Report samples: 10 quorum: 2 period: 10s timeout: 3s maxAge: 30s ``` Keep your other values, such as a [routing health probe](routing-health-probes.md), in the same file: `helm upgrade` resets the values that aren't in it to the chart's defaults. | Field | What it sets | | --- | --- | | `path` | The path added to a home's endpoint, starting with `/`. Setting it turns the poll on. | | `method` | `GET` or `POST`. | | `format` | `Report`. | | `options` | Leave it out: `Report` takes none. | | `samples` | How many readings to keep for each home. At least 1, and at most the chart's [`routing.observer.maxSamples`](#the-observer-limits), 100 by default. | | `quorum` | Which reading, from the lowest, sets the ceiling. At least 1, and at most `samples`. | | `period` | How often to poll, as a duration such as `10s`. At least the chart's [`routing.observer.minPeriod`](#the-observer-limits), `1s` by default. | | `timeout` | How long a poll can take. Shorter than `period`. | | `maxAge` | How long a reading counts, from its `observedAt`. At least `quorum` times `period`. | Once you set `path`, every field but `options` is required, or the controller doesn't start: see [The controller rollout doesn't finish](#the-controller-rollout-doesnt-finish). To choose the values: - Raise `quorum` when successive polls can reach different reporters behind a load balancer. A drop then applies only when `quorum` readings agree, after at least `quorum` polls. - Give `maxAge` room above `quorum` times `period` for the time that the reporter holds a figure before it answers. A reporter that freezes loses its home's ceiling within `maxAge`. Upgrade the release with the chart version that you already run, 1.3.0 or later: ```bash helm upgrade --install ome oci://ghcr.io/moirai-internal/charts/ome-resources \ --namespace ome --version 1.3.0 -f values.yaml ``` Helm reports that the release `ome` has been upgraded, and the controller restarts with the new settings. Check that it's back up: ```bash kubectl rollout status deployment ome-controller-manager -n ome ``` When the rollout finishes, kubectl prints: ```output deployment "ome-controller-manager" successfully rolled out ``` ## Step 2: Set a poll for one service Give one InferenceService its own poll in its `spec.routing.capacity`, with or without a poll in the chart. It replaces the chart's poll as a whole, so set every field but `options`. For endpoints that spread polls across several reporters, this poll keeps four readings, needs three to agree before it lowers a ceiling, and lets a reading count for four periods: ```yaml title="isvc.yaml" apiVersion: ome.io/v1beta1 kind: InferenceService metadata: name: qwen3-0-6b namespace: qwen3-0-6b spec: model: name: qwen3-0-6b runtime: name: srt-qwen3-0-6b engine: minReplicas: 2 maxReplicas: 2 placement: policy: ClusterAffinity mode: All clusterAffinity: - matchFields: - key: metadata.name operator: In values: [worker-a, worker-b] routing: capacity: path: /capacity method: GET format: Report samples: 4 quorum: 3 period: 10s timeout: 3s maxAge: 40s ``` The API server rejects a poll that's missing a field, with the message `an enabled capacity poll must specify path, method, format, period, timeout, samples, quorum, and maxAge`. It accepts some other mistakes: see [The TrafficMap doesn't pick up the poll](#the-trafficmap-doesnt-pick-up-the-poll). ```bash kubectl apply -f isvc.yaml ``` ```output inferenceservice.ome.io/qwen3-0-6b configured ``` The new settings start the homes over with no readings, so they get their ceilings from the third poll on, about 20 seconds later. ## Step 3: Watch the capacity state Check the TrafficMap, which has the InferenceService's name and namespace. Its `CapacityFallback` condition sums up the homes: ```bash kubectl get trafficmap qwen3-0-6b -n qwen3-0-6b \ -o 'custom-columns=FALLBACK:.status.conditions[?(@.type=="CapacityFallback")].status,REASON:.status.conditions[?(@.type=="CapacityFallback")].reason,MESSAGE:.status.conditions[?(@.type=="CapacityFallback")].message' ``` ```output FALLBACK REASON MESSAGE False EndpointCapacityAvailable endpoint capacity is available for all 2 home(s) ``` While any home has no ceiling, it's `True` with the reason `EndpointCapacityUnavailable`: see [A home falls back to its admitted replicas](#a-home-falls-back-to-its-admitted-replicas). [Traffic map](../../concepts/rollouts-and-traffic/traffic-map.md#capacityfallback) lists its other reasons. List each home's weight and capacity: ```bash kubectl get trafficmap qwen3-0-6b -n qwen3-0-6b \ -o jsonpath='{range .spec.entries[*]}{.cluster}{" "}{.weight}{" "}{.capacity.allocated}{" "}{.capacity.ready}{" "}{.capacity.source}{" "}{.capacity.reported}{"\n"}{end}' ``` When `worker-a` reports 3 and `worker-b` reports 1, and both have two ready replicas: ```output worker-a 2 2 2 ControlPlane 3 worker-b 1 1 2 Endpoint 1 ``` On `worker-b`, the ceiling of 1 lowers `allocated` from 2 to 1, so `source` is `Endpoint`. On `worker-a`, the ceiling of 3 is above the two admitted replicas, so `source` stays `ControlPlane`. Its `reported` value is 3, which a failed poll would leave empty. The weights 2 and 1 give `worker-a` two thirds of the traffic. OME leaves out `allocated` and `ready` when they're 0, so a missing number means 0. [Traffic map](../../concepts/rollouts-and-traffic/traffic-map.md#read-a-trafficmap) describes the fields. While the poll is on, the gauge `ome_trafficmap_capacity_fallback_homes`, with the labels `namespace` and `trafficmap`, counts the homes without a ceiling. Alert when it stays above 0. [`kubectl ome placement endpoint`](../../reference/kubectl-ome/placement.md#endpoint) shows the condition and a code for each home's fallback reason and, with `-o wide`, its capacity source. The command reads alpha features. ## Turn the poll off To turn the poll off for one InferenceService, whatever the chart sets, replace the `capacity` block in `isvc.yaml` with `disabled: true`: ```yaml spec: routing: capacity: disabled: true ``` Apply the file again: ```bash kubectl apply -f isvc.yaml ``` ```output inferenceservice.ome.io/qwen3-0-6b configured ``` OME stops polling the service's homes, and `CapacityFallback` turns `False` with the reason `CapacityPollingDisabled`. Don't use `spec.routing.enabled: false` for this: it takes the service out of routing, and OME deletes its TrafficMap. To stop the chart's poll, remove the `capacity` block from your values file, and run `helm upgrade` again. OME keeps polling for InferenceServices that set their own `spec.routing.capacity`. ## The observer limits One chart block, `ome.multicluster.config.routing.observer`, bounds the work that the capacity polls and the [routing health probes](routing-health-probes.md) create, across all routed InferenceServices. These are the chart's defaults: ```yaml ome: multicluster: config: routing: observer: maxConcurrentReconciles: 8 maxConcurrentRequests: 16 maxResponseBytes: 65536 minPeriod: 1s maxSamples: 100 ``` | Field | What it bounds | | --- | --- | | `maxConcurrentReconciles` | How many TrafficMaps OME updates at once. | | `maxConcurrentRequests` | How many poll and probe requests OME has underway at once. The two kinds share the budget. | | `maxResponseBytes` | How many bytes OME reads from an answer. A longer report fails its poll with `reading capacity response: response exceeds configured limit of 65536 bytes`. A probe stops reading there, and passes or fails on the status alone. | | `minPeriod` | The shortest `period` a poll or a probe can set. | | `maxSamples` | The most readings a poll's `samples` can keep. | With `routing.enabled: true`, all five limits are required, even when the chart sets no poll or probe. A limit that you leave out of your values file keeps its default, but one set to 0, or removed with `null`, stops the controller from starting, like a bad poll: see [The controller rollout doesn't finish](#the-controller-rollout-doesnt-finish). OME checks the chart's poll and probe against `minPeriod` and `maxSamples` when the controller starts, and a `spec.routing` block when it picks the block up, where a broken bound [freezes the TrafficMap](#the-trafficmap-doesnt-pick-up-the-poll) like any other mistake in the block. A poll or probe waits for one of the `maxConcurrentRequests` slots before it's sent. The wait can't fail it, since its `timeout` starts with the request. But when many homes share the budget, the time between a home's attempts grows past `period`, which shows as a probe's [`lastProbeTime`](routing-health-probes.md#step-3-check-the-probe-state) falling behind. Raise `maxConcurrentRequests` in your values file when that happens. ## Troubleshooting ### The controller rollout doesn't finish With an unusable multi-cluster setting, the new controller pods exit when they start, and `kubectl rollout status` fails with `deployment "ome-controller-manager" exceeded its progress deadline`. Find the setting in the controller's log: ```bash kubectl logs -n ome -l control-plane=ome-controller-manager --tail=-1 | grep 'invalid multi-cluster configuration' ``` The error names the setting. For example: - A period without a unit gives `routing.capacity.period: "10" is not a duration (use forms like "30s", "5m")`. - A timeout as long as the period gives `routing.capacity.timeout (10s) must be shorter than routing.capacity.period (10s)`. - A period under [the observer's floor](#the-observer-limits) gives `routing.capacity.period (500ms) must be at least routing.observer.minPeriod (1s)`. - An observer limit set to 0 or `null` gives `routing.observer.maxSamples must be positive when routing is enabled`. Fix the value in your values file, and run `helm upgrade` again. ### The TrafficMap doesn't pick up the poll `kubectl apply` accepts some mistakes in `spec.routing.capacity` that OME refuses later. Until you fix them, OME stops polling and probing the service's homes, and [its TrafficMap stops updating](../../concepts/rollouts-and-traffic/traffic-map.md#where-it-comes-from): it keeps its last weights, and `Published` turns `False` with the reason `InvalidOwner`. A new InferenceService gets no TrafficMap. A [probe](routing-health-probes.md) error has the same effect. Find the error in the controller's log: ```bash kubectl logs -n ome -l control-plane=ome-controller-manager --tail=-1 | grep -E 'validate routing policy|resolve (capacity|probe) policy' ``` For example: | Problem | Error | | --- | --- | | `maxAge` is shorter than `quorum` times `period` | `validate routing policy: spec.routing.capacity.maxAge (20s) must span at least quorum (3) period intervals` | | A format other than `Report`, such as `report` | `resolve capacity policy: spec.routing.capacity.format "report" is not registered in this build (available: [Report])` | | `period` is under the chart's [`routing.observer.minPeriod`](#the-observer-limits) | `resolve capacity policy: spec.routing.capacity.period (500ms) must be at least routing.observer.minPeriod (1s)` | | `samples` is over the chart's [`routing.observer.maxSamples`](#the-observer-limits) | `resolve capacity policy: spec.routing.capacity.samples (200) must not exceed routing.observer.maxSamples (100)` | Fix the field, and apply `isvc.yaml` again. ### A home falls back to its admitted replicas When the `CapacityFallback` condition is `True`, its message counts the homes without a ceiling, as in `1 of 2 home(s) are using control-plane capacity; inspect spec.entries[].capacity.fallbackReason`. List each home's reason: ```bash kubectl get trafficmap qwen3-0-6b -n qwen3-0-6b \ -o jsonpath='{range .spec.entries[*]}{.cluster}{": "}{.capacity.fallbackReason}{"\n"}{end}' ``` When `worker-b`'s poll reaches the model server instead of your reporter, and `worker-a` has its ceiling: ```output worker-a: worker-b: capacity endpoint returned HTTP 404 ``` The reason says what to fix: | `fallbackReason` | What it means | | --- | --- | | `no capacity report yet`, or `capacity report awaiting quorum (1/3 accepted samples)` | Nothing's wrong. The home gets its ceiling once `quorum` polls in a row succeed. | | `capacity report is stale (at least configured maximum age 40s)`, or `capacity report awaiting quorum after stale samples expired (2/3 accepted samples)` | The reporter updates less often than `maxAge`, has frozen, or has a clock that runs behind. Fix it, or raise `maxAge`. | | `capacity report timestamp is in the future` | The reporter's clock runs ahead of the controller's. | | `capacity endpoint unreachable:`, then the error | A DNS, connection or TLS error, or no answer within `timeout`. Check the network path from the controller's pods, or raise `timeout`. | | `capacity endpoint returned HTTP`, then the status | 404: the path doesn't reach your reporter, as [The report format](#the-report-format) explains. 3xx: OME doesn't follow redirects. 401 or 403: the poll sends no credentials. | | `capacity response is not a valid report:`, then the error, `capacity report has no servable count`, `capacity report has no observedAt stamp`, or `capacity report is negative (-1)` | The body isn't the report that [The report format](#the-report-format) describes, or is empty, as with `method: HEAD`. A negative count isn't read as 0. | | `reading capacity response:` or `capacity request did not complete:`, then the error | The body is longer than the chart's [`routing.observer.maxResponseBytes`](#the-observer-limits), or the poll ran out of time. | | `capacity target is invalid:`, `capacity request URL is invalid:` or `capacity request could not be built:`, then the error | OME can't build a request from the entry's `endpoint` and the `path`. | | `capacity observation could not be submitted:`, then the error | The controller was starting or stopping, as in a restart. It clears on its own. | ## Next steps - [Traffic map](../../concepts/rollouts-and-traffic/traffic-map.md): how OME builds the TrafficMap and works out each home's weight. - [Configure routing health probes](routing-health-probes.md): set a cluster's weight to zero when its endpoint stops answering. - [Weight traffic for heterogeneous clusters](weight-traffic-for-heterogeneous-clusters.md): count one cluster's replicas for more than another's. - [Drain a workload cluster](drain-a-workload-cluster.md): hold a workload cluster at zero weight with `kubectl ome traffic drain`, whatever its capacity. - [Publish a global endpoint](publish-a-global-endpoint.md): serve the InferenceService on one hostname that splits its traffic by the TrafficMap's weights.