Skip to content

refactor: stop no-op reconcile writes that churn resourceVersion at scale - #32

Merged
melancholictheory merged 1 commit into
mainfrom
refactor/reconcile-hygiene
Aug 3, 2026
Merged

refactor: stop no-op reconcile writes that churn resourceVersion at scale#32
melancholictheory merged 1 commit into
mainfrom
refactor/reconcile-hygiene

Conversation

@melancholictheory

Copy link
Copy Markdown
Owner

Every ensure* helper reconciled with existing.Spec = desired.Spec; Update() unconditionally, so an unchanged object got rewritten on every reconcile. At fleet scale that is constant resourceVersion churn and audit-log spam, plus reconcile amplification back through the informer. The upstream community operator hit the same thing in production (valkey-io/valkey-operator #315).

Each Update is now gated by the tool that fits the object's shape.

  • Service ports, NetworkPolicy spec use exact DeepEqual. The builder fully specifies these (Protocol, PolicyTypes) and the API server defaults nothing inside them, so DeepEqual both skips a no-op and catches a shrink (turning metrics off drops a port).
  • StatefulSet and backup CronJob use a desired-hash annotation (appliedSpecHash). Their pod templates carry both API-server defaults (DeepEqual would churn on them) and operator-owned lists that can shrink, like a metrics sidecar or a TLS volume being removed. Hashing the desired value ignores defaults and still detects a removal; DeepDerivative would treat the shorter list as an already-satisfied prefix and silently skip it.
  • PodDisruptionBudget uses DeepDerivative. It has no operator-owned list that can shrink.
  • ServiceMonitor stays unconditional (documented in-line): its desired spec is a map[string]string inside an unstructured map[string]interface{}, so a structural compare is a type mismatch. It is one object per cluster, so the write is cheap.

Why the mix rather than one strategy

No single check works everywhere. DeepEqual churns on server-defaulted fields, DeepDerivative silently skips a list shrink, and the pod-template resources have both problems at once. The hash annotation is the one gate that handles defaults and removals together.

Verification

  • Unit tests for every gate: a no-op reconcile leaves resourceVersion stable, a real change updates, and a list shrink (metrics port, exporter sidecar, TLS volume) is applied rather than skipped.
  • Full envtest + webhook suites and make lint are clean.
  • Live on a throwaway cluster: across five reconcile intervals a steady-state cluster's Service and NetworkPolicy resourceVersion and the StatefulSet generation do not move.

A cross-model review (Grok) shaped this. It caught the Service/NetworkPolicy shrink bug and that the first StatefulSet gate was unsafe on sidecar removal, which is what pushed the StatefulSet and CronJob paths onto the hash approach.

Out of scope, tracked separately: ensureConfigMap still updates unconditionally.

…cale

Every ensure* helper did `existing.Spec = desired.Spec; Update()` unconditionally,
so an unchanged object was rewritten on EVERY reconcile — bumping resourceVersion
and emitting an audit event each pass, and amplifying reconciles back through the
informer. That is real API-server load at 500+ instances (design-review Q3), the
same churn the upstream community operator hit in production (valkey-io #315/#272).

Gate each Update, picking the tool that fits the object's shape:

- Service ports, NetworkPolicy spec: exact DeepEqual. The builder fully specifies
  these (Protocol/PolicyTypes included) and the API server defaults nothing inside
  them, so an exact compare skips no-ops AND catches a shrink (turning metrics off
  drops a port).

- StatefulSet, backup CronJob: a desired-hash annotation (appliedSpecHash). Their
  pod templates carry BOTH server defaults (DeepEqual would churn) AND
  operator-owned lists that can shrink — a metrics sidecar or TLS volume removed
  (DeepDerivative would prefix-match and silently skip the removal). Hashing the
  desired value ignores defaults and detects removals.

- PodDisruptionBudget: DeepDerivative (resourceSettled) — no shrinkable list.

- ServiceMonitor: left unconditional (documented): its desired spec is
  map[string]string inside an unstructured map[string]interface{}, so any
  structural compare is a type mismatch; it is one object per cluster.

Cross-model review (Grok) drove the shape of this: it caught the Service/NP
prefix-match shrink bug and that the first StatefulSet gate was unsafe on
sidecar removal, which pushed the STS/CronJob paths to the hash approach.

Verified live on a dedicated k3d cluster: across five reconcile intervals a
steady-state cluster's Service/NetworkPolicy resourceVersion and StatefulSet
generation do not move (no operator writes), while unit tests prove each gate
still updates on a real change and, crucially, on a list shrink.

Follow-up (out of scope): ensureConfigMap still updates unconditionally.
@melancholictheory
melancholictheory merged commit 26fadab into main Aug 3, 2026
5 checks passed
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