fix(sync): bound full-state ingress reconciliation latency - #1482
rodrigobuenoai wants to merge 9 commits into
Conversation
|
|
Greptile SummaryThis PR replaces the per-Ingress O(n²) reconciliation loop with a full-state batching approach: a configurable quiet-period
Confidence Score: 4/5Safe to merge as a canary behind the opt-in --reconcile-batch-window flag; the non-batch path is unchanged. The core logic is well-structured and backed by solid unit tests covering coalescing, serialisation, retry, and cache short-circuiting. The behavioral change in reconcileAll — dropping routes for ingresses whose dependencies fail during a full-state flush — is a deliberate design trade-off but could manifest as transient downtime in production under rolling restarts or service churn. The defer/ctx.Err() issue is a real code defect (always passes nil) that happens to be harmless today because the fallback value matches the expected value in normal shutdowns. Both findings are non-blocking but should be addressed before the feature is enabled by default. Files Needing Attention: controllers/ingress/batch_coordinator.go (defer/ctx.Err() eager evaluation), controllers/ingress/reconcile.go (routes removed for transiently-failing ingresses during batch flush), and deployment.yaml / config/pomerium/deployment/base.yaml (/busybox/sleep path assumption).
|
| Filename | Overview |
|---|---|
| controllers/ingress/batch_coordinator.go | New trailing-edge batch coordinator. Correctly serialises flushes and coalesces signals, but defer c.finishWaiters(ctx.Err()) evaluates ctx.Err() eagerly at defer time (always nil), so shutdown waiters always receive context.Canceled instead of the actual context error. |
| controllers/ingress/batch_reconcile.go | New batched reconcile path; correctly delegates full-state writes via Submit and handles delete/not-found/unmanaged cases. Ingresses with missing dependencies are excluded from the batch Set, which is a behavioral change from the non-batch path where per-ingress failures leave existing Pomerium config intact. |
| pomerium/ingress_config_cache.go | New fingerprint cache: correctly excludes status/managed-fields from hash inputs, uses RWMutex, handles the uninitialized state, and is fully covered by tests. |
| pomerium/sync.go | Set gains a two-pass validation strategy (fast full-config path, per-ingress fallback) and fingerprint-based early-exit; the configValidationError sentinel type is clean. Cache is updated with fingerprints of all input ingresses including excluded ones, which is correct. |
| controllers/ingress/reconcile.go | reconcileAll extracts shared list+fetch+Set logic; bad ingresses are skipped rather than aborting, which is the source of the transient-route-removal concern for previously-valid ingresses. |
| controllers/reporter/ingress.go | PruneIngressStatuses is well-implemented; RFC 6901 escaping now correctly encodes ~ before / fixing a latent bug in the old IngressDeleted code. |
| controllers/ingress/batch_events.go | Clean helper predicates/mappers for wiring informer signals to the batch coordinator. |
| controllers/settings/controller.go | GenerationChangedPredicate correctly prevents Pomerium spec reconciliation from being re-triggered by the ingress controller's own status writes, breaking a potential tight loop. |
| deployment.yaml | preStop/terminationGracePeriodSeconds added; assumes /busybox/sleep is present at the exact path — needs verification against the container image. |
Sequence Diagram
sequenceDiagram
participant K8s as Kubernetes Informers
participant Pred as batchSignalPredicate / batchSignalMap
participant Rec as reconcileBatched
participant Cache as ingressConfigCache
participant Coord as BatchCoordinator (Start loop)
participant All as reconcileAll
participant Pom as Pomerium (databroker)
K8s->>Pred: Ingress / Secret / Service / Endpoints event
Pred->>Coord: Signal(source)
Pred->>Rec: enqueue reconcile request
Rec->>K8s: Client.Get(ingress)
Rec->>Rec: fetchIngress (secrets, services, endpoints)
Rec->>Cache: NeedsIngressUpdate(ic)
alt fingerprint unchanged
Rec->>K8s: updateIngressStatus (no-op if LB unchanged)
else fingerprint changed
Rec->>Coord: Submit(ctx) [waits for batch result]
Note over Coord: quiet period expires or maxWait reached
Coord->>All: flushReconcileBatch → reconcileAll
All->>K8s: Client.List(ingresses)
loop each managed ingress
All->>K8s: fetchIngress
alt fetch ok
All->>All: append to ics
else fetch fails
All->>K8s: IngressNotReconciled (skipped from config)
end
end
All->>Cache: fingerprintIngressConfigs(ics)
alt fingerprints match cache
Cache-->>All: matches → skip
else
All->>Pom: Set(ics) — fast path (no per-ingress validation)
alt full validation fails
Pom-->>All: configValidationError
All->>Pom: Set(ics) — fallback (per-ingress validation)
end
Pom-->>All: changed, err
All->>Cache: replace(fingerprints)
end
Coord-->>Rec: batch result (err or nil)
Rec->>K8s: updateIngressStatus
Rec->>K8s: IngressReconciled / IngressNotReconciled
end
Comments Outside Diff (2)
-
controllers/ingress/batch_coordinator.go, line 281 (link)Eager evaluation of
ctx.Err()in deferdefer c.finishWaiters(ctx.Err())evaluatesctx.Err()immediately at the point thedeferstatement is reached — beforeStarthas returned — so the argument is alwaysnil.finishWaitershandles this by substitutingcontext.Canceled, meaning all shutdown waiters receivecontext.Canceledregardless of whether the coordinator's context expired withcontext.DeadlineExceeded. The fix is to close overctxand callctx.Err()lazily.Red test:
func TestBatchCoordinatorFinishWaitersReceivesDeadlineExceeded(t *testing.T) { c := newReconcileBatchCoordinator(time.Hour, time.Hour, func(context.Context) error { return nil }) ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(60*time.Millisecond)) defer cancel() submitted := make(chan error, 1) go func() { submitted <- c.Submit(context.Background()) }() // Start blocks until the deadline expires. require.NoError(t, c.Start(ctx)) select { case err := <-submitted: // Fails: got context.Canceled (nil fallback) instead of context.DeadlineExceeded. assert.ErrorIs(t, err, context.DeadlineExceeded) case <-time.After(time.Second): t.Fatal("waiter not resolved on shutdown") } }
-
controllers/ingress/reconcile.go, line 129-141 (link)Batch flush removes routes for transiently-unavailable ingresses
When a batch flush (
flushReconcileBatch→reconcileAll) runs while an ingress's dependency (Service, Secret, Endpoints) is temporarily unavailable,fetchIngressfails and the ingress is skipped fromics. The subsequent full-stateSetreplaces the entire Pomerium config without that ingress, actively removing routes that were previously valid. In the non-batch reconciliation path the equivalent failure only prevents anUpsert, leaving the existing Pomerium config intact. Clusters where service restarts or rolling updates coincide with batch flushes can therefore observe transient 503s until the dependency is restored and a new flush re-includes the ingress.Red test (add to
batch_reconcile_test.go):func TestReconcileAllDoesNotRemoveRoutesForTransientlyMissingDependency(t *testing.T) { reconciler := &batchTestReconciler{tracked: map[types.NamespacedName]int64{ {Namespace: "default", Name: "app-a"}: 2, {Namespace: "default", Name: "app-b"}: 2, }} r, _ := newBatchTestController(t, reconciler) ctx := context.Background() // Prime the cache: both app-a and app-b are applied successfully. _, err := r.reconcileBatched(ctx, ctrl.Request{NamespacedName: types.NamespacedName{ Namespace: "default", Name: "app-a", }}) require.NoError(t, err) require.True(t, reconciler.TracksIngress(types.NamespacedName{Namespace: "default", Name: "app-b"})) // Simulate a transient dependency outage: delete the shared "echo" service. svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "echo", Namespace: "default"}} require.NoError(t, r.Client.Delete(ctx, svc)) // A batch flush triggered by any unrelated event now calls reconcileAll. // app-b cannot be fetched (its service is gone) so it is excluded from ics. _, _, flushErr := r.reconcileAll(ctx) require.NoError(t, flushErr) // FAILS: app-b's routes were removed from Pomerium even though the outage is transient. assert.True(t, reconciler.TracksIngress(types.NamespacedName{Namespace: "default", Name: "app-b"}), "routes for an ingress whose dependency is transiently unavailable must not be removed during a batch flush") }
Reviews (1): Last reviewed commit: "fix(deploy): drain proxy before pod term..." | Re-trigger Greptile
| exec: | ||
| command: | ||
| - /busybox/sleep | ||
| - "30" | ||
| serviceAccountName: pomerium-controller | ||
| terminationGracePeriodSeconds: 10 | ||
| terminationGracePeriodSeconds: 60 |
There was a problem hiding this comment.
preStop exec assumes /busybox/sleep is present
The lifecycle hook runs /busybox/sleep 30 to drain connections before SIGTERM is delivered. If the container image does not include busybox at that path the hook exits with a non-zero code; Kubernetes ignores preStop failures and proceeds with termination immediately, silently defeating the 30-second drain. The same hook appears in deployment.yaml. Consider using a shell built-in (/bin/sh -c 'sleep 30') or verifying the image ships busybox at /busybox/sleep.
Red test (shell-level proof of the silent failure):
# Run inside the pomerium/ingress-controller:v0.32.9 image.
# If /busybox/sleep is absent this exits non-zero and the drain is skipped:
docker run --rm pomerium/ingress-controller:v0.32.9 \
/busybox/sleep 1 \
&& echo "busybox present" \
|| echo "FAIL: /busybox/sleep not found – preStop drain will be skipped silently"|
Addressed the automated review findings in two separate commits:
I also verified Validation completed with Go 1.25.11: full unit/envtest suite, full race suite, and The published dev-canary artifact embeds exactly Pomerium Core |
Summary
This draft is a
v0.32.9-based candidate for a controlled dev canary. It is related to #1477, but does not include that PR's commit history.The current implementation repeatedly copies and validates the growing configuration for every Ingress, causing reconciliation time to grow quadratically.
This change:
Pomerium still receives one complete, validated configuration.
Local validation
go vetpassed.v0.32.9.Proposed dev canary
Repeat cold starts, a three-replica rollout and backend removal in non-production GKE while monitoring:
Keep the current dev image available for immediate rollback.
Related issues
Related to #1477.
Checklist