Repository navigation
api, cluster-controller: reach Kubernetes-only state through one accessor - #386
Merged
Merged
Conversation
ac-pplx
force-pushed
the
ac/substrate-capabilities-api
branch
from
October 13, 2026 17:33
e5862f7 to
251e4c7
Compare
ac-pplx
force-pushed
the
ac/substrate-kubernetes-accessor-routability
branch
3 times, most recently
from
October 13, 2026 19:35
05b795c to
6f1dc66
Compare
This was referenced Oct 13, 2026
api, cluster-controller: sort the tests that use the Direct substrate as a fixture (tests only)
#402
Merged
…ssor Shared code outside the substrate module no longer matches on `Substrate`'s variants. The sites that did fall into two kinds, and each kind gets one seam: - Sites that need Kubernetes-only state take it from `Substrate::as_kubernetes() -> Option<&KubernetesSubstrate>`, behind the `kubernetes` feature: the cluster controller's Kubernetes reconciler set (still typed on `KubernetesSubstrate`) and its leader election, which contends for the Lease only on Kubernetes. The election arm binds nothing (`Some(_)`): since #431 the Lease store gets its own failure-traced client rather than the substrate's. Control flow, logs and spawn order are unchanged. - The routability contract ("`started` promises a routable execution row that may need hydrating from a live pod") becomes substrate behaviour: `SubstrateAdapter::ensure_routable_execution(sandbox_id, budget)`. The default is Direct's answer, `Unconditional`; Kubernetes overrides it with `hydrate_restored_execution`, mapping its three outcomes one to one; and `Substrate` forwards it for both substrates. The api's cancelled-wake completion redrive and the transient-lifecycle sweeper both call it. Each keeps its own budget (the redrive's per-attempt budget, the sweeper's probe timeout) and its own error mapping. The sweeper also keeps its hydration events, which is why the answer separates `Unconditional` (nothing checked, so no event) from `AlreadyRoutable`. `hydrate_restored_execution` and `ExecutionHydrationOutcome` narrow to the Kubernetes substrate module, so shared code cannot reach around the new method through `as_kubernetes()`; the outcome type's unused `as_str` and `Display` go. `Substrate`'s Direct arm of `observe_pod_identities` forwards to `DirectSubstrate` instead of repeating the default inline, like every other defaulted method. Behaviour is unchanged. Two tests cover paths that had none: the routability answer on Kubernetes for each hydration verdict, asked through `Substrate`; and the redrive on Kubernetes hydrating a row the database still reports unroutable from its live pod before completing it, which the completion CAS alone would refuse. The tests reach the scripted kube client and the annotated sandbox pod through test-only `pub(crate)` visibility.
ac-pplx
force-pushed
the
ac/substrate-kubernetes-accessor-routability
branch
from
October 14, 2026 16:46
6f1dc66 to
0eaa61f
Compare
ac-pplx
marked this pull request as ready for review
October 14, 2026 17:10
nickdujay-pplx
approved these changes
Oct 15, 2026
nickdujay-pplx
left a comment
Collaborator
There was a problem hiding this comment.
Approved at 0eaa61f, unchanged from the detailed review. No blocking findings: the accessor and routable-execution abstraction preserve the Direct and Kubernetes behavior, including the rebased leader-election path.
The earlier local run passed 150 targeted tests with real PostgreSQL and mock Kubernetes coverage, and CI is green for this exact commit.
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.
Based on
main: #368 merged as723bbcc, and this PR is now its one commit rebased onto that. The rebase had one conflict, with #431's leader-election change (see Rebase onto main).What merging does
After this PR, shared code outside the substrate module no longer matches on
Substrate's variants anywhere outside tests. The four sites this phase covers fall into two kinds, and each kind gets one seam:Substrate::as_kubernetes() -> Option<&KubernetesSubstrate>, behind thekubernetesfeature. Two cluster-controller sites use it: the Kubernetes reconciler set inreconcilers.rs(still typed onKubernetesSubstrate) and the leader-election arm inlib.rs, which contends for the Lease only on Kubernetes. Since Observability wave 4a: drop background trace noise, fix the gateway hydration race, add shim teardown spans #431 that arm builds the Lease store's own failure-traced client, so it binds nothing (Some(_)).SubstrateAdapter::ensure_routable_execution. The contract is "startedpromises a routable execution row that may need hydrating from a live pod". The api's cancelled-wake completion redrive and the cluster controller's transient-lifecycle sweeper both ask it.So shared code can't reach around the new method through
as_kubernetes(),KubernetesSubstrate::hydrate_restored_executionandExecutionHydrationOutcomenarrow to the Kubernetes substrate module. The outcome type's unusedas_strandDisplayare removed.Substrate's Direct arm ofobserve_pod_identitiesnow forwards toDirectSubstrateinstead of repeating the default inline; it was the only defaulted method that did, and the answer is the same.Production behaviour, wire formats, schema, metrics, logs and configuration are unchanged. Two behavioural tests cover Kubernetes paths that had no test, using existing fixtures. Test construction (
Substrate::Kubernetes(Box::new(...))) is the only place outside the substrate module that still names a variant; making the variants private is left for the phase's last PR.The accessor
It returns
Someexactly for the Kubernetes variant, borrowing the same boxed value the old match arms bound. Its doc comment states the split the repo owner chose: Kubernetes-only state goes through the accessor, behaviour goes onSubstrateAdapter, and static facts go inSubstrateCapabilities.The routability method
startedmakes no pod-routability promise, so nothing is checked or written.DirectSubstratedoes not override it.KubernetesSubstrateoverrides it with today'shydrate_restored_execution(sandbox_id, budget). Its three outcomes map one to one, and its error passes through unchanged.budgetbounds the apiserver read.NotRoutablecan still have recorded a matching pod's identity on an execution row that is not running, such as arecoveringone.impl SubstrateAdapter for Substrateforwards both arms: Direct toDirectSubstrate(and so to the default), Kubernetes to the override. The new tests call the method throughSubstrate, so a missing Kubernetes arm fails them (mutation M3).How the two sites differ, and why this is one method with a parameter and a four-way answer:
sandbox_id,REDRIVE_HYDRATE_BUDGET(per attempt)sandbox_id, the sweeper's configuredprobe_timeouterr.to_string()execution_hydrationErroroutcome event, thenerr.to_string()Hydrated→ infoHydratedExecutionFromLivePod;AlreadyRoutable→ debugSkipped/already_routable;NotRoutable→ debugDeferred/no_routable_pod; Direct → no eventboolExecutionRoutability::{Routable, NotRoutable}The inputs differ only in the budget value, which becomes the parameter. Each site keeps its own error mapping and telemetry. The sweeper emits nothing for Direct but a
Skippedevent for an already-routable Kubernetes row, so the answer has to separateUnconditionalfromAlreadyRoutable; aboolor a two-way enum would have changed telemetry. The sweeper's two-variantExecutionRoutabilitystays as itsSweepSinkcontract, and the new type is named…Checkto avoid colliding with it. The sweeper'sSweepSink::ensure_routable_executioncallsSubstrateAdapter::ensure_routable_execution(self.substrate.as_ref(), …)explicitly, so the call doesn't read as recursion.Site table
crates/libs/cluster/controller/server/src/reconcilers.rsrun_reconcilersmatch substrate.as_ref() { Substrate::Direct(_) => log skip, Substrate::Kubernetes(kube) => { let kube = Arc::new((**kube).clone()); … } }match substrate.as_kubernetes() { None => log skip, Some(kube) => { let kube = Arc::new(kube.clone()); … } }None: same "skipping Kubernetes-only reconcilers on direct substrate" logSome: sameKubernetesSubstrate::clone, same reconcilers in the same ordercrates/libs/cluster/controller/server/src/lib.rsrun(leader election)match &substrate { Substrate::Kubernetes(kube) if le_config.enabled => campaign, _ => election disabled }match substrate.as_kubernetes() { Some(_) if le_config.enabled => campaign, _ => election disabled }None→_: election disabled, same log_maincrates/libs/api/src/controllers/sandbox/wake_completion.rswake_completion_redriveDirect(_) => Ok(true);Kubernetes(kube) => hydrate_restored_execution(id, REDRIVE_HYDRATE_BUDGET):Hydrated | AlreadyRoutable → true,NotRoutable → false,Err → err.to_string()substrate.ensure_routable_execution(id, REDRIVE_HYDRATE_BUDGET):Ok(check) → check.is_routable(),Err → err.to_string()Unconditional→true(a ready future, no await point)crates/libs/cluster/controller/server/src/transient_lifecycle_sweeper/store.rsSubstrateSweepSink::ensure_routable_executionDirect(_) => Ok(Routable);Kubernetes(kube) => hydrate_restored_execution(id, probe_timeout)with the events above, thenRoutable/NotRoutableSubstrateAdapter::ensure_routable_execution(self.substrate.as_ref(), id, probe_timeout): sameErrorevent and string,Unconditional→ no event, the other three → the same events, thenis_routable()→Routable/NotRoutableUnconditional→Routable, no eventThe
#[cfg(feature = "kubernetes")]arms at the two routability sites go away. Without the feature, Direct is the only variant and its answer is the same. The comments that named these sites are updated: thehydrate_restored_executiondoc (which named only the sweeper), the redrive's docs, and the reconcilers' sweeper comment (the sink needsSubstrateAdaptermethods, not justdelete_execution). TheNotRoutabledoc onExecutionHydrationOutcomeno longer claims nothing was written, because arecoveringexecution records the pod identity.Coverage by mutation
These runs were on the pre-rebase head
05b795cand were not repeated after the rebase, which changed only the leader-election arm's binding (Some(kube)→Some(_)). Each mutation was applied on top of the commit. For each,cargo nextest run --locked -p <pkg> --all-features --retries 0 --run-ignored allran for bothpplx-space-apiandpplx-space-cluster-controller-serverwithSPACE_CI_REQUIRE_TEST_DEPS=1, and the mutation was then reverted. The tree was clean afterwards. Below,redrive_on_kubernetesiscontrollers::sandbox::lifecycle::wake_completion_postgres_tests::wake_completion_redrive_on_kubernetes_hydrates_before_completing, androutability_checkis…::hydration_and_suspend::routability_check_answers_with_the_hydration_verdict.pplx-space-apiNotRoutablecontrollers::sandbox::lifecycle::characterization_tests::redrive_completes_only_a_restoring_rowAlreadyRoutable→NotRoutableroutability_checkcase 2Hydrated→NotRoutableredrive_on_kubernetes,routability_checkcase 1Substrateforwarding: Kubernetes arm answers the defaultredrive_on_kubernetes,routability_checkcases 1–4Unconditionalredrive_on_kubernetesOk(true)(never asks the substrate)redrive_on_kubernetesUnconditionalnot routableredrive_completes_only_a_restoring_rowUnconditionalUnconditionalnot routablereconcilers.rs:as_kubernetes()→Nonelib.rs:as_kubernetes()→Noneas_kubernetes()returnsNonefor KubernetesUncovered sites:
KubernetesSubstratefixture (the api crate'skubernetes::testsfixtures arepub(crate)), and its sweeper tests drive a fakeSweepSink. The method underneath is covered at the api layer (M1–M3, M12). The site's own logic is the event selection and the two-way mapping. Its Direct path is unreachable in practice: the candidate select treatsmanaged_by <> 'k8s'rows as routable before this call.reconcilers.rsandlib.rs(M8–M10). These are process-startup wiring, and no test runsrunorrun_reconcilers. The Kind run below is their evidence: on the deployed binary, a cluster-controller replica contended for leadership, was elected, and started the Kubernetes reconciler set. Direct's answer at these sites (None) can't be broken intoSomewithout aKubernetesSubstrate, so only the Kubernetes direction was mutated.New tests, both behavioural, using existing fixtures:
routability_check_answers_with_the_hydration_verdict: an rstest table askingensure_routable_executionthroughSubstrateon Kubernetes. The cases are a matching pod (Hydrated), an already-routable row with a broken apiserver (AlreadyRoutable), an absent pod (NotRoutable), and an apiserver error (Err).wake_completion_redrive_on_kubernetes_hydrates_before_completing: the redrive on Kubernetes, with a scripted apiserver, over arestoringrow whose execution iscreatingwith no pod IP. The database reports that row unroutable, so the completion CAS alone would refuse it.started.restoring; the CAS's routability predicate also holds that case.The redrive test reaches
scripted_test_doubles::{scripted_kube_client, kube_status, pod_reply}(and theKubeRequest,KubeReplyandKubeLogtypes in their signatures) andadapter_characterization_tests::sandbox_podthroughpub(crate)visibility. All of these are#[cfg(test)]. No existing test was changed. The characterization suites pass unchanged: #323 (cluster controller) and #273/#290/#326/#330 (api).Decisions made unattended
budgetparameter, a four-way answer. The two sites differ only in budget value, error mapping and telemetry. The last two stay at the call sites, and the answer keeps the distinction the sweeper's events need (table above).Substrateforwards Direct toDirectSubstraterather than answering inline. This is the patternretire_fence_mismatchuses, so a future Direct override can't be silently bypassed.observe_pod_identities, the one defaulted method that answered inline, now follows the same pattern.lib.rsand a capability field forreconcilers.rs; I followed the repo owner's decision (as_kubernetes()) for both.kubernetesmodule.hydrate_restored_executionandExecutionHydrationOutcomebecomepub(in crate::controllers::substrate::kubernetes); the re-exports becomepub(super)inpod_opsand private inkubernetes. That is the narrowest visibility their users (the override and the module's own tests) need. Removingas_str/Displaycompiles and lints cleanly on every target, so nothing used them.cargo archreports advisoryfile_size.listed_no_growthgrowth forsubstrate/mod.rsandkubernetes/adapter/substrate_adapter.rs, which api: ask the substrate for capabilities instead of matching on it #368 also grows. The trait, its forwarding impl and the single Kubernetes impl live there, so I didn't split files in a behaviour-preserving PR. There are 0 required findings.BOOTSTRAP_TIMEOUTwas 3600 instead of CI's 1800, because this box builds the Rust artifact image itself instead of downloading CI's.build-rust-artifacts.shin itsdockermode from this commit's tree. The final bring-up then used the harness'sprebuiltmode with that image's ID and this commit's SHA, the path CI's smoke shards take. The Kind nodes' containerd storage (SPACE_KIND_DATA_ROOT) was a task-owned directory on the Docker host's tmpfs, andTMPDIRpointed off the home volume. The Kind containerd image cache under the home volume and the Docker volumes were not touched.Some(_)in the leader-election arm. Observability wave 4a: drop background trace noise, fix the gateway hydration race, add shim teardown spans #431's body no longer uses the substrate, so the arm binds nothing. The other way to write the same guard isif substrate.as_kubernetes().is_some() && le_config.enabled. I kept the match, as the rebase plan asked, so the diff againstmainstays two lines. After Observability wave 4a: drop background trace noise, fix the gateway hydration race, add shim teardown spans #431 this site asks only "is this Kubernetes?", a capability-shaped question; turning it into aSubstrateCapabilitiesflag is left to the phase's later PRs.Overlaps
These trials were run before #368 merged, on the previous base, and were not repeated for this rebase. All nine PRs are still open. For each overlapping PR, I cherry-picked #368's commit and then this commit onto its head as a trial:
transient_lifecycle_sweeper/store.rsSQL literals) edits that file's first import line and SQL constants and queries; this PR edits thepplx_space_apiimport block and theensure_routable_executionbody. The trial applies cleanly, so I expect the rebase to be clean.controllers/substrate/AGENTS.md) is not merged. Its bullet "MatchingSubstrate::Kubernetes(k)to reach Kubernetes-only state is fine" overlaps with this PR's accessor: once both land, it should nameas_kubernetes(). Trial: clean.substrate_adapter.rs,substrate/mod.rs,kubernetes/mod.rs,pod_ops.rsorpod_ops/restore.rsin other hunks. Trials: clean.substrate/mod.rs,kubernetes/mod.rs,pod_ops.rs, or the cluster controller'slib.rs/reconcilers.rs. All four are on older bases that already conflict with api: ask the substrate for capabilities instead of matching on it #368. Their own hunks don't touch this PR's lines; [space] order data-plane scale-in by open connections via pod-deletion-cost #28's new reconciler inside the Kubernetes arm useskube.clone(), which still compiles underSome(kube).hydrate_restored_execution,ExecutionHydrationOutcome,as_str/Displayon it, or the widened test doubles from outside thekubernetesmodule.Substrate::Kubernetes(…)/Substrate::Direct(…)match outside tests.Rebase onto main
This PR's commit was rebased with
git rebase --onto 723bbcc 251e4c7(dropping #368's original commit). There was one conflict, incrates/libs/cluster/controller/server/src/lib.rs's leader-election arm. #431 had replacedkube.k8s.client.clone()with a Lease client of its own,failure_traced_kube_client_from_env(shutdown_token.clone()), mapped toRunClusterControllerError::LeaseClient. This PR had changed the arm's pattern. The resolution keeps both: this PR'sas_kubernetes()match and #431's body exactly asmainhas it. Because that body doesn't use the substrate, the arm binds nothing. The whole diff againstmainin this file:git range-diffagainst the pre-rebase commit shows only that hunk. Apart from the commit message, nothing else changed.git diff 723bbcc HEADtouches the same 13 files with the same counts (+358/−137).Commits on
mainsince the previous base (64afd0b) that touch this PR's files, and how each interacts:723bbcc). This is the squash of the old parent251e4c7;git range-diffshows only the commit title changed. No interaction.cluster/controller/server/src/lib.rs. This is the conflict above. Its other hunk in that file (theLeaseClienterror variant) is untouched and still used.substrate/mod.rs,kubernetes/mod.rsandkubernetes/adapter/substrate_adapter.rs. It adds kept worker channels:RuntimeClient::kept/retire_kept_connection,ResolvedPodInstance,pod_worker_channels, andresolve_pod_instance/kept_worker_clientin the runtime-client methods. Those are different hunks and methods from this PR'sensure_routable_execution,as_kubernetes(),observe_pod_identitiesforwarding and visibility changes. It adds noSubstratevariant match outside the substrate module, and no defaulted trait method thatSubstrateanswers inline.substrate/mod.rs. In this file it removes thenode_api_transporthelper and rewordsnode_api_connect_endpoint's doc. That is node-api transport code this PR doesn't touch, so there's no interaction.Substrate::Direct/Substrate::Kubernetesis named outsidecontrollers/substrate/only in test code.Validation
All runs below were on head
0eaa61f: this commit onmainat723bbcc(#368). Its tree is identical to77ca814, the same rebase before a message-only amend. Some runs started on77ca814.cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-features --locked -- -D warningscargo check --locked -p pplx-space-api(no features)cargo nextest run --locked --retries 0 -p pplx-space-api --all-features,SPACE_CI_REQUIRE_TEST_DEPS=1(nextest's Postgres and Redis setup scripts), 3 consecutive runs#[ignore]dbenchmark_single_node_occupancypplx-space-cluster-controller-server(--all-features), 3 consecutive runspre-commit run --from-ref 723bbcc --to-ref HEADpassedNot repeated after the rebase: the mutation runs M1–M12 and
cargo arch, which ran on the pre-rebase head05b795c(see above). The rebase changes only the leader-election arm's binding, which no unit test reaches (M9). The Kind run below exercises that arm.Full Kind validation
These are this rebase's results, on a fresh
space-localcluster at0eaa61f. The cluster was created after the artifact image was built (decision 8), bysmoke-kind-e2e.sh's bring-up.Build provenance. The Rust artifact image was built by
build-rust-artifacts.sh(dockermode) from a clean checkout of this tree. The bring-up then used it inprebuiltmode, which checks the checkout's SHA and the image ID. Before and after the suites, thesha256ofspace-api-svc,space-internal-api-svcandspace-cluster-controllerwas taken in every running api (2), internal-api (1) and cluster-controller (2) pod. Every hash equalled the artifact image's. The controllers reportgit_sha=0eaa61f9c98e. Tilt reported 50/50 resources ok, none pending or in error, after the bring-up and again after the suites.The rebased leader-election arm, at runtime. Both cluster-controller replicas logged "contending for leadership", and one logged "elected leader; starting reconcilers". The leader then started the Kubernetes reconciler set that
as_kubernetes()gates inreconcilers.rs(warm pool, kube-node-sync, the transient-lifecycle sweeper, node drain, …), with no Direct "skipping" log.smoke-kind-e2e.sh(bring-up,DATA_PLANE_URLset as CI does)smoke-kind-typed.sh all(unsharded;SKIP_BOOTSTRAP=1,SPACE_SMOKE_KIND_ADMIN_TOKEN_FILEandSPACE_SMOKE_KIND_COLD_RECEIPTSset, and the data-plane wait, as CI does; host dm targets loaded)local.ecw_template_build_from_oci_bootsstorage admission requires 55834574848 available bytes but found 18519506944. Builder storage admission (build/mgr/src/storage.rs, astatvfsof the builder's storage root) needs 52 GiB free. On this box that root was the task-owned tmpfs (decision 8), with 17.2 GiB free and the cluster in it. Rerun once on the same cluster and binaries: the same error, with the same free bytes as the tmpfs at that moment. Classified ENVIRONMENTAL, since the failure is explained by host storage, so I made nomaincomparison. The scenario never reached code this PR changes; it passed on #386's previous head.smoke-kind-leader-election-brownout.sh,SPACE_SMOKE_LEADER_ELECTION=1leases-RBAC contender reported the refusal on sight, ran no reconcilers and did not restart over 2× its budget. (3) Apiserver outage: the readiness marker cleared, the holder resigned 37 s into the outage (TTL 60 s), and one replica spent its liveness budget and was restarted. After the apiserver came back, there was one leader, one reconciler runner, and both replicas were Ready.smoke-kind-sandbox-fork.pysmoke-kind-e2e.shagain (SKIP_BOOTSTRAP=1)🤖 Generated with Claude Code