Skip to content

Partition unassigned pod pool by hashring - #145

Open
scott-rc wants to merge 4 commits into
sc/fix-pod-pool-contentionfrom
sc/partition-pod-pool
Open

scott-rc wants to merge 4 commits into
sc/fix-pod-pool-contentionfrom
sc/partition-pod-pool

Conversation

@scott-rc

@scott-rc scott-rc commented May 14, 2026 •

Copy link
Copy Markdown
Contributor

Eliminates the inter-controller race in assignPod that drove the May 12 production incident -- bimodal GetInstance p99 (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, so getUnassignedPods returns 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.

assignPod now uses server-side apply with a controller-IP-suffixed field manager. A real apiserver Conflict during a ring-rebalance window means a peer's field manager already owns the pod; assignPod treats it as a retry-pick signal and loops back to a different candidate after a short jittered backoff. ApplyOptions.Force stays 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-patch test-op + goto GET_UNASSIGNED_POD retry loop is gone.

Two concurrent assignPod calls inside the same controller can pick the same pod via getUnassignedPod's random selection. An in-process per-pod reservation (xsync.Map[string, struct{}] keyed by namespace/podName) serializes the SSA path -- the second caller loops back to getUnassignedPod instead of running a same-manager SSA that would silently rewrite the first caller's work. The reservation release defers before the on-error deletePod, so the K8s side is clean before another goroutine can pick the same pod again.

Oneshot getReadyInstance forwards to the function-responsible controller when this controller is not ring.Get(fn), mirroring scale'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 an applies/success metric that counts SSA Apply actions per successful assign. 1.000 proves each assign runs exactly one SSA -- no retryPick loop-back and no conflict-driven outer retry on this fake. The bench pins GOMAXPROCS to numControllers so b.RunParallel uses 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, and managedFieldObjectTracker (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 its LoadOrStore check.
  • TestAssignPod_RecoversFromSSAConflict -- a reactor injects synthetic field-manager Conflict responses; asserts assignPod loops onto a different candidate instead of exhausting a futile same-apply retry budget.
  • TestAssignPodRejectsAlreadyAssigned updated 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, and applies/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 oneshot GetInstance to the responsible peer instead of running assignPod locally.

Stacked on #144 (multi-controller bench harness), which is stacked on #143 (router in-flight counter fix).

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.
@scott-rc

scott-rc commented May 14, 2026 •

Copy link
Copy Markdown
Contributor Author

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.
Comment thread internal/controller/pod.go
Comment thread internal/controller/pod.go Outdated
scott-rc added 2 commits May 14, 2026 18:47
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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ 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...)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2f3e095. Configure here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant