Repository navigation
api: let tests inject the Kubernetes readiness and suspend budgets - #330
Merged
Merged
Conversation
ac-pplx
force-pushed
the
ac/api-k8s-readiness-budget-seam
branch
2 times, most recently
from
October 12, 2026 23:18
1c25d9d to
6c18406
Compare
ac-pplx
force-pushed
the
ac/api-k8s-readiness-budget-seam
branch
from
October 12, 2026 23:59
6c18406 to
4fb236f
Compare
ac-pplx
marked this pull request as ready for review
October 13, 2026 00:24
Collaborator
Review — 3 findingsMedium
Low
Verified against |
the-gigi-pplx
approved these changes
Oct 13, 2026
the-gigi-pplx
left a comment
Collaborator
There was a problem hiding this comment.
Approved. 3 non-blocking findings in the review comment above.
KubernetesSubstrate now carries a SubstrateBudgets value (new
kubernetes/budgets.rs) for six wall-clock budgets that were compile-time
constants at their use sites:
- create_ready (CREATE_READY_BUDGET): create_execution's outer readiness
budget, its timeout message, and the phase deadline of a create that
joins an in-flight activation;
- create_ready_phase_margin (READY_WAIT_PHASE_MARGIN), as reserved inside
create_ready;
- preserve_decision (PRESERVE_DECISION_BUDGET);
- suspend_durability (TEARDOWN_SUSPEND_DURABILITY_BUDGET): suspend_execution's
deadline and the budget named in its timeout error;
- pooled_split_receipt_wait (WARM_POOL_SETUP_RPC_TIMEOUT), as the started
warm-pool adoption's split-receipt deadline;
- teardown_worker_connect (POD_WORKER_CONNECT_TIMEOUT), as teardown's connect
bound for the pod's worker.
Production constructs SubstrateBudgets::default(), which is exactly those
constants, so deadlines, error messages and logs are unchanged. The create
and suspend timeout messages print their budget with Duration's Debug, which
renders the 135 s and 300 s defaults as before ("135s", "300s") and a
test's sub-second budget as, say, "600ms" rather than "0s"; unit tests pin
both renderings. The free
ready_wait_bounds used by wake, start, restore and access delegates to the
same arithmetic with the default values, and every other use of the shared
constants still reads them directly.
Tests through the real entry points, with Postgres, the scripted apiserver
and the scripted worker real and the budgets shortened:
- create_execution readiness failures: when the outer budget cuts off a
hung pod read, a scheduled, reachable pod is kept as started on its node,
a terminating pod is deleted, a preserve decision that outlives its budget
ends in a delete, and a vanished pod is left with its claim to
reconciliation; a vanished pod is likewise left after node-mgr probes
answer Unavailable; lock contention after the pod exists stays
retryable only when the cleanup delete was accepted and is demoted to
Failed when it was rejected;
- a create that joins an in-flight activation returns the owner's pod
without a lease of its own, or times out within the create budget without
touching the owner's execution;
- suspend_execution keeps polling transient failures and Pending until
durable within its budget; past the budget, while Pending or before a
node-api pod resolves, it times out and keeps its claim.
The split-receipt refusal test drops from about 5 s to 0.3 s. The
unreachable-worker teardown case sets a 300 ms connect budget and asserts
the retire returns within POD_WORKER_CONNECT_TIMEOUT. On hosts that drop its
TEST-NET SYN that proves the substrate's budget bounds the dial, and the case
drops from about 3.2 s to 0.4 s; where the host answers ENETUNREACH the dial
fails at once and the bound holds trivially. New and converted node-api
tests use owned token files rather than the host's token paths.
ac-pplx
force-pushed
the
ac/api-k8s-readiness-budget-seam
branch
from
October 13, 2026 04:13
4fb236f to
1a95969
Compare
Contributor
Author
|
Thanks. All three are addressed in
|
This was referenced Oct 13, 2026
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.
Gives
KubernetesSubstrateaSubstrateBudgetsvalue for the wall-clock budgets ofcreate_execution,suspend_execution, started warm-pool adoption and teardown's worker connect. Production takes the default, which is exactly today's constants. Tests can shorten the budgets, so the readiness-failure, joined-create and suspend-timeout arms that #273 deferred now run through the real entry points, against real Postgres and #273's scripted apiserver and worker. The production diff is only this seam.The seam
kubernetes/budgets.rsdefinesSubstrateBudgets, aCopystruct of sixDurations.KubernetesSubstrateholds one in a privatebudgetsfield, which the production constructor sets the way it sets the neighbouringrecovery_join_backoffpolicy. Tests in thekubernetesmodule tree assign the field directly; there is no setter.KubernetesSubstrate::try_from_env, the only production constructor, setsbudgets: SubstrateBudgets::default().DefaultreturnsSubstrateBudgets::DEFAULT, aconstbuilt from the existing constants, so each field is its constant by construction:create_readyCREATE_READY_BUDGET(=NODE_RPC_BLOCKING_BUDGET)create_ready_phase_marginREADY_WAIT_PHASE_MARGINpreserve_decisionPRESERVE_DECISION_BUDGETsuspend_durabilityTEARDOWN_SUSPEND_DURABILITY_BUDGET(nowpub(super)so the default can name it)pooled_split_receipt_waitWARM_POOL_SETUP_RPC_TIMEOUTteardown_worker_connectPOD_WORKER_CONNECT_TIMEOUTThe constants, their docs and their static asserts are unchanged.
SubstrateBudgets::ready_wait_boundsis the existingsupport::ready_wait_boundsarithmetic over the struct's two create fields:create_ready − create_ready_phase_margin, and the outer backstop is one margin later.create_readywhile the margin fits inside it. Adebug_assert!checks that, and the subtraction saturates rather than panics in release builds.ready_wait_bounds, still called by wake, start, restore and access, delegates toSubstrateBudgets::DEFAULT.ready_wait_bounds. For the defaults that is the same 130 s inner window and 135 s backstop.ready_wait_boundsunit tests inkubernetes/tests.rsstill pass unchanged against the free function.Production deadlines, error messages and log lines are identical. The create and suspend timeout messages print their budget with
Duration'sDebug({:?}), which renders the 135 s and 300 s defaults as135sand300s, exactly the earlier{}sofas_secs(), and a test's sub-second budget as, say,600msinstead of0s. Two unit tests inadapter/tests.rs(create_ready_timeout_message_names_the_budget,generation_suspend_timeout_message_names_the_budget) compare each message at the default budget with the old format's output byte for byte, and pin the sub-second rendering. These two are the only messages derived from aSubstrateBudgetsfield; no test asserted their text before. Nothing changes on the wire, in the schema, in metrics or in configuration.Call-site inventory, before → after
CREATE_READY_BUDGETcreate_executionouter readinesstimeout(adapter/substrate_adapter.rs)self.budgets.ready_wait_bounds(self.budgets.create_ready)create_executionouter-timeout messageself.budgets.create_ready, through the newcreate_ready_timeout_errorActivationInFlight) phase deadlineself.budgets.ready_wait_bounds(self.budgets.create_ready), of which the arm keeps only the phase deadline; it has no outer timeout (Finding 3)ready_wait_bounds(9 call sites: 7 inexecution.rs, 2 inadapter/substrate_adapter.rs; wake, start, restore, access)SubstrateBudgets::DEFAULT(unrouted, same value)mod.rs, docsREADY_WAIT_PHASE_MARGINcreate_ready_phase_marginready_wait_boundsSubstrateBudgets::DEFAULT(unrouted)PRESERVE_DECISION_BUDGETcreate_executionpreserve decisionself.budgets.preserve_decisionTEARDOWN_SUSPEND_DURABILITY_BUDGETsuspend_executiondeadlineself.budgets.suspend_durabilitypoll_generation_suspendtimeout message (adapter/suspend.rs)durability_budgetparameter, passedself.budgets.suspend_durability; the two lower-layer tests inadapter/tests.rspass their own budgetWARM_POOL_SETUP_RPC_TIMEOUTself.budgets.pooled_split_receipt_waitPOD_WORKER_CONNECT_TIMEOUTteardown_executionworker client (adapter/teardown.rs)self.budgets.teardown_worker_connectendpoint_for_pod_ip,builder_node_client_at, the suspend node-api endpointBudgets these paths use that stay as they are
KubernetesState:create_data_plane_ready_timeout,create_command_ready_timeoutandk8s_blocking_delete_timeout.START_WAIT_CONNECT_TIMEOUT(500 ms proxy probe),START_WAIT_COMMAND_PROBE_TIMEOUT(1 s), the start-wait poll intervals and window,COMMAND_READY_MAX_POLL_INTERVAL,REFLECTOR_WAIT_BUDGET(250 ms), and the 250 ms suspend poll pacing.NODE_API_ENDPOINT_RESOLVE_TIMEOUTand its retry interval, which run insidetimeout_at(suspend_deadline)on suspend.POD_IP_WATCH_TIMEOUT_SECS(the server-side watch timeout);K8S_BLOCKING_DELETE_FALLBACK_GET_TIMEOUT(only after a full blocking-delete budget);SUSPEND_PENDING_REASON_LOG_INTERVAL(log pacing only);NODE_RPC_TIMEOUT(the channel timeout shared with every node RPC).No clock is injected. Short real budgets are enough, and Postgres stays real.
Call sites left unrouted, and why
ready_wait_boundscallers (wake, start, restore, access). They are outside the deferred paths, no test here needs them, andexecution.rs, which holds 7 of them, is edited by open PR [api] stop retrying restore pods whose snapshot the host CPU cannot load #221. They keep today's values throughSubstrateBudgets::DEFAULT.WARM_POOL_SETUP_RPC_TIMEOUTRPC timeouts. No test here waits them out. One of them, inapply_pooled_execution_auto_lifecycle_with_client, is an associated function withoutself, so routing it means a new parameter.POD_WORKER_CONNECT_TIMEOUTsites, all endpoint connect timeouts.endpoint_for_pod_ipis an associated function withoutselfused across the substrate. The builder client and the suspend node-api endpoint are not on a path a test here waits out.Tests
The tests live in
#[cfg(test)]modules underkubernetes/adapter_characterization_tests/. They callcreate_execution,suspend_execution_tracked(theTaskTrackerwrapper aroundsuspend_execution),prepare_pooled_executionorretire_execution, each against its own Postgres database frompg_test_db(). Every new or converted test that starts the scripted worker enters its child process withenter_sim_worker_process_with_owned_token_files(), so bearer loading reads test-owned token files and never the host's/var/run/space-auth/*.token.New module
create_readiness.rsWired by one
mod create_readiness;line.create_readiness_failure_keeps_or_releases_only_what_its_arm_ownshas 7 cases.creating, so readiness reads the pod rather than watching it;resolve_pod_ipskips that read only forcreatingandrecoveringexecutions.create_ready1 s with a 500 ms margin andpreserve_decision1 s. Readiness reaches that read after the 250 ms reflector wait, well inside the budget.outer_budget_preserves_a_reachable_podOk:started/on_nodeon the worker's node id,command_ready: false, no binding; no DELETE; the execution is still active andrunningwith the pod's UID and IP; no cancel countedouter_budget_tears_down_a_terminating_poddeletionTimestampErr(Timeout { phase: None }); exactly one DELETE, of the pod's path; execution retired; no cancel countedslow_preserve_decision_tears_downouter_budget_defers_cleanup_of_a_vanished_podErr(Timeout { phase: None }); no DELETE; the claim stays for the orphaned-sandbox reconciler; one cancel counted toward backpressureunreachable_node_mgr_defers_cleanup_of_a_vanished_podUnavailableuntil the 500 ms command-ready phase endsErr(Timeout { phase: GuestNotReady }); no DELETE; claim kept; one cancel countedcontention_with_accepted_cleanup_stays_retryableACCESS EXCLUSIVEtable lock underlock_timeoutErr(LockContention); one DELETE; execution retiredcontention_with_rejected_cleanup_is_demotedErr(Failed), demoted; one DELETE; execution retiredThe cleanup decision is
err_defers_cleanup = is_nodemgr_unreachable(&err) || matches!(err, Timeout { .. }). Case 4's outer-budget timeout names neither phase nor node-mgr, so only theTimeouthalf defers it; removing that half fails it (D1). Theis_nodemgr_unreachablehalf has no genuine input on this path and is not pinned (Finding 4).joined_create_waits_on_the_owners_podhas 2 cases. Another attempt owns the activation, claimed throughK8sStore::begin_activation_execution_row.returns_the_owners_reachable_pod:Okwith nolease_completion,command_ready: true, the owner's pod UID as the binding's execution object, and the worker's node id.times_out_without_disturbing_the_owner:Err(Timeout { phase: PodNeverReady })within the create budget, although the data-plane timeout is 60 s.Suspend:
hydration_and_suspend.rssuspend_execution_polls_the_node_within_its_durability_budgethas 3 cases. It adds only what the entry point owns: the substrate's budget reaches the poll, and the claim is kept on timeout.suspend_execution_commits_the_nodes_artifact_before_retiring_the_pod.adapter/tests.rs.Cases:
pending_then_durable: the node answersUnavailable, thenPending, thenDurable(2 s budget). The suspend returns the node's artifact.pending_past_the_budget(600 ms budget): the node staysPending.node_api_never_resolves(600 ms budget): there is no node-api pod for the node.Err(Timeout)before the 5 s bound; no apiserver request; the sandbox stayssuspendingand the execution active, so a retry joins the claim.Converted slow tests
started_pooled_adoption_refuses_a_worker_without_split_receipts(pooled_adoption.rs) setspooled_split_receipt_waitto 300 ms and asserts the refusal arrives before the 5 s production budget. Wall time: 5.0 s → 0.32 s.teardown_keeps_execution_tracked_when_the_node_cannot_finish::retire_worker_unreachable(create_and_teardown.rs) keeps api: characterize Kubernetes substrate suspend, pooled preparation and restore hydration (tests only) #273's dial to TEST-NET-1192.0.2.1and its outcome assertions: the retire fails, no pod is deleted, and the execution stays tracked. It setsteardown_worker_connectto 300 ms and asserts the retire returns withinPOD_WORKER_CONNECT_TIMEOUT(3 s).ENETUNREACHand the dial fails at once, or it drops the SYN and the 300 ms budget ends the dial.192.0.2.1:80was still pending after 3 s), and the case takes 0.39 s instead of 3.2 s.ENETUNREACH, the assertion passes trivially and proves nothing about the budget.Shared doubles
scripted_test_doubles.rs:ScriptedReplygainsHang(never answer). The suspend test uses theSequencereply that #326 added.Mutation proofs
Each mutation was a temporary edit to one file, followed by the whole
adapter_characterization_tests::set (55 tests). The listed tests were confirmed to fail, then the file was restored and touched. 24 mutations, all killed:Timeouthalf oferr_defers_cleanup.Where each one ran:
retire_worker_unreachablefailed, at 3.07 s, on the connect-budget bound.create_readiness(S1, S2, B1–B9, D1, T1–T3) ran on4fb236f. Since then the only change to their paths is the create timeout message's formatting.1c25d9d. Their production code and tests are the same here apart from the suspend test's move to api: pin the pooled-setup error arms #273 called unreachable #326'sSequenceAPI."readiness N", "joined N" and "suspend N" refer to the cases in the order listed above.
ready_wait_bounds(CREATE_READY_BUDGET)againPRESERVE_DECISION_BUDGETagainTEARDOWN_SUSPEND_DURABILITY_BUDGETagainWARM_POOL_SETUP_RPC_TIMEOUTagainPOD_WORKER_CONNECT_TIMEOUTagainretire_worker_unreachable(3.07 s; on a host that drops the TEST-NET SYN)mark_cause(NodeMgrUnreachable)(the guard then spawns cleanup)err_defers_cleanupkeeps onlyis_nodemgr_unreachable(&err)command_ready: falsePendingobservation is terminalUnavailablelock_timeoutFindings (reported, not changed)
create_executiondeletes it withdelete_sandbox_pod(DeleteParams::default()).CancelGuard's cleanup deletes the same pod withdelete_sandbox_pod_if_uid. No production path to deleting the wrong pod was found: pod names are per generation, and the create holds the execution claim throughout. The tests do not pin the missing precondition.release_activation_attempt_best_effort→complete_activation_attemptmatches zero rows, because it requires the sandbox lifecycle to equal the attempt's target (started) and the sandbox is stillcreating.lease_completion: None, so the caller does not complete the attempt either.activation_attempt_idstill set aftercreate_executionreturned.ACTIVATION_ATTEMPT_LEASE_TTL_SECS). Meanwhile, activations for other targets getActivationInFlight.read_sandbox_podfalls back tofetch_sandbox_pod_uncached, an apiserver GET with no timeout, so a hung apiserver extends a failed create past its budget.ready_wait_bounds. Its readiness wait starts with aresolve_pod_ipread inwait_for_pod_ip_assignment(wait_ready.rs), which runs before that phase deadline'stimeout_at. That read can fall through to an uncached apiserver GET, so a hung apiserver there hangs the joined create. Thecreate_readydoc says only that its phase deadline bounds the joined create's readiness phases.is_nodemgr_unreachable(&err)half oferr_defers_cleanuphas no genuine input; its only match is incidental.Timeout, which the other half already defers.resolve_pod_ipreturnsUnavailable("sandbox pod is unschedulable: {scheduler message}"), and the heuristic matches when the scheduler's message happens to contain "unavailable", for example anode.kubernetes.io/network-unavailabletaint.NodeMgrUnreachable.Observations, not changed here:
tokio::spawn, untracked and unbounded, since kube-client has no read timeout. Routing it through the substrate'sTaskTrackerwould let graceful shutdown wait on a hung PATCH for the whole drain grace, which is a production change and outside this seam.CancelGuard's background pod DELETE is on the tracker and likewise unbounded.KubernetesStateper-phase timeouts bound. The budgets are what make the outer-budget timeout, the preserve-decision budget, the joined-create timeout and the suspend timeouts testable in about a second.Host safety
127.0.0.1:0, closed on drop.towerservice.127.0.0.1, with the substrate's proxy port set to the test's own worker port.192.0.2.1, so a regression that does dial one fails to connect rather than reaching a host listener. The unreachable-worker teardown case dials it on purpose, as api: characterize Kubernetes substrate suspend, pooled preparation and restore hydration (tests only) #273's did: the connect fails or gives up within 300 ms.1. The no-node-api case has no node-api pod to resolve.substrate_with_unreachable_store(127.0.0.1:1, fast failure). The contention cases build a second pool to their own test database, withlock_timeoutset at connect, and hold and release their own table lock.pplx-space-apitest runs in CI's unprivileged half; none is in theprivilegednextest group.Overlaps with open PRs
The named PRs #269, #271, #273, #275, #277 and #278 are merged. #270 (open) touches only sbx-mgr files, so there is no overlap.
Open PRs that touch this directory predate the split of
kubernetes/adapter.rsintoadapter/:adapter.rshunks addabandon_when_retiredto the twoPodProxyWaitTargetliterals increate_execution. Those now live inadapter/substrate_adapter.rs, about 8 lines below this PR's twoready_wait_boundsedits. It also editsexecution.rsandwait_ready.rs, which this PR does not touch, and appends tests at the end oftests.rs; this PR adds onebudgets:line to each of the three substrate literals there.adapter.rshunk now corresponds to code about 40 lines from this PR's split-receipt edit inadapter/substrate_adapter.rs. This PR does not touchpool.rs, which api: decide admission pool claims for the create's owner; collect refused claims promptly #168 also edits.mod.rs, where this PR adds onemod, oneuseand one field, andtests.rsnear the restore-shortlist tests.Neither #221 nor #168 touches the
TEARDOWN_SUSPEND_DURABILITY_BUDGETline inadapter.rs. The new tests are in new or #273-owned test files that no open PR edits.Validation
At head
1a95969f39987efd4c6d381359bff80a89471113, one commit onmainat9d89c26.mainhas moved toc52a6c3; GitHub reports the PR mergeable, so it was not rebased.This revision fixes three review findings: the
create_readydoc and Finding 3 now describe the joined create's bounds accurately,retire_worker_unreachableasserts its connect bound again (S6), and the two budget-derived timeout messages print theDurationwith{:?}.bash ci/scripts/run-rust-checks.sh fmt(env-contract check, Rust error policy,cargo fmt --all --check)bash ci/scripts/run-rust-checks.sh clippy(workspace--all-targets --all-features -D warnings, plus the observability, common-svc and feature-flags isolation lints)cargo nextest run --locked --all-features -p pplx-space-api --retries 0×3 in a row, Postgres and Redis through the nextest setup scripts,SPACE_CI_REQUIRE_TEST_DEPS=1,SPACE_TEST_DEP_SKIP_LOGsetSPACE-TEST-DEP-SKIPmarkeradapter/teardown.rsrestored and touched1a95969(run 38345438698) (Rust tests, clippy, format, dependency checks, guide, docs, migration guards, shell, Python, Go shim, gateway traffic,space-pull-ci)Carried from
4fb236f, not rerun because this revision changes production code only in two messages' formatting:scripts/source_guards/{env_test_hook_boundary,node_selector_extras_smoke,observability_vendor_boundary}.pyscripts/check_no_inline_rust_test_modules.py,scripts/source_guards/thin_service_mains.pycrates/libs/observability/src/{otlp_endpoint,selection}.rs,crates/apps/vol/mgr/svc/src/main.rs); pre-existing, unrelated--run-ignored only)create_readiness::module ×50 under load (48 busy-loop processes on the 48-core host, load average ~25)1c25d9dand again on6c18406space-localcluster; deployedspace-api-svcsha256 equal to the build's artifact)TaskTrackerchange:smoke-kind-e2e.shpassed twice;smoke-kind-typed.sh allpassed 23/23 native and 76/77 ECW, the 77th beinglocal.ecw_template_build_from_oci_boots, which is ENVIRONMENTAL (builder storage admission needs 55.8 GB free and found 26.7 GB on the host's shared disk; failed again on rerun). Not rerun: production code has since changed only by the revert tomain'sadapter/pooled.rs, adebug_assert!, and the timeout messages' formatting, which is byte-identical at the production defaults.🤖 Generated with Claude Code