Ray Orphan Reaper¶
ray_reaper
¶
Reclaim Ray clusters whose run is over — the backstop for a launcher that never cleaned up.
Every Ray cluster this product creates is torn down in a finally by the process that created it.
That is not a guarantee, it is a hope that nothing kills the process: kill -9, a preempted VM, a
Composer worker eviction, an OOM and a laptop that sleeps all defeat it, and each one leaves a head
node plus workers — possibly an accelerator — billing against a run that no longer exists. This was
not theoretical. A dev machine restarted mid-provision on 2026-09-12 and left a head node, a CPU
worker and a T4 running for twenty-five minutes with nobody watching.
The Dataproc path does not have this problem, which is what makes the gap worth a module.
dataproc_cluster.build_lifecycle_config attaches an idle TTL and a max age to every cluster we
build, so the identical kill leaves something the service reclaims without us. Vertex's
PersistentResource has no such field to set — there is no TTL, no idle timeout, no auto-delete
anywhere on the resource — so the only mechanism available is to come back later and look. That is
this module.
What makes "later" safe is that garbage is decidable, not guessed at. Three independent facts have to line up before anything is deleted:
- the cluster carries our
applabel and aregistrylabel naming this registry (ray_cluster.cluster_labels), so a second deployment sharing the project is invisible to us; - its name starts with
sf-ray-(engines.ray_io.EPHEMERAL_PREFIX), which every run-derived ephemeral cluster has and nothing else is checked against; - the
run_idembedded in that name resolves to a run header that is terminal, or to no header at all and the cluster is older thanDEFAULT_MIN_AGE_SECONDS.
A standing reuse target is safe because of the first fact, not the second. The reuse path skips
create and teardown (ray_submit.submit_ray), so a cluster an operator provisioned themselves was
never labelled by us and ray_cluster.list_clusters does not even return it — which holds however
they chose to name it. The name check is the second line, for a labelled cluster of ours that a
config then pointed a reuse run at.
The age floor exists for exactly one race: a cluster that has just been created by a run whose
header is not yet written reads as "unknown run" for a few seconds. It applies only to the no-header
case — a cluster whose run is already COMPLETED is garbage the moment it is seen, however young,
because the only thing that could have produced it is a teardown that did not happen.
Name-to-run matching is by prefix, not equality, because ray_io.cluster_name clamps to 63
characters and a long enough run_name loses the tail of its id. A prefix that matches several
runs is treated as matching all of them: one live match keeps the cluster. Ambiguity resolves toward
leaving a machine running, never toward deleting one.
Preview is the default and yes=True executes, the same shape every destructive verb in
registry.ops uses — and the plan prints the kept clusters as prominently as the doomed ones,
because "why is this one still here?" is the question an operator actually arrives with.
Two ways in, because a verb alone is not a ceiling. reap_clusters is the operator's command
and previews by default. sweep_on_launch is the same decision run automatically, immediately
before this deployment creates a Ray cluster — the one moment it is guaranteed to be awake, and the
moment a leaked cluster is holding the quota the new one needs. It swallows its own failures,
because no cleanup is worth a launch that will not start, and it can be switched off with
SF_REAP_ON_LAUNCH=0.
Split along the usual pure/I-O seam: classify_clusters and format_reap_plan are pure and decide
everything, while plan_reap_clusters, reap_clusters and sweep_on_launch only fetch and act.
Public surface: reap_clusters, sweep_on_launch, plan_reap_clusters,
classify_clusters, format_reap_plan, ReapPlan, ReapCandidate,
DEFAULT_MIN_AGE_SECONDS, SWEEP_ON_LAUNCH_ENV. Reachable as registry-ops reap-clusters
and as sdk.Registry.reap_clusters; the Vertex listing and the delete it drives are
ray_cluster.list_clusters / ray_cluster.teardown_shared_cluster.
ReapCandidate
dataclass
¶
One cluster the reaper looked at, and why it lived or died.
matched_runs is every run id in the registry whose name the cluster's embedded id is a
prefix of — usually one, empty when the run is unknown, and occasionally several when a clamped
name is ambiguous. reason is written for an operator reading a preview, so it names the
run and the status rather than restating the rule.
ReapPlan
dataclass
¶
Exactly which Ray clusters reap_clusters would delete, and what it is leaving alone.
classify_clusters
¶
classify_clusters(
clusters,
status_by_run,
*,
registry_dataset_id,
now,
min_age_seconds=DEFAULT_MIN_AGE_SECONDS,
)
Decide each cluster's fate (pure). Every cluster comes back with a verdict and a reason.
The checks run cheapest-and-most-exclusionary first — registry label, then ephemeral name, then
the STOPPING short-circuit, then the run header — so a cluster that is not ours exits before
anything has to reason about run ids. status_by_run is the whole registry's latest header
per run; a run id missing from it is a run this registry has never heard of.
Source code in src/scale_forecasting/ray_reaper.py
format_reap_plan
¶
Render a ReapPlan as the operator-facing preview — the exact text a dry run prints.
Kept clusters are listed too, and by design: a reaper that prints only what it will delete cannot answer "so why is that T4 still running?", which is the question that brings an operator here in the first place.
Source code in src/scale_forecasting/ray_reaper.py
plan_reap_clusters
¶
Which Ray clusters reap_clusters would delete — without deleting anything.
regions defaults to the data-plane region alone. Widen it when a run's capacity walk may
have hopped the cluster elsewhere; a region left off the list is one the sweep cannot clean,
and ray_cluster.list_clusters raises rather than quietly skipping one it cannot read.
Source code in src/scale_forecasting/ray_reaper.py
reap_clusters
¶
reap_clusters(
*,
settings=None,
regions=None,
min_age_seconds=DEFAULT_MIN_AGE_SECONDS,
yes=False,
)
Delete the Ray clusters whose run is over. Preview by default; yes=True executes.
Deletes through ray_cluster.teardown_shared_cluster, which is the same verified teardown a
normal run uses: the SDK's success line is ignored and the resource is polled until it reads
NOT_FOUND. A cluster that refuses to go is logged by name as still billing rather than
counted as reclaimed — a reaper that reports a delete it did not achieve is worse than none at
all, because it also removes the operator's reason to go and look.
Every cluster is attempted even if one delete fails, for the reason
shared_clusters.teardown_spark_clusters gives: stopping at the first failure strands all of
the ones behind it, which is precisely the outcome this module exists to prevent.
Source code in src/scale_forecasting/ray_reaper.py
sweep_on_launch
¶
Reclaim finished-run clusters just before creating another one. Best effort; never raises.
The verb above only runs when a person runs it, and a leak is by definition something that happened while nobody was watching. Vertex has no cluster-side ceiling to lean on instead (no TTL, no idle timeout), so the closest thing to "always" available here is every time this deployment provisions a Ray cluster — which is also when it matters most, since the leaked cluster is holding the regional quota the create is about to ask for.
Two deliberate choices:
- Failure is swallowed. A launch must not die because a cleanup read failed; the worst case of skipping the sweep is the status quo before it existed, while the worst case of raising is a run that never starts. Anything that goes wrong is logged and the create proceeds.
- It is an environment switch, not a config field.
SF_REAP_ON_LAUNCH=0turns it off. ARunConfigfield would change every run'srun_id— the id is a digest of the config — and a deployment-level cleanup policy has no business changing the identity of a run's results. Same reasoning asSF_SERVERLESS_DEPSliving on the infra object.
Returns the names it reclaimed, for the caller that wants to say so.