Skip to content

fix(controller): stop Executor status feedback loop and retry on conflict - #2830

Open
fseldow wants to merge 2 commits into
notaryproject:mainfrom
fseldow:fix/2797-executor-status-concurrent-writes
Open

fix(controller): stop Executor status feedback loop and retry on conflict#2830
fseldow wants to merge 2 commits into
notaryproject:mainfrom
fseldow:fix/2797-executor-status-concurrent-writes

Conversation

@fseldow

@fseldow fseldow commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes part of #2797.

When the provider deployment is scaled beyond replicas: 1, every pod runs its own ExecutorReconciler and they all write the same Executor.status concurrently. This PR lands the two minimal / immediate remediations from the issue:

  1. Cut the status feedback loop. Add builder.WithPredicates(predicate.GenerationChangedPredicate{}) to the For(&Executor{}) watch. Status-only writes don't bump metadata.generation, so they no longer re-trigger Reconcile. This stops the reconcile→status-write→reconcile loop that repeatedly rebuilds the in-memory executor and hammers external providers (e.g. Azure Key Vault) — amplified ×N across replicas.
  2. Stop swallowing write conflicts. Wrap the status write in retry.RetryOnConflict, re-fetching the object to pick up the latest resourceVersion before re-applying status. Previously an HTTP 409 was only logged, so the losing writer's update was silently dropped.

Added unit tests (fake client + interceptor) covering the retry-on-conflict path and the error-recording path.

Intentionally out of scope

Remediations 3–5 in the issue (leader election, per-pod readiness/metrics, Gatekeeper-style ExecutorPodStatus) are not included here. The full per-pod approach is proposed separately as an alternative direction.

Testing

  • go build ./..., go vet ./internal/controller/...
  • go test ./internal/controller/ -run TestUpdateStatus ✅ (new unit tests)

web-flow and others added 2 commits July 26, 2026 18:06
…lict

Address the concurrent-writer problems described in notaryproject#2797 for the
ExecutorReconciler when the deployment is scaled beyond a single replica.

- Add GenerationChangedPredicate to the Executor watch so status-only
  updates no longer re-trigger Reconcile. This cuts the reconcile
  feedback loop (and the resulting executor rebuild storms that hammer
  external providers such as Azure Key Vault), amplified xN across replicas.
- Wrap the status write in retry.RetryOnConflict and re-fetch the object
  before re-applying status, so a lost optimistic-concurrency race
  (HTTP 409) is retried instead of being silently swallowed.
- Add unit tests covering the retry-on-conflict and error-recording paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.55319% with 61 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.02%. Comparing base (0e5f47a) to head (22e54a7).

Files with missing lines Patch % Lines
internal/controller/executor_controller.go 71.56% 18 Missing and 11 partials ⚠️
...nternal/controller/executorpodstatus_controller.go 69.09% 11 Missing and 6 partials ⚠️
internal/manager/manager.go 0.00% 9 Missing ⚠️
internal/podstatus/name.go 80.00% 2 Missing and 2 partials ⚠️
internal/pod/info.go 0.00% 2 Missing ⚠️

❌ Your patch check has failed because the patch coverage (67.55%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2830      +/-   ##
==========================================
- Coverage   76.36%   76.02%   -0.34%     
==========================================
  Files          88       90       +2     
  Lines        3999     4176     +177     
==========================================
+ Hits         3054     3175     +121     
- Misses        799      837      +38     
- Partials      146      164      +18     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Updates Ratify’s Executor reconciliation/status reporting to reduce reconcile amplification and handle concurrent status writers when running multiple replicas, while also introducing a per-pod status CRD and an aggregation controller to represent replica-specific health.

Changes:

  • Filter the Executor watch with GenerationChangedPredicate to prevent status-write feedback loops from re-triggering reconciles.
  • Add conflict-retry behavior around status updates and introduce per-pod ExecutorPodStatus reporting with aggregation into Executor.status.byPod.
  • Extend CRDs/RBAC/manifests and add unit tests for retry/error recording and per-pod aggregation behavior.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/podstatus/name.go Adds reversible name packing/unpacking for per-pod status objects.
internal/podstatus/name_test.go Unit tests for pack/unpack, uniqueness, and DNS1123 character compliance.
internal/pod/info.go Adds pod.Name() helper for reading POD_NAME.
internal/manager/manager.go Wires pod identity into ExecutorReconciler and registers ExecutorPodStatusReconciler.
internal/controller/executorpodstatus_controller.go New controller aggregating ExecutorPodStatus into Executor.status.byPod.
internal/controller/executorpodstatus_controller_test.go Tests per-pod status upserts and aggregation logic.
internal/controller/executor_controller.go Adds generation predicate, per-pod status upsert/delete logic, and conflict-retry for direct status updates.
internal/controller/executor_controller_retry_test.go Tests retry-on-conflict and error recording for direct Executor status updates.
config/rbac/role.yaml Updates RBAC rules to include pods + new executorpodstatuses resources.
config/manager/manager.yaml Injects POD_NAME and RATIFY_NAMESPACE via downward API.
config/crd/kustomization.yaml Adds the new ExecutorPodStatus CRD base to kustomize resources.
config/crd/bases/config.ratify.dev_executors.yaml Extends Executor CRD schema with status.byPod entries.
config/crd/bases/config.ratify.dev_executorpodstatuses.yaml Adds new ExecutorPodStatus CRD definition (namespaced + status subresource).
api/v2alpha1/zz_generated.deepcopy.go Updates deep-copies for ExecutorStatus.ByPod and adds deep-copies for new types.
api/v2alpha1/executorpodstatus_types.go Defines PodStatusEntry, ExecutorPodStatus, and list types.
api/v2alpha1/executor_types.go Adds ByPod []PodStatusEntry to ExecutorStatus.
Files not reviewed (1)
  • api/v2alpha1/zz_generated.deepcopy.go: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +68 to +71
var list configv2alpha1.ExecutorPodStatusList
if err := r.List(ctx, &list); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to list ExecutorPodStatus objects: %w", err)
}
Comment on lines +252 to +258
// briefError truncates an error message to maxBriefErrorLength characters.
func briefError(msg string) string {
if len(msg) <= maxBriefErrorLength {
return msg
}
return msg[:maxBriefErrorLength] + "..."
}
Comment on lines +41 to +49
// PackName returns a deterministic, DNS-1123-compliant object name that embeds
// both the pod name and the executor name. Because the name is unique per
// (pod, executor) pair, no two pods ever target the same ExecutorPodStatus
// object, which eliminates write conflicts. The name is reversible via
// UnpackName so aggregation can recover the executor name even from a delete
// event (where only the object name is available).
func PackName(podName, executorName string) string {
return encode(podName) + "-" + encode(executorName)
}
Comment on lines +123 to +137
// updateStatus records the outcome of the reconcile for this pod.
//
// When the pod identity is known, the outcome is written to a dedicated per-pod
// ExecutorPodStatus object (owned by the pod for automatic garbage collection),
// which a separate aggregation controller folds into Executor.status.byPod.
// This avoids all replicas writing the same Executor.status concurrently. When
// the pod identity is unknown, it falls back to writing the Executor status
// directly (single-writer, e.g. out-of-cluster usage).
func (r *ExecutorReconciler) updateStatus(ctx context.Context, executor *configv2alpha1.Executor, upsertErr error) {
if r.PodName == "" {
r.updateExecutorStatusDirectly(ctx, executor, upsertErr)
return
}
r.upsertPodStatus(ctx, executor, upsertErr)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants