Repository navigation
feat: replace pooled Trino instances before voluntary node disruption - #1243
Conversation
Test Impact PlanDeterministic summary of how this PR changes tests, CI runners, and coverage-risk signals. Summary
Signals
Coverage risk: neutral or increased No coverage-reduction warnings detected. |
bill-ph
left a comment
There was a problem hiding this comment.
Review
I read the PR description and the full diff, then the surrounding code in repo/: the planner (controlplane/trinopool/plan.go), the reconcile/progress/durable-step paths (trino_pool_operator.go, trino_pool_progress.go, trino_pool_durable.go, trino_pool_failure.go), the new node guard and evidence store, the recovery API projection, and the migration. The design is conservative and matches the described boundaries: default-off, no node deletion or pod eviction, UID/resourceVersion-bound cordon, first-write-wins evidence under the pool fence, Gateway drain/retirement still authoritative for deletion. The planner change correctly requires a serving replacement (serving > DesiredInstances) before draining a node-replacement instance, and the admission race/read-back handling is sound.
P1
- A candidate on an unsafe node can stay in
PREPARINGand wedge the whole pool's lifecycle.validateCandidateonly fails the candidate wheninstance.NodeReplacementEvidence != nil; the newcandidateNodeCheckerrors are returned as(false, err)and are treated as retryable. IfinspectreportserrTrinoCandidateNodeReplacement(drift/deletion observed afterprotectPoolNodesran, or on a node whoseNodeClaimis missing/mismatched) orerrTrinoUnschedulableNodewithout a Drifted/Deleting condition, the candidate never advances, andPlanNextreturnsPlanActionNonewheneverpreparing > 0. That suppresses every create — including capacity repairs — and every drain/rollout for the pool until the node situation changes. The evidence branch shows the intended remedy (fail the candidate so it is cleaned up and replaced); the error branch should do the same for the replacement-needed class instead of returning a transient error. This contradicts the PR's "fresh candidates reject unsafe nodes" claim: they are blocked, not rejected.
P2
-
Incomplete pod inventories are reported as reconcile faults during normal scheduling.
inspectrequiresscheduled == replicasfor both Deployments and otherwise returnserrTrinoPodProtectionIncomplete. A freshly created instance (CREATING/PREPARING) has pods that are not yet scheduled, soprotectPoolNodesrecords a failure,reconcileOncereturns/logs it and marks the instanceRolloutBlockedon every tick until scheduling settles. Since the feature is opt-in this is not a default-path problem, but it turns a healthy rollout into a stream of "reconcile failed" warnings/metrics. Consider treating "pods still being scheduled" as a wait rather than an error. Relatedly, when a node-protection error does block a serving instance, the planner's fallback reason is "a serving instance has unreadable rollout evidence; repair its record", which is misleading for a node-lookup failure. -
No operator-level positive test for the record/cordon path. Every test either pre-seeds
NodeReplacementEvidenceon the instance or installs a guard backed by an empty fake clientset, so nothing exercisesprotectPoolNodesdiscovering drift/deletion frominspect, persisting the first evidence under the fence, and then the planner acting on it. Add a test with a DriftedtrinoNodeClaimthat asserts the evidence is recorded first-write-wins and that the instance becomes a create/drain candidate. -
The new
node_replacementprojection is only key-set tested. The admin unit test only covers the nil case (len(row) == 4), and the e2e harness only relaxes its key-set assertion. Since the populated object now exposes internal node and NodeClaim names/UIDs, add a unit assertion that a valid evidence row is projected correctly and still passes the existing sanitization checks.
No P0 issues found.
— Robo Bill v2 (deepseek-flash, high reasoning)
412bbd3 to
02b8704
Compare
There was a problem hiding this comment.
Review
I read the PR description, the combined diff (f1aae3b..02b8704, including the follow-up commit that answers the earlier round), and the surrounding code in repo/: the guard (trino_pool_node_guard.go), the disruption orchestration (trino_pool_node_disruption.go), the reconcile/plan/progress paths (trino_pool_operator.go, trinopool/plan.go, trino_pool_progress.go), the fenced evidence writer and migration 000043, the admin projection, and the runbook.
The earlier round's four points are addressed: definitiveTrinoNodePlacement now fails a stuck candidate instead of wedging PlanNext behind preparing > 0; errTrinoPodProtectionIncomplete is treated as convergence for CREATING/PREPARING; TestTrinoNodeObservedReplacementPersistsAndUsesNormalRollout covers observe → persist → cordon → surge → drain end to end; and TestTrinoRecoveryInstancesExposeTypedNodeReplacementEvidence asserts the populated projection and re-runs the sanitization checks. I re-verified the parts most likely to break: nodeSafeAdmission's GetMember read-back is safe because coordinator_pod_uid, coordinator_boot_id and gateway_incarnation are all persisted at registration before PREPARING, so the identity comparison does not spuriously fail; the ACTIVE branch replays through the same Step id, so idempotency holds; the cordon patch's uid + resourceVersion preconditions and the if node.Spec.Unschedulable { return nil } short-circuit correctly avoid clobbering a foreign cordon's annotation; and RecordTrinoPoolNodeReplacement is genuinely first-write-wins under the pool fence.
The issues below are all in the guard's error handling, not in the durable or Gateway-facing state machine.
P1
- A node-protection error suppresses the rollout of an instance whose replacement was already accepted and cordoned.
applyPlansetsview.RolloutBlocked = truefor any instance present ino.nodeProtectionErrors, with no exception for an instance that already carries valid, durablenode_replacement_evidence(controlplane/trino_pool_operator.go:559).outdatedServingskipsRolloutBlockedinstances, so while the error persists the planner will neither surge the replacement (rule 5 needslen(outdated) > 0) nor drain the original (rule 4) — it falls through to"a serving instance has unreadable rollout evidence; repair its record"and does nothing. Any transientNodes().Getfailure, NodeClaim list error, or momentarily unscheduled worker pod on the drifted instance therefore stalls a replacement the controller has already committed to and cordoned for, which is in tension with the runbook's "Recorded replacement is deliberate: it completes even if drift later clears." The intended tolerance already exists one layer up —protectPoolNodesclearserrTrinoUnschedulableNodeonceinstance.NodeReplacementEvidence != nil(trino_pool_node_disruption.go:59) — it just is not extended to theRolloutBlockeddecision. Suggest gating line 559 on the evidence being absent or unparseable, so only unreadable evidence blocks rollout.
P2
-
Drift evidence observed in the same pass is discarded when the pod inventory is incomplete.
inspectdeliberately returns(evidence, errTrinoPodProtectionIncomplete)(trino_pool_node_guard.go:242), butprotectPoolNodesonly records whenerr == nil(trino_pool_node_disruption.go:62), so a serving instance that is one unscheduled worker short never persists drift it has already proven. Combined with (1) that makes the partially-degraded instance — the one most likely to be sitting on a node that is going away — the one that never gets a replacement request. Recording evidence is a pure observation and is safe to do even when the inventory is incomplete; only the cordon needs the stricter gate. -
errTrinoUnschedulableNodereturns from the middle of the node loop, so an unrelated cordon can mask drift.inspectiterates nodes in sorted name order and bails out on the first unschedulable-and-undrifted node (trino_pool_node_guard.go:236). If an instance's pods span an admin-cordoned nodea-...and a Drifted nodez-..., the drift onz-...is never reached and no evidence is ever recorded — whether the instance gets replaced depends on node-name sort order. Collecting evidence across all nodes first and only then reporting the unschedulable condition would make this order-independent. -
A node that is not owned by a
karpenter.sh/v1NodeClaim turns every candidate into a create → fail → recreate cycle.errTrinoNodePlacementInvalidis now "definitive", sovalidateCandidatecallsfailCandidate(controlplane/trino_pool_progress.go:296).PhaseFailedPreparingis terminal, so it stops occupying capacity andPlanNextimmediately creates another instance, which lands on the same kind of node and fails again — an unbounded churn of Deployments/ConfigMaps/Services and instance rows rather than a stable wait. This is the right trade against the previous wedge, but it makes "every node eligible for pool pods is owned by a Karpenter v1 NodeClaim" a hard prerequisite that the runbook's "Enable safely" section does not list alongside the RBAC and annotation steps. Worth calling out explicitly, and/or bounding the retries. -
Cluster-scoped NodeClaim listing cost per tick.
trinoPoolReconcileIntervalis 5s. EachprotectPoolNodespass does one paginated NodeClaim list (up to 20 requests at the 500/page, 10k cap), andcandidateNodeCheck/nodeSafeAdmissioneach callg.reset()and re-list, so a tick with a PREPARING or VALIDATING candidate does two or three full inventories. On a large cluster that is a sustained cluster-scoped list load added per pool operator, and the runbook's "One paginated NodeClaim inventory is shared within the pass" is accurate only for the protection pass. Caching the inventory for the whole tick rather than perreset()would remove most of it.
No P0 issues found. Agreed with keeping this a draft until the live pod-protection harness and an isolated physical replacement test have actually run — the unit coverage here is good but, as the description says, it does not establish the zero-loss property.
Inline comments
controlplane/trino_pool_operator.go:559— P1: this blocks rollout even for an instance that already has valid, durablenode_replacement_evidenceand a cordoned node.outdatedServingskipsRolloutBlockedinstances, so a transientNodes().Get/NodeClaim-list failure or a momentarily unscheduled worker stalls both the surge create and the drain of a replacement that was already accepted — the planner falls through to "a serving instance has unreadable rollout evidence".protectPoolNodesalready encodes the opposite intent by clearingerrTrinoUnschedulableNodeonce evidence exists. Suggest only settingRolloutBlockedhere when the evidence is absent or unparseable.controlplane/trino_pool_node_disruption.go:62— P2:inspectintentionally returns(evidence, errTrinoPodProtectionIncomplete)(guard.go:242), but this only records whenerr == nil, so drift already proven for a serving instance is thrown away whenever one worker pod is unscheduled. Recording is a pure observation and is safe here; only the cordon below needs the stricter gate.controlplane/trino_pool_node_guard.go:236— P2: this returns from the middle of the sorted node loop, so an unrelated unschedulable node whose name sorts first hides aDriftednode later inorderedand no evidence is ever recorded. Whether the instance gets replaced then depends on node-name ordering. Collecting evidence over all nodes and reporting the unschedulable condition only afterwards makes this order-independent.controlplane/trino_pool_progress.go:296— P2:errTrinoNodePlacementInvalidis definitive, andPhaseFailedPreparingis terminal, so it frees its capacity slot andPlanNextcreates another instance right away. On a node that is not owned by akarpenter.sh/v1NodeClaim that becomes an unbounded create → fail → recreate cycle instead of a wait. Better than the previous wedge, but it makes "all nodes eligible for pool pods are Karpenter v1 NodeClaim-owned" a hard prerequisite that the runbook's "Enable safely" list does not mention — worth documenting and/or bounding the retries.
— Robo Bill v2 (opus, high reasoning)
Summary
Add default-off handling for voluntary Karpenter node replacement in shared Trino pools. Legacy Trino is unchanged.
Validation completed
just lintpassed with an isolated cache; Kubernetes-tagged changed-code lint passed.Latest review and CI follow-up
Live validation still pending
The opt-in live pod-protection harness has not been run, and no physical Karpenter/EC2 replacement or zero-loss client test has been performed. Unit simulations do not establish that guarantee. This PR must not be represented as production-validated.
The required cluster-scoped Node patch / NodeClaim read permissions are approved and prepared in charts #16443. Keep that activation PR unmerged until the prerequisite source image and migration have fully rolled out. No live permission changes or node mutations were performed here. Deploy the new Duckgres image to every eligible control-plane replica before enabling the chart flag. New blueprint pod annotations are chart-owned; the source flag defaults to false.
Hardware failures, forced pod/node deletion, interruption deadlines and finite NodeClaim termination grace remain outside the zero-loss claim. Once accepted, a durable replacement request is not canceled by disabling new observation. Owned cordons are deliberately not automatically removed; the runbook covers drift clearing while unrelated workloads remain.