Repository navigation
api: cap each client's live sandboxes, counted from the pod reflector and a Redis pending set - #1172
Open
the-gigi-pplx wants to merge 1 commit into
Conversation
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
from
October 30, 2026 13:05
0717d96 to
a67d677
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-6977-carry-activating-client-id-label-sandbox-pods
branch
from
October 30, 2026 13:13
55f39ef to
13a3942
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
from
October 30, 2026 13:19
a67d677 to
e0e85e7
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-6977-carry-activating-client-id-label-sandbox-pods
branch
from
October 30, 2026 13:31
13a3942 to
6510c61
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
from
October 30, 2026 13:33
e0e85e7 to
38e4286
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-6977-carry-activating-client-id-label-sandbox-pods
branch
2 times, most recently
from
October 30, 2026 13:49
f4135d8 to
018a9f6
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
10 times, most recently
from
October 30, 2026 16:48
ddc161d to
9af1860
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-6977-carry-activating-client-id-label-sandbox-pods
branch
from
October 30, 2026 16:50
018a9f6 to
031d7bc
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
2 times, most recently
from
October 30, 2026 16:58
bf65c7f to
2a65564
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-6977-carry-activating-client-id-label-sandbox-pods
branch
2 times, most recently
from
October 30, 2026 17:09
52d8110 to
de0c507
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
from
October 30, 2026 17:20
2a65564 to
1730266
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-6977-carry-activating-client-id-label-sandbox-pods
branch
from
October 30, 2026 17:28
de0c507 to
85557fb
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
from
October 30, 2026 17:30
1730266 to
9ed7317
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
4 times, most recently
from
October 30, 2026 18:11
8e390d5 to
611bade
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-6977-carry-activating-client-id-label-sandbox-pods
branch
from
October 30, 2026 20:32
ca4a494 to
73df601
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
2 times, most recently
from
October 30, 2026 21:21
005bdca to
0e27d98
Compare
the-gigi-pplx
marked this pull request as ready for review
October 30, 2026 22:20
the-gigi-pplx
force-pushed
the
gigi/pla-6977-carry-activating-client-id-label-sandbox-pods
branch
3 times, most recently
from
November 1, 2026 05:45
26b1e9b to
9db802c
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
4 times, most recently
from
November 1, 2026 09:21
4e664f0 to
019ade5
Compare
shehan-pplx
approved these changes
Nov 2, 2026
shehan-pplx
left a comment
Member
There was a problem hiding this comment.
Reviewed by Shehan's Review Bot
the-gigi-pplx
force-pushed
the
gigi/pla-6977-carry-activating-client-id-label-sandbox-pods
branch
from
November 2, 2026 14:56
9db802c to
1a1939e
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
4 times, most recently
from
November 2, 2026 16:02
6ca30a6 to
c8b0d89
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-6977-carry-activating-client-id-label-sandbox-pods
branch
2 times, most recently
from
November 2, 2026 21:14
e3167bc to
fefa52e
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
2 times, most recently
from
November 2, 2026 21:23
dd2bef2 to
bab7d84
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-6977-carry-activating-client-id-label-sandbox-pods
branch
from
November 2, 2026 23:46
fefa52e to
26b1df0
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
from
November 3, 2026 00:38
bab7d84 to
ffd7ba7
Compare
…r and a Redis pending set
the-gigi-pplx
force-pushed
the
gigi/pla-7044-per-client-live-sandbox-cap-substrate-count
branch
from
November 3, 2026 00:49
ffd7ba7 to
3c9bda6
Compare
This branch has not been deployed
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.
Summary
Adds a per-client live cap: the most sandboxes with a live runtime one ECW client key may hold at once. It's built to the per-client admission RFC in PR #1072 (
docs/space/rfcs/2026-10-28-substrate-independent-client-admission.md): Decision 5 ("one count interface"), Decision 6 ("how the limits compose") and the "Per-client live cap" row of "What to keep, rework or drop". It ships off: with neither new variable set, nothing is capped and no Redis call is made.Counting (Decision 5).
Substrate::live_sandboxes_for_client(ids, activating, already_pending)is a synchronous cache read:kubernetes/client_live_pods.rs). It mapsspace.pplx.net/client-id→ sandbox → qualifying pods, and pending pods count; terminal and deleting pods don't, as in the network-slot index, so a pod stuck terminating on a lost node doesn't hold its client's slot. Nor does a drain's stopped re-placement pod (checkpointstopped-materialization), which runs no VM, so a re-placedstoppedsandbox counts like one whose pod ended with its VM. It's updated from the same watch events and rebuilt whole on a re-list, like the network-slot index. A cold reflector answersUnknown(PodCacheCold). It answersUnknown(PodCacheStale)from a watch error or the end of its stream until the next live event, and during a re-list until the rebuilt index is in. A watch that's merely quiet stays trusted: the watcher reconnects a watch that's idle past its timeout. While stale, the cap counts the pending set alone: a frozen index never causes a refusal, and admissions within one pending window stay under the cap.Unknown(Unsupported), and the cap admits.client_live_cap/live_pending.lua) records admissions the reflectors haven't seen yet, in Redis next to the existing buckets. It keeps one sorted set per client id, with per-attempt owner tokens, an id registry forunknown's count, and a sandbox → ids companion for teardown. Every key shares one hash tag. A check reads the pending members, counts the visible sandboxes not among them, then runs a reserve script at the same cutoff. The script refuses at the cap, asks for a redo when it would refuse on members the read missed (at most three passes), and refuses to decide on a cutoff older than the grace.LiveReservation. A background task renews it while the activation runs.keep()on success lets it lapse after 30 s, which covers the watch delay. Dropping it on any failure removes only that attempt's owner. A confirmed delete (or a node that had no such sandbox) and a suspend, which remove the sandbox's pods, forget its members; a failed delete leaves them to lapse.Where the cap runs (Decision 6). The
CapPolicythat PR #1068 stamps on every path now decides it:Reserved, before any cluster token is spent:createdorstoppedsandbox.ReserveAtClaim: the Kubernetes lease winner decides it. A fresh claim reserves before its charge and becomesReserved. A takeover of a lapsedrestoringlease becomesCheck, after a read-only pre-check when no reusable pod is visible, so a client at its cap doesn't pay a token on every takeover retry. The reservation rides with the detached wake leader, so a disconnected caller doesn't uncount a restore that's still running.Check:ensure_pod_existschecks on the branch that's about to create a pod, and before the retry path's cross-node re-drive. A refusal abandons the claim it created and returns a refused charge. The charge reaches it throughPodMaterializationOwnership::with_charge.start_execution's stale-snapshot cold start) run a read-only pre-check when the reflector shows no reusable pod (ready, not being deleted) for the sandbox, so a client at its cap doesn't spend a cluster token on every retry.Refusal. A
429with the cluster limiter's jitteredRetry-Afterand a message naming the key and cap. It carries no cluster-backpressure cause, so the async dispatcher retries it against its attempt budget.control_runtime_clientalready passes a429from its first call through, and its fallback wake is itself checked.Caps. There are two env defaults:
SPACE_API_CLIENT_LIVE_CAP_PER_CLIENTfor listed keys, andSPACE_API_CLIENT_LIVE_CAP_UNKNOWN, which every unlisted id shares. Unset or0means no cap, andnativeis never capped. The quotas flag gains alivemap (unknownsets the shared cap). An entry applies only while the env default for that key is set, and a0entry is ignored: the flag tunes and assigns the caps the environment enabled. Naming an id in the flag moves it fromunknownto its own key under the per-client default. When the flag stops resolving, its last valid value stands for 10 minutes (RFCKEY_SET_STALENESS), then the env allowlist alone applies. The key set is the env allowlist plus, while the per-client default is set, the flag'sliveids and itsoverridesids (the latter while the concurrency limiter isn'toff). So a flag entry can't move an id out from underunknown's cap into no cap. Each decision is counted insandbox.api.client_live_cap.decision, tagged with the check, decision, reason and resolved key. The cap counts only while the base'sSPACE_ACTIVATION_CLIENT_ATTRIBUTION_ENABLEDis on: with it off, pods carry no client label, and counting the pending set alone would cap a 30 s admission rate rather than live sandboxes, so every check admits. Every fail-open case has its own reason (misconfigured,store_unavailable,unsupported,pod_cache_cold,attribution_disabled), and so does the pending-only check of a stale cache (pod_cache_stale).Docs:
docs/configuration.md(variables, plus a "Live cap" section),docs/ecw.md, and thedirect.rsmodule doc.Validation
At this head, on a Linux devbox: workspace
cargo clippy --all-targets --all-features -D warningsis clean, and the affected tests at CI's opt-level 0 (client_live_cap,client_live_pods,recovery_charge, theensure_pod_existscap-policy cases, the access, ports and spaced paths,cluster_admission, lifecycle and delete paths, e2b) pass, and so does the rest ofpplx-space-api,pplx-space-metricsand the DB fixtures: the full workspace (11317 of 11318 unprivileged; the one failure, node-mgr'sstopped_unbound_snapshot_reaches_filesystem_without_allocating, and the privileged lane's six fs-verity,contained_launcherandspaced-guest-workerfailures are this devbox's environment, in crates this PR doesn't touch), against a real Redis and Postgres. With attribution off, the cap admits without counting (drop the guard):attribution_off_admits_without_countingfails. Nothing revives a lapsed member past the cap:a_lapsed_member_is_never_revived_past_the_capfails when a reserve renews against the read's cutoff instead of now, and when renew and swap ignore the member's expiry. CI's full run, Kind smoke included, passed on an earlier head; CI on this head covers the rest.The full-workspace and mutation results below are from an earlier head. On a Linux devbox:
cargo clippy --all-targets --all-features -D warnings: clean.cargo arch: no required findings.client_live_cap,client_live_pods,recovery_charge, theensure_pod_existscap-policy cases,sandbox_pod_view,pod_reflector,cluster_admission): 83 passed, against a real Redis and Postgres.run-rust-checks.sh tests-unprivileged: 10957 of 10958 passed.tests-privileged: 61 of 67 passed. Every failure is a test that fails on this kind of devbox atmainfor environment reasons, and CI's lanes cover them: node-mgrstopped_unbound_snapshot_reaches_filesystem_without_allocating, and in the privileged lane the fs-verity trio, twocontained_launcher"unsafe ancestors" tests andspaced-guest-worker::embedding.total = visible):the_pending_set_counts_admissions_the_reflector_has_not_seenfails.a_running_sandbox_is_never_refusedandthe_activating_sandbox_and_pending_ones_never_countfail.a_pending_sandbox_the_reflector_shows_counts_oncefails.Known(0)):direct_admits_as_unknownfails.ensure_pod_existschecks aCheckcharge (dropCheckfrom the policy match):a_pod_create_is_checked_against_the_live_cap_by_its_cap_policyfails.a_failed_attempt_never_uncounts_a_siblingfails.a_kept_member_lapses_after_its_ttl_or_when_forgottenfails.Not validated: a Kind or deployed smoke run, so the cap is untested end to end through ECW handlers and the dispatcher. The create and lifecycle reserve sites have no controller-level test.
Severity and risk
Severity: does not apply. This is a new guardrail, off by default.
Risk: with no cap set, the new code paths return before any Redis call. The changes outside the cap are a new reflector index on the watch path (a few map updates per pod event), the
RecoveryChargeconstructors now carrying the admission handle, and teardown'sforget(a no-op without a cap). With a cap set, the main risk is a wrong refusal, a429on a create or wake under the cap. Every uncertain count fails open, and a refused attempt removes only its own pending member. To roll back, unset both variables. Nothing persistent is written outside Redis keys that expire on their own.Stack
Stacked on PR #1068, which provides
ActivationClient, the record, the pod label, the attribution switch that gates both, and theChargeState/CapPolicysplit. This PR's base is that branch. Merging waits for the RFC in PR #1072.Not covered
client_live_capfrom the same inputs (env allowlist, quotas flag) and should move onto PR [api] tag limiter decisions and reconnect metrics with the bounded client id #1058's resolver once both land. Until then, a non-label allowlist entry is ignored here rather than warned about.KEY_SET_STALENESS(10 minutes).SPACE_API_CLIENT_QUOTAS_FLAG_REQUIRED, and counting each unlisted id on its own while degraded, are resolver behavior, so they're left to that move. Past the 10 minutes, an outage falls back to the env allowlist, as the RFC specifies when the flag isn't declared required: flag-listed clients count underunknownuntil the flag resolves again.ActivationRejectionKind::ClientAtLiveCap. The kind comes with PR api: refuse a cold create with a 503 when every eligible node's network slots are held #1061, which isn't in this base. A cap refusal is a429without the backpressure cause, which the dispatcher already retries against the attempt budget, the behavior the RFC gives the new kind.Unknown(Unsupported), so the outcome (admit, before any Redis round trip) is the same. Closing the gap also needs a Direct point that reserves for aReserveAtClaimcharge and checks aCheckone, withRecoveryCharge's live-cap methods built without thekubernetesfeature. It's a row in thedirect.rsgap table. Calling it there would change Direct, which belongs in its own PR.Checkrefusal fromensure_pod_existsduring a lease winner's wake drive runs the wake's existing failure cleanup, which reverts the restore claim, rather than a separate refused-charge release. The outcome is the same, and the refusal reaches the caller as the429.ensure_pod_existsrefuses it. Matching only the current generation would instead risk refusing a running sandbox.SPACE_API_CLIENT_LIVE_CAP_*variables have to be set there too;docs/configuration.mdsays so.unknown. Such a client can be refused a connect to a sandbox whose pod isRunningbut not yet ready (the pre-checks treat only a ready pod, or aPendingone the scheduler hasn't marked Unschedulable, as reusable, so a pod left on a lost node or an unschedulable pinned restore doesn't skip them). The same holds for a pod another replica created that this replica's reflector hasn't seen yet: the pre-check, andensure_pod_exists's own check before its create, both run before the create's conflict path would find it. A client at or under its cap isn't affected.docs/configuration.mdsays to turn attribution on first.429for up to 30 s once a client's failed attempts fill its cap. On the sharedunknowncap one unlisted client's failures do that for every unlisted client. Releasing at once on failures the substrate knows came before the pod create needs that signal plumbed out of the substrate, which this PR doesn't do.a_reservation_whose_pod_may_exist_lapses_when_droppedcovers the reservation side.overridesids through the concurrency limiter's own decode, so the two agree while the flag resolves. When it stops resolving, the cap keeps the last good value forKEY_SET_STALENESS(10 minutes) and the limiter drops it at once, so for that long an id listed only inoverridesis its own key here and unspecified there.POD_CACHE_STALENESSof quiet; that isn't implemented, because a quiet watch on an idle namespace is healthy, and kube-runtime doesn't surface bookmarks to the event stream as a liveness signal. Bounding it means a shorter watcher timeout or a heartbeat.AI assistance: Claude Code wrote the code, tests and this description from the RFC.