Skip to content

fix(sandbox): stop counting pod-less sandboxes against the running limit - #1555

Open
0xAlcibiades wants to merge 1 commit into
paradigmxyz:mainfrom
0xAlcibiades:fix/retire-pod-less-sandboxes
Open

fix(sandbox): stop counting pod-less sandboxes against the running limit#1555
0xAlcibiades wants to merge 1 commit into
paradigmxyz:mainfrom
0xAlcibiades:fix/retire-pod-less-sandboxes

Conversation

@0xAlcibiades

Copy link
Copy Markdown

Closes #1554.

A Sandbox CR with spec.replicas: 1 and no backing Pod maps to SandboxStatus::Created, which consumes a running slot. Nothing can return that slot: the admission controller's stale-retirement arm fires only on Stopped/Gone/NotFound, idle pausing needs a DB-idle-eligible session, the reaper only stops past-max-lifetime sandboxes, and the cleanup worker only reaps sandboxes no session references. So the count drifts permanently above reality, and once it hits max_running every cold_create and resume is refused, the execution is marked failed with no retry, and nothing surfaces on the thread that triggered it.

The fix

A portable SandboxStatus::Vacant — the record asks for a process and the backend has none for it. That is a different state from Created, where a process exists and is still coming up, and from Suspended, where nothing is asking for one. Today Created means both, which is exactly why the count and the reclamation paths cannot tell them apart.

Because status_consumes_running_slot lists the statuses that do count rather than the ones that do not, adding the variant is what fixes the accounting — in the admission path and in the warm pool, whose replenishment is gated on the same count and so was blocked from refilling by the same phantom.

A second cleanup-worker arm that pauses vacant sandboxes. Not counting them is the capacity fix; this is the other half. Left alone the CR keeps requesting a replica that never arrives, so reconciliation reports drift on every pass and it never settles.

It pauses and never stops. replicas: 0 keeps the CR, its state volume and its proxy, so the owning session resumes with its workspace intact; stopping would delete the volume and every uncommitted change on it. That holds whether or not a session still references the sandbox — an unreferenced one reads Suspended after this arm runs, and the existing orphan arm can then reap it on its usual two passes.

Retirement needs two consecutive sweeps, mirroring pending_orphans, because a fresh create is briefly vacant between the CR landing and its Pod being scheduled.

Both sandbox arms now share one list_observed call. They do different things to a sandbox, so they should not disagree about what they saw.

The other Vacant arms

The compiler required a decision at each of these; recording the reasoning since none is forced:

Site Behaviour Why
ReconcilePlan, desired Running ReportDrift(MissingWhileRunning) The record wants a process and there is none. That is drift, not a state to wait out.
ReconcilePlan, desired Suspended Pause Not a no-op: the Pod is gone but replicas is still 1, so pausing is the repair.
ReconcilePlan, desired Stopped Stop Same as every other non-terminal status.
pause_capacity_candidate falls through to its pause As above, and it frees the slot at admission time rather than waiting for a sweep.
record_idle_pause falls through to its pause Same.
existing_sandbox_action ResumeOrReplace The state volume outlived the Pod, so the workspace is still there to resume into.

Tests

  • maps_agent_sandbox_replicas_and_pod_readiness_to_status(1, None) is now Vacant.
  • vacant_sandboxes_do_not_consume_running_slots in both centaur-session-runtime and centaur-sandbox-manager::warm_pool — the bug in one assertion each.
  • vacant_retire_requires_two_consecutive_passes, a_sandbox_that_gets_its_pod_back_is_not_retired, only_vacant_sandboxes_are_retired — the new arm.
  • vacant_sandboxes_are_left_to_the_retire_arm — the orphan arm does not stop one, so the state volume survives.
  • vacant_sandbox_resumes_rather_than_replaces.

cargo test --workspace, cargo clippy --workspace --all-targets and cargo fmt --all --check are clean.

Scope

Capacity accounting and pod-less retirement only. Evicting paused sandboxes under pressure, and queueing instead of dropping at the cap, are separate problems — a turn refused at admission still fails silently with nothing on the originating surface, which this does not change.

SandboxStatus is public, so the new variant is a breaking change for anything matching on it exhaustively outside this workspace. Happy to gate it differently if you would rather not take that.

A Sandbox CR with `spec.replicas: 1` and no backing Pod mapped to
`SandboxStatus::Created`, which consumes a running slot. Nothing could ever
return that slot:

- The admission controller's stale-retirement arm only fires on
  `Stopped`/`Gone`/`NotFound`, so it never sees a CR reporting `Created`.
- Idle pausing needs a DB-idle-eligible session. A session whose execution
  rows are stuck non-terminal is never eligible.
- The reaper only stops past-max-lifetime sandboxes, and the cleanup worker
  only reaps sandboxes no session references.

So the observed running count sits permanently one above reality. Once it
reaches the cap, every `cold_create` and `resume` is refused with "sandbox
running capacity exceeded", the execution is marked failed with no retry, and
the originating thread receives nothing. The fleet stays saturated until an
operator deletes the CR by hand.

The fix is a portable `SandboxStatus::Vacant`: the record asks for a process
and the backend has none for it. That is a different thing from `Created`,
where a process exists and is still coming up, and from `Suspended`, where
nothing is asking for one. Because `status_consumes_running_slot` lists the
statuses that count rather than the ones that do not, adding the variant is
what fixes the accounting, in both the admission path and the warm pool
(replenishment is gated on the same count, so a phantom blocked refills too).

The cleanup worker gets a second arm that pauses vacant sandboxes, so the CR
stops asking for a replica that never arrives instead of reporting drift on
every reconcile. It pauses and never stops: `replicas: 0` keeps the CR, its
state volume and its proxy, so the owning session resumes with its workspace
intact, while stopping would delete the volume and every uncommitted change
on it. Retirement needs two consecutive sweeps, mirroring the existing orphan
arm, because a fresh create is briefly vacant between the CR landing and its
pod being scheduled.

Both sandbox arms now share one `list_observed` call. They do different
things to a sandbox, so they should not disagree about what they saw.

Elsewhere `Vacant` is handled where the compiler required a decision:
reconciliation reports drift against a Running desired state and pauses
against a Suspended one; admission and the idle backstop fall through to
their pause, which is the repair; and `existing_sandbox_action` resumes
rather than replaces, because the state volume outlived the pod.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pod-less Sandbox CRs permanently consume running-capacity slots

1 participant