Repository navigation
[cluster-controller] add the runtime_upgrade reconciler for sandbox upgrade campaigns - #1251
Draft
the-gigi-pplx wants to merge 1 commit into
Draft
the-gigi-pplx wants to merge 1 commit into
the-gigi-pplx wants to merge 1 commit into
Conversation
the-gigi-pplx
force-pushed
the
gigi/pla-6906-runtime-upgrade-reconciler
branch
10 times, most recently
from
November 2, 2026 23:25
2279de8 to
70e1483
Compare
the-gigi-pplx
changed the base branch from
main
to
gigi/pla-7133-stay-stopped-runtime-upgrade
November 2, 2026 23:25
the-gigi-pplx
force-pushed
the
gigi/pla-6906-runtime-upgrade-reconciler
branch
2 times, most recently
from
November 3, 2026 00:13
65a18f3 to
3ee7c88
Compare
the-gigi-pplx
changed the base branch from
gigi/pla-7133-stay-stopped-runtime-upgrade
to
main
November 3, 2026 00:13
the-gigi-pplx
force-pushed
the
gigi/pla-6906-runtime-upgrade-reconciler
branch
from
November 4, 2026 21:54
3ee7c88 to
9ab568d
Compare
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true |
the-gigi-pplx
force-pushed
the
gigi/pla-6906-runtime-upgrade-reconciler
branch
3 times, most recently
from
November 5, 2026 23:42
7d64742 to
7d8f76f
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-6906-runtime-upgrade-reconciler
branch
5 times, most recently
from
November 7, 2026 05:53
fa89210 to
bdb0634
Compare
the-gigi-pplx
force-pushed
the
gigi/pla-6906-runtime-upgrade-reconciler
branch
from
November 7, 2026 05:55
bdb0634 to
f1ce2d7
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 the cluster controller's
runtime_upgradereconciler, which drives sandbox upgrade campaigns: it finds the sandboxes on a campaign's source environment that can move to its target, the cluster default, and hands them one at a time to an upgrade dispatcher under the campaign's per-node and per-cluster caps. It pauses a campaign that reaches its failure budget, re-drives items whose worker died, and completes the campaign once its sandboxes are drained. It drives sandboxes through a dispatcher trait and is tested against a fake. It ships disabled and isn't started yet: its production dispatcher, over the stay-stopped upgrade in #1322, and its spawn fromrun_reconcilerscome once that PR has the node-side half it now needs (see Dependencies).Terms
runtime_environment_policy,runtime_environment_states) names a default and gives each environment a state:allowed,deprecated(still executes, but should move) orblocked.runtime_upgrade_campaignsnaming the environment to move off (from), the target (to), the kind (sandboxesortemplates), its pacing (max_in_flight_per_node,max_in_flight_per_cluster), afailure_budget,include_running, and a phase:canary,rolling,paused,completedoraborted.runtime_upgrade_items, one sandbox (or template release) in a campaign, in phasepending,in_flight,done,failedorskipped.in_flightitem'slease_tokenandlease_expires_at. The schema requires both exactly while the item is in flight; an outcome is recorded only under the matching token.decideincrates/l40_api/api/src/controllers/substrate/runtime_upgrade.rs) would find it.The problem
PR #981 added the campaign and item tables, and PR #1015 decides an upgrade when a stopped sandbox starts or a suspended one resumes. Nothing moves a sandbox that isn't activated, so an environment an operator deprecates or blocks keeps its stopped sandboxes until each happens to start. Retiring an environment needs its population at zero, and nothing drives it there.
Nothing reads or writes the campaign tables yet, either. The schema leaves the store a set of obligations: write
finished_atandlease_expires_atfrom the database clock, bound lease length (a live lease blocks the down migration), fence outcomes by lease token, keepsubject_kindconsistent with the campaign's kind, truncatelast_errorto 4096 characters, and lock campaigns before items, in the down migration's order.How this fixes it
crates/l30_storage/runtime/upgrade/campaign/store), the tables' first reader and writer. Executor-generic verbs with typed rows and closed vocabularies:sandbox_candidates: one page, in id order, of the substrate's published sandboxes in the given lifecycles that the campaign has no item for, with the execution each last published. A record bound to another environment is left out in SQL; bound and unbound records that could be on the source are returned for attribution.insert_pending_sandboxes: records items only into an unfinished sandbox campaign, which it locks for the insert. A skipped item found again is made pending while it has attempts left, keeping its count; items in other phases are left alone.lease_sandbox_items: owns the pacing. Under the campaign row lock it leases pending items up to the cluster cap, counting items already in flight, and up to the per-node cap by the node that last published each sandbox. Acanaryleases one item at a time and nothing once one is done, so it waits for an operator's resume. Leases are clamped to an hour and expire on the database clock. It leases an item whose sandbox is published on a node only while that node's status is reachable and evidence of its environments; an item on any other node stays pending, which holds the campaign open. An item with no published node is leased, and the worker skips it, since it's on neither end of the campaign.renew_leaseandfinish_item: act only under the item's current token, so a worker that lost its lease records nothing. A renewal also needs the lease unexpired on the database clock; an outcome still records under an expired lease nobody has re-driven, since nobody else holds the item.redrive_expired: returns lapsed in-flight items to pending, or to failed once they've been leased the maximum number of times.create_campaign: starts a campaign, refusing while an unfinished campaign of the same kind moves the same source, or a finished one (aborted, say) still has an upgrade in flight under a live lease, so two campaigns never act on one sandbox. Creators for one kind and source serialize on a transaction-scoped advisory lock and look for an unfinished campaign after taking it; no constraint or migration.pause_if_over_budgetandcomplete_if_drained: guarded phase changes that never overwrite a phase an operator wrote first. Completion locks the campaign before it reads the items, so it can't complete a campaign under items inserted meanwhile. It refuses while any item is pending or in flight, while an item with attempts left is skipped for a sandbox that's still a candidate (the substrate's, in a lifecycle the passes scan, published, and not bound elsewhere, assandbox_candidatesreads it), and while the campaign is over its failure budget, which leaves it to pause. The counts come from one snapshot after the lock, so an outcome committed late is either visible and judged by its sandbox's current state, or still in flight. A skipped sandbox that was deleted, started under a stopped-only campaign, or moved elsewhere doesn't hold the campaign open.retire_skipped_sandboxes: uses up the attempts of skipped items whose unbound execution the catalog now attributes elsewhere, so they neither come back as candidates nor hold completion.crates/l40_api/cluster/controller/server/src/runtime_upgrade/). Each tick, for each unfinished sandbox campaign, it re-drives lapsed leases and pauses a campaign at its failure budget. Then it examines one page of candidates, completes a drained campaign, and leases and dispatches items.decide, for a sandbox on the campaign's source, says what the campaign does. A campaign is held while its target isn't the cluster default (the node-side upgrade only boots under the default) or its source isallowedagain. Its sandboxes are stranded when no certified edge reaches the target: they're counted, not recorded, nothing is leased, and the campaign stays open until an edge is certified or an operator aborts it. Each candidate's published execution is attributed with PR [api] decide the runtime upgrade at resume and start, and stage pod-less sandboxes on the node #1015'sattribute, so a campaign moves exactly what a start would move, and, as at start, a publish that carried no execution is attributed nowhere.include_runningand the move's authorization accepts memory loss, which only a block of the source gives. A deprecated environment still executes, so its residents are left alone.SandboxUpgradeDispatcherhas two calls: upgrade a stopped sandbox to the campaign's target, leaving it stopped, and stop a started or paused one so it can take that path. The stop names the campaign's source, and its contract is a guarded transition: it declines unless the sandbox is still started or paused on that source, so a sandbox restarted elsewhere after the reconciler's read keeps running, and its item is skipped. The upgrade decides afresh where it acts, as a start would, and a move to anything but the campaign's target (the default changed since the lease) isn't recorded as the campaign's. Just before the upgrade, the worker re-checks where the sandbox is: one started and stopped again under another environment since it was found is skipped, while one on the target still goes to the upgrade, which finishes an earlier lease's upgrade that published the target but didn't finish its backup or retire. The reconciler re-reads each leased sandbox first: a deleted one is skipped, one in a transitional lifecycle is retried, and one that started since it was found is skipped unless the campaign setsinclude_running.SPACE_RUNTIME_UPGRADE_ITEM_DEADLINE_SECS(30 minutes by default, from 15 minutes to a day) after its lease was requested is dropped, however many renewals it spans. The item fails, counting against the budget, and its lease is released, so a dispatch that never returns can't hold its slot. The deadline is per lease: a retried item gets a new one on its next lease, and its five attempts bound the total.claim_renewal_interval). When the lease is lost, or no renewal succeeds by its end (a hung renewal included), the work is dropped and nothing is recorded; nothing it waits on, sleep or renewal, runs past the lease's end.crates/l30_storage/node/status/store),pplx-space-node-status-store, which declaresnode_status_snapshotsas its state. Its writers, the internal API's node status handler and the Direct substrate, stay where they are for now. It holdsenvironment_evidence(alias), the one rule for when a node's published environments count as evidence, as a whole SQL predicate a caller builds into its statement once, aspplx-space-sandbox-portabilitydoes: the latest execution inventory iscomplete, the snapshot is fresh bystale_after, and the environment arrays were received within the node's freshness window (stale_after - observed_at). It's never NULL, and it leaves reachability to the caller. The campaign lease is its first caller; placement, which today checks its own freshness columns inline, can move onto it.space-data-store-development) listsenvironment_evidencebeside the portability and residency predicates as a fragment interpolated whole rather than re-spelled.SPACE_RUNTIME_UPGRADE_RECONCILER_ENABLED(the shared flag vocabulary; unset or anything unrecognized is off) gates the loop. Off, it waits for shutdown and never touches the campaign tables.crates/l40_api/cluster/controller/server/src/metrics/runtime_upgrade.rs, beside the controller's other families): gauges for unfinished campaigns by kind and phase, each campaign's items by phase, and the stranded count of its last full pass (sandboxes on the source it found unrecorded, plus pending items), every combination emitted including zeros; counters for item outcomes (including lost leases), re-drives, policy holds, and auto-pauses. Their tag values aremetric_vocabulary!enums.list_unretired_entries, and the parsing of a recorded execution intoRecordedExecution::from_record. The start-time decision's store and the reconciler both use them.Template campaigns are listed and reported but not driven: moving a template release needs the node-side template caches keyed by environment first. The start-time and resume-time upgrade is unchanged.
Validation
On a Linux x86_64 build host, at this head on
85b68f39d:run-rust-checks.sh clippyandisolated-cratespassed, and so didcargo archagainstmain's baseline and architecture (no required findings; the advisorylayers.policy_changedones are the store grants under Docs).runtime_upgradeandenv_configtest in the API and the cluster controller), andcheck-test-durations.pypassed: no test of this change takes 2 s.run-rust-checks.sh tests-unprivileged: 12,538 of 12,538 nextest tests passed, plus doctests and feature isolation.run-rust-checks.sh tests-privileged, as root insidewith-nocow-test-filesystem.sh: 70 of 76 passed. The six failures come from the build host: threenode-runtime-corefs-verity tests (no fs-verity in the host kernel), twonode-runtime-supervisorcontained_launcher::linuxtests (the checkout's ancestors aren't root-owned), andspaced-guest-workerembedding(it needs a private cgroup namespace). CI's privileged lane runs them.New coverage, all against Postgres:
node/status/store/src/tests.rs):a_snapshot_is_evidence_only_while_every_condition_holds, across a complete, fresh snapshot (reachable or not), no inventory report, an incomplete one, a stale snapshot, one whose arrays came after its observation, no arrays, and arrays inside and past a window measured fromobserved_at; every case decodes into a plainbool, so the predicate is never NULL.runtime/upgrade/campaign/store/src/tests.rs):only_items_on_reachable_nodes_with_environment_evidence_are_leased(an unreachable node, a stale one and one with no status get nothing, an unpublished sandbox is leased, and the scan limit counts only what may be leased);a_gated_node_is_leased_from_once_it_qualifies_again; a verb that fails after locking the campaign releases the lock on the connection it held; a skipped item with attempts left is a candidate again and is made pending with its count kept, while one out of attempts, and done, failed and pending items, are left alone; a release gives its lease's attempt back and a retry keeps it; an expired lease isn't renewed but still records its outcome;a_skipped_sandbox_that_is_still_a_candidate_holds_completion: a skipped candidate holds it (a non-stringenvironmentincluded), and one out of attempts, retired, bound elsewhere, deleted, started under a stopped-only scan, of another substrate, or unpublished doesn't, while a started one does under a scan that takes running sandboxes; retirement leaves other sandboxes, phases and campaigns alone; a campaign over its budget neither leases nor completes;one_unfinished_campaign_per_kind_and_source,a_finished_campaign_holds_its_source_while_an_upgrade_is_in_flight, andof_two_concurrent_creates_for_one_source_exactly_one_succeeds, which holds the first create's transaction open across the second; campaign validation, including a per-node cap equal to the cluster cap; candidates bound to another environment left out, and a non-string environment treated as unbound; the cluster cap counting items already in flight; the per-node cap, and an unpublished sandbox counting toward the cluster cap only;subject_sandboxreading a published, an unpublished and a missing sandbox; the canary; nothing leased from a paused or template campaign; leases on the database clock and clamped; renewals and outcomes only under the current token; lapsed leases re-driven until their attempts run out; the budget pause and that it leaves an operator's phase alone; completion only when rolling and drained, including with a connection holding the campaign across refused calls; candidate scoping, paging and exclusion; error truncation.runtime_upgrade/tests.rs,tests/leases.rs,tests/disabled.rs), against a fake dispatcher that records concurrency:include_runningon a blocked source;a_plan_that_changes_mid_pass_restarts_the_pass: an edge certified mid-pass doesn't let the pass complete the campaign without the sandboxes it counted as stranded earlier;a_sandbox_the_node_keeps_declining_is_dispatched_a_bounded_number_of_times;a_sandbox_declined_once_on_a_multi_page_pass_is_moved_before_completion;a_catalog_change_mid_pass_restarts_the_pass;a_campaign_completes_only_when_a_pass_ends_not_partway_through_the_next: a sandbox that stops after a pass ended, behind the next pass's first page, is still upgraded before the campaign completes;a_sandbox_skipped_because_it_had_started_is_upgraded_once_it_stops;a_failed_scan_after_a_pass_ended_doesnt_complete_the_campaign: an insert that fails on the tick after a pass ended leaves the campaign rolling;a_declined_sandbox_is_found_again_and_a_deleted_one_doesnt_hold_completion: a declined sandbox seeded beside a deleted one is found again and moved, and the campaign then completes with the deleted one's item still skipped;a_skipped_sandbox_the_catalog_attributes_elsewhere_is_retired_not_waited_on;a_running_sandbox_is_stopped_only_while_it_and_the_policy_still_call_for_it: aninclude_runningitem whose sandbox a start has moved onto the target, and one whose source is only deprecated now, are skipped without a stop;a_stop_the_substrate_declines_skips_the_item_without_an_upgrade;a_dispatch_that_never_returns_fails_at_the_deadline: the item fails with its lease released, and the failure pauses a campaign with a budget of one;a_publish_without_an_execution_is_attributed_nowhere: with the legacy setting off and a source on the initial Firecracker versions, an empty record is neither found nor upgraded;an_unreachable_node_with_fresh_evidence_gets_no_dispatch: the item stays pending across ticks and the campaign stays rolling;a_stopped_sandbox_goes_to_the_upgrade_only_on_the_campaigns_source_or_target: one on another environment is skipped, and one on the target is handed to the upgrade;a_disabled_reconciler_never_touches_the_campaign_tables: with both tables heldACCESS EXCLUSIVEby another transaction, the disabled loop leaves no lock waiter, while the enabled loop's first read waits on the lock.Mutation check, on the store and reconciler code of this head (the store's and most of the reconciler's before the move to the layer directories, which didn't change their code; the worker's lease and dispatch code again after the lease tests' rescale): cargo-mutants over the campaign store (82 caught, 13 unviable) and over the reconciler module (93 caught, 32 unviable, counting the held-back dispatcher's mutants), over the worker after the rescale (20 caught, 8 unviable), the node evidence predicate's three conditions and its never-NULL default, the lease gate's reachable, evidence and unpublished-node clauses, the stranded count's pending items, the no-evidence attribution guard, and the draining refusal, each mutated by hand and each caught, and each of 51 SQL predicate guards mutated by hand. The survivors that later test additions cover (the subject read, the stranded campaign's lease guard, completion's string-environment check, retirement's sandbox list, the worker's source-or-target check, and a boot-timeout constant since held back) were rerun by hand and are caught. Every remaining survivor is equivalent:
phasepredicates inrenew_lease,finish_itemandredrive_expired, which the schema'sruntime_upgrade_items_lease_phase_checkalready implies through the token or expiry they also test;UPDATE'sphase = pendingguard, since only the lease verb moves an item out of pending, under the campaign lock;subject_kind = sandbox, since no verb writes a template item into a sandbox campaign.Not validated: any run on a Kind or deployed cluster. The reconciler isn't started anywhere yet, and a campaign also needs certified edges in
runtime_environmentsand an admin route to create it.Dependencies
SandboxUpgradeDispatcherover it (an upgrade to the campaign's target is done, the operation's final refusals are skips, every other error is retried, every stop is declined) and spawns the loop fromrun_reconcilersfor both substrates.Not covered
:resumedoes with failed items already counted against the budget is that route's decision.include_running. The production dispatcher declines every stop, so they're left running and their items skipped. A guarded stop, which stops a sandbox only while it's still on the campaign's source, is a follow-up change; once it exists they'll be stopped, upgraded and left stopped, not restarted.canary, where one item is leased at a time in subject order, such a sandbox holds the canary on it too.sandbox_observed_statesrow is gone) aren't candidates; they're left to the start-time upgrade.sandboxesandsandbox_observed_statesfrom the campaign store rather than through their own stores, since it needs them joined with the campaign's items.Severity and risk
Severity: does not apply. This adds a capability that ships disabled.
Risk: low. Nothing starts the reconciler, and enabled it does nothing without a campaign, which nothing creates yet. The start-time decision's evidence reads now go through the shared catalog verb and parser, with its existing tests unchanged and passing. Roll back by reverting this commit.
Docs
docs/configuration.mdgainsSPACE_RUNTIME_UPGRADE_RECONCILER_ENABLEDandSPACE_RUNTIME_UPGRADE_ITEM_DEADLINE_SECS. The first says that stopped sandboxes are upgraded and stay stopped, that running and paused ones are left running and skipped for now, and that the controller doesn't start the loop yet. The new store lives incrates/l30_storage/and declares the campaign tables its state.architecture-baseline.tomldrops them from its unowned tables, andnode_status_snapshotstoo, which the new node status store now declares.ARCHITECTURE.tomladds the campaign store as the node status store's user, and the cluster controller as a user of the catalog, policy and campaign stores and the API as a user of the catalog store, whichlayers.policy_changedreports: both read the catalog and policy to decide upgrades, and the controller owns the campaign tables. The schema's comment onruntime_upgrade_campaigns.include_runningstill says the upgrade "restarts" them; changing a column comment is a migration, so it's corrected with the next migration that touches the table.AI assistance: Claude Code drafted the code, the tests and this description, under the author's direction and review.