Repository navigation
cluster-controller: gauge sandboxes wedged in a transient lifecycle - #1332
Merged
Merged
Conversation
|
Thanks for opening this pull request! A SPACE maintainer will take a look. CONTRIBUTING.md explains how we review outside contributions. |
ac-pplx
force-pushed
the
ac/lifecycle-stuck-gauge
branch
from
November 2, 2026 21:02
6e1be55 to
63715db
Compare
ac-pplx
force-pushed
the
ac/lifecycle-stuck-gauge
branch
from
November 2, 2026 21:15
63715db to
c988796
Compare
ac-pplx
force-pushed
the
ac/lifecycle-stuck-gauge
branch
from
November 2, 2026 23:25
c988796 to
a305208
Compare
This was referenced Nov 3, 2026
Merged
ac-pplx
marked this pull request as ready for review
November 3, 2026 03:51
ac-pplx
marked this pull request as draft
November 3, 2026 03:52
ac-pplx
force-pushed
the
ac/lifecycle-stuck-gauge
branch
from
November 3, 2026 06:00
a305208 to
e90aa28
Compare
ac-pplx
marked this pull request as ready for review
November 3, 2026 06:29
nikhilbirmiwal-pplx
approved these changes
Nov 3, 2026
Nothing reported how many sandboxes sit in an in-progress lifecycle, or
for how long. The elected leader now reads that census from the
sandboxes rows once a minute and emits two gauges:
- sandbox.transient_lifecycle.rows{lifecycle, age}: rows per age bucket
(lt_1m, 1m_10m, 10m_1h, 1h_6h, 6h_1d, ge_1d);
- sandbox.transient_lifecycle.oldest_age_seconds{lifecycle}.
Age is NOW() - lifecycle_changed_at on the database clock, which the
lifecycle trigger stamps only on a lifecycle change, so a retry within a
lifecycle doesn't reset it, and leaving and re-entering a transient
lifecycle starts it over. The lifecycle set is SandboxLifecycle::all()
filtered by is_transient(), deleting included. Every (lifecycle, age)
pair is emitted on every tick, zeros included; a failed read logs and
emits neither gauge for that tick. A third metric,
sandbox.transient_lifecycle.loop_latency_ms, times every tick.
The read is one statement in the new pplx-space-sandbox-store `stuck`
module, with the lifecycle list and bucket bounds bound, on an index on
lifecycle. The loop runs from run_reconcilers, so only the lease holder
reads the census (with leader election off, the controller's single
replica does), on both substrates, whatever the transient-lifecycle
sweeper is set to. After a leader change the previous leader's last
values can still be queried, so the docs say to combine replicas with
max, not sum. No configuration, no migration, no write.
ac-pplx
force-pushed
the
ac/lifecycle-stuck-gauge
branch
from
November 3, 2026 21:25
e90aa28 to
77fbae1
Compare
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
SPACE had no gauge of how many sandboxes are stuck in an in-progress lifecycle, or for how long. After this change, the cluster controller's elected leader counts them from the
sandboxesrows once a minute and emits:sandbox.transient_lifecycle.rows, tagslifecycleandage(lt_1m,1m_10m,10m_1h,1h_6h,6h_1d,ge_1d)sandbox.transient_lifecycle.oldest_age_seconds, taglifecycle(0 when there are no rows)sandbox.transient_lifecycle.loop_latency_ms, the time each tick takesIn Prometheus:
space_sandbox_transient_lifecycle_{rows,oldest_age_seconds,loop_latency_milliseconds}.NOW() - lifecycle_changed_aton the database clock. The lifecycle trigger sets that column only when the lifecycle changes, so arestoring -> restoringretry or anupdated_atbump doesn't reset it.suspendedand retrying looks young on every attempt. A wake refused before it changes the row, and a row stuck in a settled lifecycle such asstarted, are not counted.SandboxLifecycle::all()filtered byis_transient(), includingdeleting. Non-canonical lifecycle strings are not counted.warnand emits neither gauge for that tick (loop_latency_msstill times it). It never reports zeros it didn't read.max, notsum: after a leader change the previous leader's last values can still be queried.Pieces:
pplx-space-sandbox-storemodulestuck.transient_lifecycle_agesis one executor-generic statement with asql!locator. It binds the lifecycle list and bucket bounds and reads an index onlifecycle, not the table. Typed result, smallthiserrorerror.pplx_space_metrics::sandbox::transient_lifecycle.run_reconcilerson a child token. It is lease-gated when leader election is on (with it off, the controller assumes a single replica), runs on both substrates, and doesn't depend onSPACE_TRANSIENT_LIFECYCLE_SWEEPER_ENABLED. It runs every 60 s; there is no setting.docs/observability.md.No migration, no write, no setting.
sandbox_writesand the archcheck baseline are unchanged.Validation
At this head:
cargo fmt --all -- --check,scripts/check-rust-error-policy.sh,cargo clippy --workspace --locked --all-targets --all-features -- -D warnings: clean.cargo arch: 0 required findings. The 110 advisory findings all predate this PR and none touches these files.SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --all-features -p pplx-space-sandbox-store -p pplx-space-metrics -p pplx-space-cluster-controller-server: 1263 passed, 0 skipped.deletedand non-canonical rows are excluded; unordered bounds are refused.EXPLAINtest on 20k settled rows with distinctlifecycle_changed_at: the plan isBitmap Index Scan on idx_sandboxes_lifecyclewith noSeq Scan. The test accepts either index onlifecycle. Mutation:lower(lifecycle) = ANY($1)gives aSeq Scanand fails the test.updated_atinstead oflifecycle_changed_atfailssame_lifecycle_writes_keep_the_ageandrows_land_in_their_age_bucket.Kind, fresh cluster,
SPACE_KIND_TELEMETRY_MODE=otel(bring-up 602 s, 2 controller replicas):rowsand 9oldest_age_secondsseries, all 0, all from the lease holder. The standby emitted none.restoringby SQL showed inrestoring/lt_1m(oldest 29.8 s), then1m_10m(89.8 s → 209.8 s). It dropped to 0 one tick after the row was restored. The sandbox was then deleted.SKIP_BOOTSTRAP=1 scripts/tests/smoke-kind-typed.sh all, 3831 s: ECW 89 passed, 0 failed, 2 inconclusive; native 35 passed. The inconclusiveecw.cold_create_environmentandecw.warm_pool_environmentneed CI's admin-token file and cold-receipts directory; both passed on rerun with those supplied. The census logged no failed reads.The Kind runs above used a build whose controller and store code matches this head; the later changes touch only docs, comments and one test.
Not validated: the Direct substrate lane and the Datadog/DogStatsD path.
Severity and risk
Severity: does not apply (new visibility only).
Risk: one indexed aggregate a minute on the leader, plus 63 new gauge series and one latency histogram. Behaviour is unchanged. Roll back by reverting the commit.
Rollout
Old and new controllers can run side by side safely. Only the lease holder runs the census, so during a rollout the series come from whichever version holds the lease. Older versions emit nothing.
AI assistance: an AI agent (Claude Code) wrote the code, the tests and this description, and ran the validation.
After the wave-2 restack
This PR is one layer of a 10-PR stack, restacked onto main
c5fdf0c7(which includes #826) and then rebased mechanically onto42a99002, which changed only baseline context lines, in order #1329 → #1336 → #1338 → #1339 → #1342 → #1347 → #1333 → #1335 → #1328 → #1332. The validation earlier in this description ran at pre-restack heads; this layer's head is77fbae1e.git range-diffshows=). For every file [cluster-controller] re-place an evacuated stopped sandbox as stopped on another node, materialized by the shim without a VM #826 and this stack share, each SQL line [cluster-controller] re-place an evacuated stopped sandbox as stopped on another node, materialized by the shim without a VM #826 added was checked to be present verbatim at the top head, in place or in the store module it moved to.cargo fmt --all -- --check,cargo check --workspace --all-targets --all-featuresandcargo arch(0 required findings, with main's layers and table-owner rules), and the ratchet and caps this layer changes are recounted exactly.e90aa284before that rebase; nextest passed 4370 tests again at77fbae1e):cargo clippy --workspace --locked --all-targets --all-features -- -D warningsis clean, andSPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --all-featurespassed 4355 tests across the store, api, internal-api server, cluster-controller server, metrics, db-test-fixtures, common-core, common-svc and portability crates, [cluster-controller] re-place an evacuated stopped sandbox as stopped on another node, materialized by the shim without a VM #826's suites included.scripts/tests/smoke-kind-typed.sh allgave ECW 91/91 and native 42/42, including [cluster-controller] re-place an evacuated stopped sandbox as stopped on another node, materialized by the shim without a VM #826'snative.forced_drain_re_places_stopped_sandboxand its re-drain and restarted variants. The Direct substrate lane'sallgave ECW 51/51 and native 27/27. No flakes and no reruns.+/-lines and its SQL string literals match its reviewed head (range-diff plus a literal multiset compare).