Evict requests on tier1 when the pod's CPU is saturated - #912
Merged
Conversation
Reads usage_usec/nr_throttled from cpu.stat, PSI some avg10 from cpu.pressure and the quota from cpu.max of the container's own cgroup v2 directory, exposed as tier1 gauges. This feeds the upcoming CPU-based request shedder (see plans/2026-08-28-cpu-request-cutoff.md).
Each active request now records its production mode, whether it reached live blocks (first StepNew from the hub), and its wasm compute time excluding external-call waits. This is what the CPU shedder needs to rank requests by importance and by CPU consumed.
Detects CPU overload from the cgroup signals and picks requests to cancel: dev-mode first, then production still catching up, then live production, highest CPU burner first within a class. A clear overload (throttled or under pressure) sheds a whole batch at once, sized to bring usage back under the target; a mild one sheds a single victim per cooldown. Cancellations only start after a drain delay so the load balancer stops routing to the pod before its clients reconnect. Not wired into tier1 yet.
While the shedder reports overload, the pod advertises unready to the load balancer, refuses new Blocks requests with Unavailable, and stays unready until CPU recovers. The shedder starts with tier1 when enabled (still always off: the mode is not configurable yet).
Operators set the mode (observe, dev-only, full) and only the tunables they want to override; unset values take their defaults. Flag registration comes in a firehose-core companion change.
Observe mode logs overload edges and would-be victims but must leave routing untouched: IsOverloaded now reports only in dev-only or full mode, and the overload-change callback fires only when enforcing.
SUBSTREAMS_CGROUP_DIR lets the CPU shedder read fed files on hosts without cgroups (e.g. testing on macOS).
Scaling on substreams_active_requests breaks once the shedder is on: it measures what the pod serves, and shedding lowers it, so overload makes the autoscaler shrink the fleet.
Matches the Kubernetes vocabulary for the same decision: resource pressure, victims picked by priority, removed so they land elsewhere. Keeps it distinct from cancel (the mechanism) and reject (admission refusal).
sduchesneau
force-pushed
the
cpu_request_cutoff
branch
from
September 1, 2026 15:52
7e6e7c5 to
a00e504
Compare
Cutting one request per cooldown made overload episodes last minutes; a single threshold now cuts one batch sized to reach the target. Throttling and PSI stay as gauges instead of gating the trigger.
Neither gated anything after the switch to a single threshold.
Runs a real tier1 against hand-written cgroup files fed from the process's own CPU time, so a wave of dev-mode requests drives a genuine overload. Needs CPUEviction on the devenv tier1 config to reach the evictor.
# Conflicts: # docs/release-notes/change-log.md
sduchesneau
marked this pull request as ready for review
September 2, 2026 14:01
An unreadable cgroup left the pod unready and refusing requests until restart, burn rates divided by the configured tick instead of the real one, and readiness was written on edges by two goroutines that could disagree. The override turns the evictor on where the cgroup accounts CPU but sets no limit.
A peer could exhaust server heap by fragmenting HTTP/2 DATA frames. tests_e2e also picks up the dmetering version the root module already requires.
billettc
approved these changes
Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Off by default. Enable with
--substreams-tier1-cpu-eviction-mode(needs the companion firehose-core change that registers the flags).Problem
Request-count limits balance the number of streams per tier1 pod, but a few CPU-heavy requests can land on the same pod and saturate its CPU quota. Every stream on that pod then lags. The hub does not lag, so
head_block_driftstays flat and nothing detects the condition. Rollouts make it worse: reconnecting streams all do catchup at once, which is the worst-case CPU profile, on the fewest ready pods.What this does
A tier1 pod watches its own cgroup CPU. When it is saturated it advertises unready, waits for the load balancer to stop routing to it, then cancels the most expensive and least important streams with
Unavailableso those clients reconnect to a less busy pod.One signal drives the decision: CPU usage as a fraction of the
cpu.maxquota, from the container's own cgroup v2, so neighbour pods on the same node do not distort it. Per-request CPU comes from the wasm execution timer that already exists (module processing time minus external-call waits), diffed each tick into a burn rate in cores — no new hot-path instrumentation.One overload state: usage over
threshold(0.90) held forsustain(15s). The pod goes unready, waitsdrain-delay, then cancels one batch, sized so the surviving burn fits undertarget-ratio(0.75) of quota. It is ready again once usage stays underrecover-threshold(0.75) forrecover-sustain(30s).Only requests burning real CPU are candidates: under
min-burn-cores(0.05) cancelling one frees nothing and costs a reconnect, and requests younger thanmin-age(90s) are never selected — startup and store loading look hot. Among the candidates, victims go least important first: dev-mode, then production on live blocks, then production still catching up from files, highest burn first within each class.Live before catchup is deliberate, and it is the opposite of what most people expect. A live request burning 2 cores keeps burning them for as long as it stays connected, so moving it is the only thing that helps. A catchup request burning 2 cores is spending them on work that ends: it reaches the head and goes cheap on its own, and cancelling it throws away segment progress that has to be redone elsewhere. The cost of the choice is that a cancelled live stream is visible to a human immediately (reconnect plus store load on the new pod) while a cancelled catchup one is not.
Ordering matters: the pod flips unready and waits
drain-delaybefore the first cancellation, so the balancer has stopped routing to it by the time the evicted clients reconnect. Otherwise they land back on the same pod.Autoscaling
New
substreams_tier1_effective_active_requestsgauge, meant to replacesubstreams_active_requestsas the horizontal autoscaler input:Scaling on the plain count breaks once eviction is on, because the count measures what the pod is serving and eviction lowers it — so overload would make the autoscaler shrink the fleet. Since eviction holds CPU at
target-ratio, a pod that has evicted its way back down still reports itself at capacity instead of reporting the few requests it has left.Set
nominal-capacityto the autoscaler's per-pod request target. It defaults to the active-requests soft limit, which is usually higher.Modes
off(default),observe,dev-only,full.observeevaluates, logs every would-be victim and publishes all metrics, but never touches readiness, admission or any stream — there is a test pinning that.Metrics
substreams_tier1_cpu_quota_cores,_cpu_usage_ratio,_cpu_overloaded,substreams_tier1_evicted_requests_counter{class,action},substreams_tier1_effective_active_requests. Each eviction also logs a structured line with trace_id, class, burn cores, age, current block and the usage ratio at the time.Testing
Unit tests cover cgroup parsing, the burn-rate math, victim selection and its class order, the sustain and recovery windows, the observe-mode guard and the autoscaler metric.
tests_e2e/cpu_eviction_test.godrives the whole path against a real tier1: hand-writtencpu.max/cpu.statfiles underSUBSTREAMS_CGROUP_DIR, withusage_usecfed from the process's own CPU time, advertise a 6-core quota, and eight concurrent dev-mode requests over a 6000-block archive burn about 7.3 cores against it. Two production requests stream live blocks alongside them. From one run:(1.212 - 0.60) × 6 = 3.67cores and the four victims summed to 3.925 cores — the smallest set that covers it; three would have been 2.95class=dev, taken highest burn first: 0.9821, 0.9817, 0.9811, 0.9804 coresmin-burn-cores— and kept streaming throughoutUnavailable: server CPU overloaded, please reconnect; the four dev survivors ran to completionRan green 8 times in a row. It needs Docker and takes ~30s.
One thing the harness surfaced that is worth knowing before
full: when the overload is deep enough that one batch cannot reach the target, a second batch follows after the cooldown. In an earlier tuning (target 0.5, 3-core quota) the first batch cut 3.9 cores of attributed burn but total usage only fell by 1.5 cores, so the evictor fired again 5s later. Attributed wasm burn is not the pod's whole CPU — the Go-side work around it (protobuf, compression, egress) is not attributed to any request — so batch sizing can undershoot. That is the cooldown doing its job rather than a bug, but it is slower than "one batch and done", and the pproftrace_id-label comparison in the plan is the check that says how large the unattributed share is on a real pod.Deploying
Flag registration is a companion firehose-core PR. The HPA change (switch to
effective_active_requests, longscaleDownstabilization) lives in the deploy repo; details are inplans/2026-08-28-cpu-request-cutoff.md, including the trap that Kubernetes picks unready pods first when scaling down.Suggested rollout:
observefirst, thendev-only, thenfullwith the HPA already switched over — evicting without adding capacity just moves the pain around.