Conversation
Each unassigned pod now hashes (by name) to one controller in the existing ring. getUnassignedPods filters to pods this controller owns and falls back to the unfiltered pool only when this controller has zero owned candidates -- the cohort-transition window or an unlucky hash distribution where the deployment pool is smaller than the controller fleet. With non-overlapping partitions, controllers no longer race on the same K8s object under steady-state contention, and the JSON-patch test-op + goto retry loop in assignPod is gone. assignPod now uses server-side apply with a controller-IP-suffixed field manager, wrapped in retry.RetryOnConflict so a real apiserver Conflict during a ring-rebalance window resolves automatically instead of crashing the assignment. Intra-controller concurrency is serialized through a per-pod reservation map -- two assignPod calls inside the same controller that happen to pick the same candidate loop back through getUnassignedPod for a different pod rather than both running SSA against the same object (the fake clientset does not enforce field-manager conflicts when the manager name matches, and on the real apiserver same-manager applies just silently rewrite each other). Oneshot getReadyInstance forwards to the function-responsible controller when this controller is not the owner, mirroring scale's forwarding pattern. The entry-point controller's partition may be empty for the oneshot's function, so without forwarding the oneshot path would degrade to the empty-partition fallback every call. The Phase 1 benchmark gains an applies/success metric that counts SSA Apply actions per successful assign; 1.0 proves the retry path is unentered. The bench pins GOMAXPROCS to numControllers so b.RunParallel uses one goroutine per controller -- the production- shaped contention scenario, not the test host's GOMAXPROCS-bounded mutex queue depth. The contention-removal benefit (the May 12 incident's bimodal p99 of 3-7 min driven by 40:1 patch-failure retries) cannot be measured on fake.NewClientset because all patches serialize through the fake's single tracker mutex; production validation remains the real evidence per the design.
|
This change is part of the following stack: Change managed by git-spice. |
Three tests cover the cross-controller behaviors the single- controller suite cannot exercise: - TestMultiControllerContention: N controllers race on a shared unassigned pool for the same function. The partition keeps the K8s objects disjoint per controller, so concurrent assignPod calls return distinct pod names with zero errors and the Apply counter records exactly one apply per success. - TestRingRebalanceReclaim: a controller leaves the ring from the survivor's perspective and the survivor's partition grows to include previously-not-owned pods. The survivor can assign a previously-orphaned pod and the assigned pod is removed from the unassigned pool (no double-assign). - TestOneshotForwarding: an entry-point controller that is not the function-responsible controller forwards the oneshot GetInstance via the controller client, returning the responsible controller's assigned instance instead of running assignPod locally against its (likely empty) partition. The tests reuse newMultiControllerFixture (added in the Phase 1 bench harness) so the only new test infrastructure is the file itself.
Four small follow-ups from the Phase 4 review pass: - Cache the SSA field manager string on the Controller. The assignment hot path was re-concatenating "skipper/controller-" with the controller's PodIP on every assignPod call. - Add a missing-ReplicaSet test for assignPod. The owner- references guard introduced when the JSON-patch test op went away had no direct coverage; the new test seeds a pod with no owner references and asserts assignPod fails fast with the descriptive error. - Clone the lister-derived slice on the empty-partition fallback path of getUnassignedPods. No live bug -- the current caller is read-only -- but matches the discipline of the partition-non-empty path so a future mutating caller doesn't reach the informer's backing. - Switch TestMultiControllerContention's reactor counters from mutex-guarded int64 to atomic.Int64, matching the bench instrumentation in pod_bench_test.go.
Cursor Bugbot flagged that retry.RetryOnConflict around the SSA apply cannot resolve a field-manager Conflict without ApplyOptions.Force, and Force would defeat the partition guarantee. Translate Conflict into retryPick so assignPod outer-loops onto a different candidate; the winning peer's tenant label will be visible to the next lister read. Same bugbot review noted that retryPick currently re-enters getUnassignedPod with no wait, and timer.Poll returns instantly when candidates remain. Add a small jittered context-aware backoff so the intra-controller reservation and cross-controller race paths don't burn CPU until the in-flight SSA settles.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2f3e095. Configure here.
| if responsibleIP != s.ctrl.config.PodIP { | ||
| log.Debug(ctx, "forwarding oneshot to responsible controller", key.ResponsibleIP.Slog(responsibleIP)) | ||
| return s.ctrl.getControllerClient(responsibleIP).Instance(ctx, fn, excludeNames...) | ||
| } |
There was a problem hiding this comment.
Oneshot panics empty hash ring
High Severity
The new ring.Get call in oneshot getReadyInstance panics on an empty ring. This prevents assignPod from using its "own everything" fallback during boot or ring transitions, leading to crashes instead of local assignment.
Reviewed by Cursor Bugbot for commit 2f3e095. Configure here.
| return nil, true, nil | ||
| } | ||
| return nil, fmt.Errorf("failed to patch pod: %w", err) | ||
| return nil, false, fmt.Errorf("failed to apply pod: %w", err) |
There was a problem hiding this comment.
SSA Invalid no longer retries
Medium Severity
The change to server-side apply altered pod assignment retry logic. Previously, invalid patches or failed tests would prompt picking another pod. Now, only API server conflicts cause a retry with a new candidate. Other failures, such as a missing ReplicaSet owner or an invalid apply, are now terminal, potentially blocking function scale-up if a problematic pod is chosen.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 2f3e095. Configure here.


Eliminates the inter-controller race in
assignPodthat drove the May 12 production incident -- bimodalGetInstancep99 (0.06ms p50, 3-7 min p99) and a 40:1 patch-failure ratio under cohort-transition contention.Design
Each unassigned pod hashes (by name) to one controller in the existing
hashring, sogetUnassignedPodsreturns only pods this controller owns. Non-overlapping partitions mean no two controllers attempt to patch the same K8s object under steady state. An empty-partition fallback returns the unfiltered pool when this controller has zero owned candidates (cohort-transition window, unlucky hash distribution, deployment pool smaller than the controller fleet) so the function-responsible controller doesn't starve.assignPodnow uses server-side apply with a controller-IP-suffixed field manager. A real apiserverConflictduring a ring-rebalance window means a peer's field manager already owns the pod;assignPodtreats it as a retry-pick signal and loops back to a different candidate after a short jittered backoff.ApplyOptions.Forcestays unset -- forcing would steal the peer's assignment and defeat the partition guarantee, and retrying the same apply without it is futile, so a loop-back onto another pod is the only correct resolution. The JSON-patchtest-op +goto GET_UNASSIGNED_PODretry loop is gone.Two concurrent
assignPodcalls inside the same controller can pick the same pod viagetUnassignedPod's random selection. An in-process per-pod reservation (xsync.Map[string, struct{}]keyed bynamespace/podName) serializes the SSA path -- the second caller loops back togetUnassignedPodinstead of running a same-manager SSA that would silently rewrite the first caller's work. The reservation release defers before the on-errordeletePod, so the K8s side is clean before another goroutine can pick the same pod again.Oneshot
getReadyInstanceforwards to the function-responsible controller when this controller is notring.Get(fn), mirroringscale's forwarding pattern. Without forwarding, the entry-point controller's partition is empty for the oneshot's function on every call and the fallback runs every time.Bench
The multi-controller
BenchmarkAssignPodContention(added in #144) gains anapplies/successmetric that counts SSAApplyactions per successful assign.1.000proves each assign runs exactly one SSA -- noretryPickloop-back and no conflict-driven outer retry on this fake. The bench pinsGOMAXPROCStonumControllerssob.RunParalleluses one goroutine per controller, matching the production-shaped contention scenario.The contention-removal benefit itself cannot be measured on
fake.NewClientset: all patches serialize through the fake's single tracker mutex regardless of design, andmanagedFieldObjectTracker(k8s 1.30+) makes per-call SSA cost higher than per-call JSON-patch cost on the fake. The May 12 production data remains the real evidence per the design doc.Tests
Single-controller correctness:
TestPodHashKey_StableHash,TestPodOwnedByMe_*,TestPodPartition_StableDeterministicMapping-- partition correctness.TestGetUnassignedPods_FiltersToOwned,TestGetUnassignedPods_FallsBackWhenPartitionEmpty-- filter behavior including the empty-partition fallback.TestAssignPod_IntraControllerReservation-- 10 goroutines, 10 pods, asserts every pod is assigned exactly once. Verified red without the reservation by toggling itsLoadOrStorecheck.TestAssignPod_RecoversFromSSAConflict-- a reactor injects synthetic field-managerConflictresponses; assertsassignPodloops onto a different candidate instead of exhausting a futile same-apply retry budget.TestAssignPodRejectsAlreadyAssignedupdated to assert the unassigned-pool filter excludes already-assigned pods (no global selector swap, parallelizes cleanly).Multi-controller correctness:
TestMultiControllerContention-- N controllers race on a shared unassigned pool, asserts distinct pods, no errors, andapplies/success == 1(one SSA per success, no retries).TestRingRebalanceReclaim-- a controller leaves the ring from the survivor's perspective, the survivor's partition grows, and the survivor can assign one of the orphan-share pods with no double-assign.TestOneshotForwarding-- a non-responsible controller forwards the oneshotGetInstanceto the responsible peer instead of runningassignPodlocally.Stacked on #144 (multi-controller bench harness), which is stacked on #143 (router in-flight counter fix).