Repository navigation
Conversation
A worker may need a fresh backup attempt after its execution has retired. Require an exact earlier registration from the same source incarnation for that retry. Refuse first registration after retirement, even when inventory and the current-artifact pin match; worker-reported inventory grants no authority. First registration on active or target-only placement must match the active execution's recorded Node UID. Persist that UID per attempt and check it again on replay and finalize, holding sandbox and node locks through the catalog write. Reject conflicting artifact kind, origin, or snapshot name. Retries inherit the earliest verified registration timestamp so an older artifact cannot become fresh restore evidence after relocation. Depends on schema #318 and identity writers #319 in native GitHub stack #320. Deploy schema, then both execution writers, then enforcement; drain old in-flight uploads first. Historical attempts without UIDs cannot finalize under the new policy. Deploy exact-Ready reuse from #281 before enforcement to avoid unnecessary reuploads of restored artifacts. Keep this stack separate from approved #281 -> #285. Registered-address authentication and credential vending remain separate boundaries; credential-vending follow-up is #321. Registry convergence is required to detect node replacement; this is not caller-carried incarnation authentication. No job reset, deployment, or live recovery is included. This PR remains draft for architecture review. Validation: 416 selected PostgreSQL/controller/storage tests passed with required PostgreSQL and Redis, including the 24-case admission matrix, prior-registration freshness, replay identity, terminal transitions, and pool adoption. Formatting and repository policy checks passed. Affected-package all-target clippy passed with warnings denied. The final run passed including the transaction-lock regression; nextest reported one child-output leak in pooled-create case 1. Gitleaks and public-disclosure review had no findings in the changed scope. The complete CI lint runner stops on macOS at its Linux-only Wild prerequisite; Linux/Firecracker end-to-end behavior remains unverified. AI assistance: Codex contributed implementation, tests, and description.
a576181 to
9775913
Compare
|
Implemented the reviewed writer fixes. Warm-pool UID lookup blocks only promotion, so timeout cleanup still runs. Each authorized materialization has a token reused across its create retries; create responses, adoption, re-drive, and observers must match it before recording pod identity. Recreation clears both node fields, and cleanup is fenced by the expected materialization and observed pod UID. Legacy unknown identity is not backfilled. Placement releases sandbox locks before an independent inventory repair, then revalidates identity under shared node locks. The regression test forces the terminator/placement contention and verifies the sandbox lock is released. Pool claims preserve the incarnation captured at readiness. Validation: all 400 Kubernetes lifecycle cases passed across the final suite and a focused retry of one PostgreSQL connection-reset fixture. Workspace lint, doctests, feature isolation, SDK conformance, and migration isolation passed. The workspace gateway TLS timeout reproduces on the untouched base. This remains draft; no deployment was performed. |
shrikant-pplx
left a comment
There was a problem hiding this comment.
Reviewed together with the rest of stack #345 → #318 → #319 → #346, in two passes (correctness, then security). No P0/P1 findings. Two P2s and two P3s are inline. The token fencing holds up for observation, adoption, the claim CAS, obsolete cleanup and hydration ordering.
One more P3, in a file this PR doesn't touch:
- P3 [func] The placement-machine execution insert sets no token —
crates/libs/placement/machine/src/postgres.rs:204inserts intosandbox_k8s_executionswithoutmaterialization_id. It has no production caller today. Once the wake cutover routes through it, though, every generation it creates stays token-less, even in fenced mode. Before that cutover, bindinitial_token()or route the insert throughinsert_active_execution_claim_in.
- Fixes the stated problem and root cause? Partly. Fenced rows are fenced correctly. But compatibility mode isn't safe for pool-bound rows, and cleanup of pods this request created regressed (both inline P2s).
- Covers every affected caller and path? Partly. Every POST, metadata and placement path is updated, and the compiler enforces that. Mode gating on the pool bind and the placement-machine writer were missed.
- Every change necessary, no scope creep? Yes. Everything serves the token/incarnation feature. The only extras are insta metadata churn and test-only wrappers.
- Do the tests reproduce the failure and prove the fix and edge cases? Partly. The store, adoption, cache-miss, hydration and obsolete-cleanup tests would fail without the fix. Gaps:
- Mock POST responses carry no
resourceVersion, so the 409 on created-pod cleanup is never exercised. - No test changes the node UID during the readiness probe.
- No test covers an old writer during a mixed-version rollout.
- Mock POST responses carry no
- Regression? Yes. Two regressions:
- A created pod can leak when its cleanup DELETE hits a
resourceVersionconflict. - A pool-bound sandbox that an old replica re-materializes during rollout gets persistent 503s.
- A created pod can leak when its cleanup DELETE hits a
Checked and found no problem:
- Tokens never go from Some to None within a generation.
- Lock order matches the terminator: sandbox, then execution, then
FOR SHAREonnodes/node_k8s, with a rollback before the inventory upsert. - Every token/UID predicate is NULL-safe.
- The GET on a reflector miss prevents rotating the token over a live pod.
- A 409 with a matching token adopts the existing pod; a foreign token goes through the revalidated obsolete delete.
- The snapshot diffs are only the new annotation plus insta metadata.
| let params = DeleteParams { | ||
| preconditions: Some(kube::api::Preconditions { | ||
| uid: Some(uid.to_owned()), | ||
| resource_version: pod.resource_version().map(str::to_owned), |
There was a problem hiding this comment.
P2 [func] Cleanup of a pod this request created can 409 on a stale resourceVersion and leak the pod
delete_created_materialization_pod is called with the POST response (ensure_pod.rs:1160, redrive.rs:712 and redrive.rs:723). It goes through delete_observed_pod, which uses that response's resourceVersion as a precondition. Scheduler binding and the first kubelet status write bump the RV shortly after creation.
The failure sequence:
- Persist returns
Ok(None)because ownership was revoked, for example by a teardown racing a slow create. - The DELETE returns 409, which maps to
Unavailable("re-observe before retrying"). - No caller re-observes. The execution row is already retired or replaced, so nothing references the pod again.
I found no GC that reaps it. The orphaned-sandbox GC works from DB rows, and the restore-pod and ContainerCreating GCs only cover those pod states. Before this PR, these paths deleted by name. The tests miss this because the mock POST responses carry no resourceVersion.
Fix:
- Use a UID-only precondition for cleanup of pods this request created; this function's doc already names the UID as the authority. That matches the persist-error path at
ensure_pod.rs:1191(Some((pod_uid, None))). - Keep the RV precondition for the obsolete-token branch of
delete_execution_pod. - Add a test where the POST response's RV is older than the live pod's.
| .bind(source.pod_ip.as_deref()) | ||
| .bind(source.k8s_node_name.as_deref()) | ||
| .bind(route_activation_id) | ||
| .bind(source.k8s_node_uid.as_ref()) |
There was a problem hiding this comment.
P2 [func] Compatibility mode still records k8s_node_uid on pool binds; a mixed-version rollout can then wedge the execution
This insert writes the slot's node UID on a token-less row without checking MaterializationMode. In the default compatibility rollout:
- A new replica binds a pool slot, creating the row
(token NULL, name N, uid A). - The pod dies, and an old replica re-materializes the same generation. Its claim clears only
pod_uid/pod_ip, and its placement update setsk8s_node_namewhenever the name differs. - The row becomes
(token NULL, name M, uid A), with M on a different node incarnation.
From then on, on every new replica:
try_persist_observed_pod_node_placementhitsuid != node_uidand returnsUnavailable("observed pod node differs…").update_active_execution_node_namecan't repair the row, because its UPDATE only touches rows wherek8s_node_uid IS NULL.
So access/start/wake on that sandbox return 503 until its pod dies. That contradicts this PR's own rule in update_active_execution_node_name: old writers may move a name only when "neither token nor incarnation was ever recorded".
Fix:
- Bind
k8s_node_uidhere only when the store is inFencedmode, and NULL otherwise. - Add a Postgres test where an old writer changes the name on a token-less row that has a UID.
|
|
||
| #[derive(sqlx::FromRow)] | ||
| struct PlacementNodeRecord { | ||
| node_uid: String, |
There was a problem hiding this comment.
P3 [check] A NULL node_k8s.k8s_node_uid now fails placement instead of repairing inventory
k8s_node_uid is nullable. The internal-api node bootstrap inserts node_k8s with k8s_node_uid = NULL until node sync fills it in. Decoding that row into node_uid: String returns a database error, so the repair branch in persist_observed_pod_node_placement never runs. Before this PR, the placement transaction upserted the UID itself. This fails closed and only lasts until node sync writes the UID, but placement errors during that window.
Fix: make it node_uid: Option<String>, and treat None like a missing row (roll back and take the existing repair path).
| if cached_node_uid(&self.node_store, &metadata.node_name).as_ref() | ||
| != Some(&node_uid) | ||
| { | ||
| tracing::warn!(slot_id = %slot.id, "warm slot node changed during readiness probe; deferring promotion"); |
There was a problem hiding this comment.
P3 [test] The "node changed during the readiness probe" branch is untested
warming_slots_without_node_identity_still_timeout only covers a missing identity. No test changes the cached node UID between the snapshot and this re-check.
Fix: in observe_warming_slots_retries_not_started_probe_and_promotes_slot, apply a node event with a new UID between probe attempts. Assert that no slot is marked ready and that timeout cleanup still runs.
| match self | ||
| .store | ||
| .persist_pod_metadata( | ||
| .persist_observed_pod_metadata( |
There was a problem hiding this comment.
P2 [func] Reject stale pod addresses after the materialization fence fails
If another API replica recreates the pod on the same node while this replica still caches its predecessor, the node-name comparison above skips placement validation. persist_observed_pod_metadata correctly rejects the predecessor's UID/materialization token with Ok(None), but this branch only logs that rejection and the function still returns the predecessor's pod_ip. Runtime connections and sandbox access callers can therefore use a stale endpoint despite the database fence.
I reproduced this with PostgreSQL and a synthetic pod cache: the successor's database identity stayed intact, while resolve_pod_ip returned the predecessor's IP. Please return None or a retryable error when the identity fence rejects the observation, and add a resolver regression test for same-node recreation with a stale cached predecessor.
Review — 5 findingsMedium
Low
Verified against |
the-gigi-pplx
left a comment
There was a problem hiding this comment.
Approved. 5 non-blocking findings in the review comment above.
Same-generation pod replacement must not inherit an earlier worker identity or publish a stale cached endpoint. Use the prerequisite execution/pool identity schema; rotate materialization tokens for successors and enforce token/Pod UID ownership on observations and cleanup. Preserve materialization history used by backup generation fencing. Repair nullable inventory outside sandbox locks and revalidate afterward. Share token allocation with the placement store. Deploy token-aware compatibility writers before enabling SPACE_POD_MATERIALIZATION_MODE=fenced. Compatibility pool adoption leaves execution Node UIDs unknown. Once tokens are issued, rollback requires token-aware writers. Historical identity is never inferred. Deploy the separate additive schema prerequisite first. Validation: stale-endpoint and scheduler-resource-version regressions failed before the fixes and passed afterward. Kubernetes substrate run: 479 passed; one history assertion was updated for the ownership contract and its four-test suite then passed. Four additional tests could not bind a Linux-style loopback address on macOS. All 56 warm-pool tests passed, including a Node UID change during readiness. Public diff review and Gitleaks scan passed. Affected API, controller, pool, and placement packages passed all-target/all-feature Clippy. Workspace all-target/all-feature Clippy and affected aarch64 Linux musl compilation/lint passed; Linux/KVM live recovery remains unvalidated. AI assistance: Codex contributed implementation, tests, and this description.
| expected, | ||
| ) | ||
| .await; | ||
| assert_eq!(result.is_ok(), allowed && !changed, "{result:?}"); |
| assert_eq!( | ||
| deletes.load(Ordering::SeqCst), | ||
| usize::from(!matches), | ||
| "{result:?}" |
| "{result:?}" | ||
| ); | ||
| if matches { | ||
| assert!(result.is_ok(), "{result:?}"); |
| } else { | ||
| assert!( | ||
| matches!(result, Err(SubstrateError::Unavailable { .. })), | ||
| "{result:?}" |
| assert_eq!( | ||
| result.is_ok(), | ||
| accepted, | ||
| "{recorded_name:?}/{recorded_uid:?}: {result:?}" |
Same-generation pod replacement must not inherit an earlier worker identity or return its stale cached endpoint. Fence execution and pool observations with materialization tokens, Pod UIDs, and recorded Node UIDs. Preserve main's materialization history and repair missing inventory outside sandbox locks before revalidation.
This revision addresses the prior cleanup and readiness feedback: exact-created-pod cleanup uses its UID despite scheduler resource-version changes; obsolete-observation cleanup still requires UID/resourceVersion ownership. Rejected metadata fences cannot return stale IPs. Pool readiness rechecks Node UID, and compatibility adoption leaves execution identity unknown. Token construction is shared with the placement writer and is never accidental through Default.
Deploy token-aware compatibility writers before enabling fenced mode. Once tokens are issued, rollback requires a token-aware compatibility binary. Depends on the schema prerequisite.
Validation: stale endpoint and cleanup regressions failed before correction and passed afterward; all 56 warm-pool tests passed, including Node UID replacement during readiness. The Kubernetes substrate run passed 479 tests, with the changed history assertion corrected and its four-test suite passing; four native failover tests require Linux loopback behavior unavailable on macOS. Workspace all-target/all-feature Clippy and affected aarch64 Linux musl compilation/lint pass. Public diff review and Gitleaks passed.
Stack (land in order): #318 schema → #319 materialization ownership → #345 backup caller identity → #346 upload evidence → #283 retained-artifact recovery.
Rebuilt against
d1abc66fe48bccaaa715e09593f875f7c6c4d11d. The schema is separate because current CI requires migration-only PRs. All existing PRs are reused.Scope boundary: no file overlap with #828 (
2a4988b6), #829 (1b696768), or #837 (58ceff04). Both compositions—#828+#829 and #837+#829—passed aarch64 Linux musl Clippy and all 98 selected snapshot/teardown tests each. These are component tests, not live Firecracker recovery evidence. Capture, encrypted sealing, teardown handoff, and source cleanup remain with those workstreams. Linux/KVM held-volume recovery, manager-restart/handoff failure, and live restore of the same artifact have not been validated here; issuance must remain disabled until that deployment evidence exists.AI assistance: Codex contributed implementation, tests, and this description.