Capacity Classification & Fallback¶
capacity
¶
Resource unavailability as a state, not an exception.
The system has always reacted to "the cloud has no room": Dataproc walks zone candidates,
Vertex Ray walks region candidates. It has never represented it. A reaction lives in a stack
frame, shows up only in a log line, and when it runs out raises something indistinguishable from a
ModuleNotFoundError. A state has a name, a counter, a deadline, and a row someone can query.
This module is the representation. It owns four things, in dependency order:
classify— one classifier, three verdicts. Previously there were two classifiers with opposite defaults (compute_fallbackfor Dataproc, private helpers inray_clusterfor Vertex) and no shared vocabulary. Two classifiers that disagree is worse than one that is wrong.CapacityPolicy— how much patience to spend, bounded by attempts and wall-clock.CapacityLedger— the attempt log that ends up injob_telemetryunder$.capacity.walk— the loop: try candidates, then back off and try them again, until one works or the budget is gone.
Everything here is pure and import-free (stdlib only) so the whole thing unit-tests offline with an injected clock. The cluster submitters drive it.
Why three verdicts and not a boolean. The old question was hop, or raise? The right question is why, because only one of the three answers is worth waiting on:
| Verdict | Meaning | Action |
|---|---|---|
TRANSIENT_CAPACITY |
no room right now | hop and retry — time will plausibly fix it |
HARD_CEILING |
not allowed this much here | hop only; waiting cannot raise a quota |
CONFIG_FAULT |
the request is malformed | stop; hopping ends in the same error |
The middle one was earned on 2026-09-02: us-east1 allows 2 training T4s and a smoke asked for 7,
so no amount of retrying could ever succeed there — while us-central1's contentless error the
same afternoon was worth retrying and succeeded untouched two hours later. Same accelerator, same
day, opposite correct behaviour. A policy that treated both as transient would have burned the
entire time budget on the region that was arithmetically impossible.
An unrecognised message is TRANSIENT_CAPACITY, not CONFIG_FAULT — see classify.
CapacityAttempt
dataclass
¶
One attempt at one candidate, and what the cloud said about it.
message is kept verbatim, truncated only to keep a pathological traceback out of a
telemetry column. Three of this campaign's defects were classifiers failing on strings nobody
had thought of, and the only durable fix is keeping the strings: the next unrecognised wording
is discoverable from a query instead of from a live failure.
to_json
¶
The telemetry shape — plain JSON types only, so it merges into job_telemetry.
Source code in src/scale_forecasting/capacity.py
CapacityExhausted
¶
Bases: EngineError
Every candidate was tried until the attempt or time budget ran out.
Carries the ledger so the caller can write it to telemetry and stamp the job row FAILED with
failure_reason: CAPACITY_EXHAUSTED — the one failure worth re-running unchanged, and the
thing an operator could not previously tell apart from a broken import.
Source code in src/scale_forecasting/capacity.py
CapacityLedger
dataclass
¶
Every attempt a walk made, in order — what lands under job_telemetry.$.capacity.
Mutable and append-only by design: walk writes to it as it goes, so a caller holding the
ledger can publish partial state while the walk is still running. That is what makes
AWAITING_CAPACITY observable rather than merely nameable — without it, the only view of a
job in the middle of a three-region walk would be the same RUNNING row a computing job has.
dead_candidates
property
¶
Candidates that answered HARD_CEILING — never worth another attempt this run.
A quota ceiling does not move while a run is in flight, so re-trying one is guaranteed waste. Dropping them is what keeps a long walk from spending its whole budget re-asking a region that has already said no on arithmetic grounds.
record
¶
Append one attempt, clipping an over-long message.
Source code in src/scale_forecasting/capacity.py
to_json
¶
The $.capacity payload: the service, the attempt list, and whether it gave up.
Source code in src/scale_forecasting/capacity.py
CapacityPolicy
dataclass
¶
CapacityPolicy(
max_attempts=6,
max_wall_seconds=3600.0,
max_passes=0,
backoff_seconds=60.0,
backoff_multiplier=2.0,
backoff_max_seconds=600.0,
)
How much patience to spend looking for room, bounded two ways at once.
Both bounds, because attempt cost spans two orders of magnitude across services. A Ray GPU
provision is ~12 minutes per attempt (measured 2026-09-02); a Serverless batch rejection comes
back in seconds. max_attempts=5 therefore means an hour on one and half a minute on the
other, so neither bound alone means anything. Whichever is reached first ends the walk.
max_attempts counts individual candidate attempts, not passes over the candidate list — a
three-region config with max_attempts=6 gets two full passes. 0 disables the bound.
max_wall_seconds is measured from the first attempt. It is checked before starting an
attempt and before sleeping, never mid-attempt: a create that is already running is allowed to
finish, because killing it would leak the very resource ray_cluster._clear_stale_resource
exists to clean up. So the real ceiling is this plus one attempt's duration, which is honest and
documented rather than pretended away. 0.0 disables the bound.
max_passes bounds complete sweeps of the candidate list. 0 disables it; 1 reproduces
the pre-retry behaviour exactly — try each place once, then give up — which is what
config.CapacityConfig.enabled = false resolves to. It exists because "no retry" cannot be
expressed with the other two bounds: the walk does not know how many candidates it will get.
Back-off applies only between passes, never between candidates within a pass — hopping is free and waiting is not, so the walk exhausts the places it can try before it spends any time.
backoff_for
¶
Seconds to wait after completed_passes exhausted passes (pure, capped).
Source code in src/scale_forecasting/capacity.py
classify
¶
Read a provisioning failure and return one of VERDICTS (pure).
message should be the richest text available. On Vertex that means the resource's own
error.message concatenated with the raised exception — the SDK's exception is a generic
"returned an error" while the "Resources are insufficient in region" / quota text lives only on
the resource, so classifying on the exception alone would never detect a stockout. exc is
consulted only for its class name (_CAPACITY_EXCEPTION_NAMES), which is how a Compute Engine
stockout surfaces from create_cluster with no useful string at all.
Precedence, and every step of it is load-bearing:
- Capacity wins outright. A message can name a machine type and say the zone ran out of it; that is about the place, and hopping is exactly right.
- Quota, when it is not also capacity, is a
HARD_CEILING. The region has room; this project is simply not allowed more. Hop, never wait. - A named region-invariant cause is a
CONFIG_FAULT. Stop. - A regional prerequisite reported absent is a
HARD_CEILING. Hop, never wait. This sits after step 3 on purpose: "service account … was not found" is a config fault, and a missing thing that is missing everywhere must keep stopping. Only a resource kind known to be regional reaches this step. - Only then, the exception's type.
_CONFIG_FAULT_EXCEPTION_NAMES(a malformed request) stops;_HARD_CEILING_EXCEPTION_NAMES(a prerequisite that does not exist here) hops without waiting. Deliberately last, so it can only rescue cases that would otherwise have fallen through to step 6 — every classification the text already makes is left exactly as it was. - Everything else is
TRANSIENT_CAPACITY.
Step 6 is the deliberate asymmetry: any unrecognised or contentless platform error (such as
"An internal error occurred on your cluster" or "Unexpected response.") defaults to
TRANSIENT_CAPACITY so the multi-region fallback walks the remaining candidate regions rather
than aborting in the first region. The costs are not symmetric: retrying a config fault wastes
minutes and still ends in an error that names every candidate tried, while refusing to retry an
unrecognised stock-out loses the multi-region fallback silently. Give up immediately only when
the message names a cause that travels with the request.
Source code in src/scale_forecasting/capacity.py
current_publisher
¶
The ambient publisher, or None when nothing installed one (a bare API call, a test).
publishing_to
¶
Install on_state as the ambient capacity publisher for the duration of the block.
Source code in src/scale_forecasting/capacity.py
walk
¶
walk(
candidates,
attempt,
*,
ledger,
policy,
label=str,
describe_failure=None,
on_state=None,
now=monotonic,
sleep=sleep,
)
Try candidates until one succeeds, retrying the list until the budget is gone.
The loop the design calls for, and the whole reason this module exists::
attempt 1 .......... TRANSIENT_CAPACITY in us-central1
hop to the next candidate; no wait (another region may have room now)
candidates exhausted, budget remains
back off, then restart the candidate walk from the top
budget exhausted (attempts OR wall-clock)
terminal: CapacityExhausted, ledger intact
Returns whatever attempt returns for the candidate that worked.
Raises immediately on CONFIG_FAULT — re-raising the original exception, not a wrapper, so
the caller's existing handling and the operator's traceback are both unchanged. Hopping on a
malformed request wastes minutes and ends in the same error.
Raises CapacityExhausted when the bounds run out, carrying the ledger.
describe_failure lets a caller enrich the message before it is classified and recorded. The
Vertex path needs it: the SDK raises a generic "returned an error" while the reason lives on the
failed resource, so that path reads the resource and concatenates. Defaults to str(exc).
on_state is called after every recorded attempt, with the ledger, so a caller can publish
AWAITING_CAPACITY and the partial ledger during the walk rather than only at the end. It
is best-effort: a publish that raises must not sink a walk that might still succeed, so it is
caught and logged.
now/sleep are injected so the whole loop — including the back-off schedule and both
bounds — unit-tests offline in microseconds with no real clock.
Source code in src/scale_forecasting/capacity.py
488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 | |