Skip to content

docs(dha): resolve SSA-handoff open question — client-side apply, not omit - #66

Open
scooby87 wants to merge 14 commits into
mainfrom
dha/poc-ssa-handoff-resolved
Open

docs(dha): resolve SSA-handoff open question — client-side apply, not omit#66
scooby87 wants to merge 14 commits into
mainfrom
dha/poc-ssa-handoff-resolved

Conversation

@scooby87

@scooby87 scooby87 commented Sep 2, 2026

Copy link
Copy Markdown

Resolves the one open migration detail in §Upgrade / Open questions of the database-horizontal-autoscaling proposal.

Finding (PoC, 2026-09-02, live on dev9). The platform runs helm-controller v1.5.0, which defaults to server-side apply. Under SSA the chart-only omit handoff the proposal preferred is not safe: (1) any field the chart renders is force-owned by Flux and reverted from the HPA's live /scale value on every apply; (2) the omit handoff needs the HPA to own .spec.instances before the phase-2 apply, but the HPA claims the /scale field-manager only when it actually writes (desired != current) — a cluster idle at its floor never writes, never owns the field, and the phase-2 omit prunes it → CNPG defaults to 1 (quorum-fatal). That prune is unavoidable chart-only and would require a runtime actor this design deleted.

Resolution. The implementation does not omit under active autoscaling; it renders a constant seed instances: max(replicas, effectiveMin) and forces client-side (three-way-merge) apply for the tenant HelmRelease via a per-app annotation release.cozystack.io/helm-server-side-apply: "false". Under client-side apply the constant seed is a merge no-op, so KEDA's live value survives — deterministic and safe across enable/disable/dry-run with no handoff race, and uniform for the multi-engine roadmap. This supersedes §3's "omit the field" on the apply-strategy detail only; the ownership goal (Flux does not contend with the HPA) is unchanged.

Implemented in cozystack/cozystack#3954. Reviewers: Timofei Larkin (@lllamnyp) IvanHunters.

… not omit

The PoC settled the one migration detail §Upgrade left open. On the current
platform (helm-controller v1.5.0, server-side apply by default) the chart-only
omit handoff is not safe: the HPA claims the /scale field-manager only when it
actually writes, so a cluster idle at its floor never owns .spec.instances and
the phase-2 omit prunes it (CNPG defaults to 1, quorum-fatal). The implementation
keeps a constant seed and forces client-side apply per app via
release.cozystack.io/helm-server-side-apply, under which the seed is a merge
no-op and KEDA's live value survives. Records the finding in §Upgrade and marks
the Open Questions item resolved.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 5e14e944-98b3-475a-b7c1-b9fe9dbf27d1


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@IvanHunters

Copy link
Copy Markdown
Contributor

Verdict

NOT LGTM

The resolution this document records does not work: forcing client-side apply does not make a rendered constant a merge no-op, because Helm's three-way merge computes its add-and-change delta from the live object to the new manifest, not from the old manifest to the new one.

Findings

[CRITICAL] design-proposals/database-horizontal-autoscaling/README.md:175, the recorded resolution rests on merge semantics Helm 3/4 does not have

The new bullet's load-bearing sentence is that the chart renders a constant seed and, "under [forced client-side apply] the constant render is a merge no-op and KEDA's live value survives". That is Helm 2 behaviour. Helm 3 changed it deliberately, and Helm 4 (embedded in helm-controller v1.5.0) keeps the change. The add-and-change half of the patch is computed against live state:

// k8s.io/apimachinery@v0.35.0/pkg/util/jsonmergepatch/patch.go:29-42
// Create a 3-way merge patch based-on JSON merge patch.
// Calculate addition-and-change patch between current and modified.
// Calculate deletion patch between original and modified.
func CreateThreeWayJSONMergePatch(original, modified, current []byte, fns ...mergepatch.PreconditionFunc) ([]byte, error) {
	...
	addAndChangePatch, err := jsonpatch.CreateMergePatch(current, modified)

original (the previous manifest) feeds only the deletion half. It has no say in whether an unchanged value gets re-asserted. This is the JSON-merge path, which is the one that applies here: a CNPG Cluster is a custom resource, and Helm's createPatch routes unstructured objects and CRDs away from strategic merge (// Strategic Merge Patch is not supported on objects like CRDs.). The strategic path behaves identically, computing diffMaps(currentMap, modifiedMap, ...) with IgnoreDeletions: true. Helm's own FAQ uses the replica-count case as its worked example of the Helm 2 to Helm 3 change: old manifest and new manifest both say three, live says zero, and "In Helm 3, the patch is generated using the old manifest, the live state, and the new manifest ... so it generates a patch to change the state back to three."

Concretely: seed renders instances: 2, KEDA has grown the cluster to 6, the next Helm upgrade action produces {"spec":{"instances":2}} and CNPG sheds four standbys and their PVCs. That is the same outcome as the SSA force-revert the bullet rejects in its finding (1), at the same frequency, since helm-controller performs a Helm action on any chart or values change and every platform release bumps the tenant chart. So the mechanism chosen to avoid the failure reproduces it.

The irony is that the document already states the correct rule three lines up, in the bullet at :174: three-way merge "deletes a key present in the old manifest and absent from the new one regardless of who last wrote it". Regardless of who last wrote it is exactly the property that also makes a present key get re-asserted regardless of who last wrote it. The original §3 omit design was right about client-side merge semantics; this revision inverts that understanding.

What this needs before it can be recorded as settled: either a mechanism that genuinely leaves .spec.instances unwritten by Flux, or an actor that re-reads the live count into the rendered value. The scale-subresource pin that the Open questions item named as the fallback is still on the table, and marking that item resolved at :219 closes the one place a reader would go looking for it.

What would change my mind: a demonstration that helm-controller v1.5.0 skips the object entirely when the rendered manifest is byte-identical to the previous revision, rather than computing a patch against live. I did not find that path in Helm v4's client-side apply; the patch-is-empty short circuit compares the computed patch, and the computed patch is non-empty precisely when live has drifted.

[MAJOR] design-proposals/database-horizontal-autoscaling/README.md:175, the annotation the resolution names does not exist, and the cited implementation does not force client-side apply

The bullet presents release.cozystack.io/helm-server-side-apply: "false" on the ApplicationDefinition as the mechanism in place. It is not in cozystack:

$ cd /tmp/cozy-ref-3954 && grep -rn 'helm-server-side-apply' .
exit=1
$ grep -n 'helm-server-side-apply' /tmp/pr3954.diff
exit=1
$ gh search code --repo cozystack/cozystack "helm-server-side-apply"
(no results)

pkg/config/config.go:32,40,59 defines exactly three of these annotations: helm-install-timeout, helm-upgrade-timeout, helm-install-disable-wait. There is no consumer for an apply-strategy annotation, and convertApplicationToHelmRelease (pkg/registry/apps/application/rest.go:1632-1650) sets Install{Timeout, Strategy} and Upgrade{Timeout, Strategy} and never touches ServerSideApply on either.

cozystack/cozystack#3954 does not add it. Its head is 48faebc7b (2026-09-01), one day before this doc commit, and it contains no occurrence of serverSideApply at all. It does render the constant seed (instances: {{ include "postgres.autoscaling.activeSeed" . }}), so half of what this document describes is real. The other half is asserted by #3954 in the opposite direction, in a Go test comment it added on 2026-08-31:

// internal/operator/package_reconciler_test.go, TestBuildHelmReleaseSpecNoForceOrDriftDetection
// (Server-side apply is not a HelmRelease knob — the
// helm-controller applies releases with Helm's own three-way merge — so there is no SSA
// field to assert here; that merge behavior IS the invariant these fields would break.)

Server-side apply is a HelmRelease knob, three of them, and the version cozystack pins carries all three:

// github.com/fluxcd/helm-controller/api@v1.5.1/v2/helmrelease_types.go:593-596, 840-846, 1111-1117
	// ServerSideApply enables server-side apply for resources during install.
	// Defaults to true (or false when UseHelm3Defaults feature gate is enabled).
	ServerSideApply *bool `json:"serverSideApply,omitempty"`
	// ServerSideApply enables server-side apply for resources during upgrade.
	// Can be "enabled", "disabled", or "auto".
	// Defaults to "auto".
	ServerSideApply ServerSideApplyMode `json:"serverSideApply,omitempty"`

So the two artifacts disagree with each other, by the same author, a day apart, and neither is reconciled. A design proposal that names a mechanism nobody has built, in present tense, sends the next implementer looking for an annotation that returns no grep hits.

[MAJOR] design-proposals/database-horizontal-autoscaling/README.md:175, "defaults to server-side apply" holds only for releases installed on v1.5+, and §Upgrade is about the ones it does not hold for

The version pin checks out: internal/fluxinstall/manifests/fluxcd.yaml:8097 is image: ghcr.io/fluxcd/helm-controller:v1.5.0, and the only feature gate set on that container is ExternalArtifact=true, so UseHelm3Defaults is off and install.serverSideApply does default to true. The premise is still too broad, because upstream scoped the default to new releases:

This minor release comes with Helm v4 support, server-side apply for Helm releases [...] Apply method is now defaulting to server-side apply for new HelmReleases.
Note that Helm persists the apply method in the release storage, hence why the auto value is an option for upgrade and rollback actions. When set to auto, the controller will reuse the apply method used in the last successful release revision, as recorded in the Helm storage, defaulting to client-side apply. This means that existing HelmReleases will continue to use client-side apply until their .spec is updated with .spec.{upgrade|rollback}.serverSideApply: enabled.
https://github.com/fluxcd/helm-controller/blob/v1.5.0/CHANGELOG.md

The resolution reads as ApplyMethod == "ssa" against Helm's release storage, where the field is empty for anything created before Helm v4 and empty means client-side:

// helm-controller v1.5.0 internal/action/upgrade.go:60-77
	if upgrade.ServerSideApply == "auto" {
		lastRelease, err := config.Releases.Last(releaseName)
		...
		serverSideApply = lastReleaseTyped.ApplyMethod == "ssa"

Since cozystack sets neither field, the platform's behaviour splits by install date. A postgres database that predates the Flux 2.8 upgrade is already on client-side apply and the proposed annotation would be a no-op for it; one created afterwards is on SSA. §Upgrade is the section about enabling autoscaling on an existing database, which is exactly the population the premise is false for. Two databases in the same cluster running the same chart behave differently here, and the document describes one uniform behaviour.

[MAJOR] design-proposals/database-horizontal-autoscaling/README.md:176, the document now asserts both designs, and the omit design still has four other homes

The bullet immediately after the insertion says: "Steady state after migration is correct. With the field absent from both the previous and the current render, three-way merge leaves the HPA-set .spec.instances untouched." Under the constant seed the field is never absent. One line after the correction, the document contradicts it.

Four more sites still teach omit, none of them touched:

  • :89 the §3 heading, "Chart change: stop declaring replicas under autoscaling", plus its illustrative {{- if not .Values.autoscaling.enabled }} code block
  • :101 "With the field absent from the HelmRelease values, Flux neither sets nor reverts it [...] This is what deletes the entire ownership problem — no marker annotation, SSA field manager, admission webhook, or terminal-freeze conflict handling is needed, because there is no contested field."
  • :204 the §Testing chart assertion, "helm template with autoscaling.enabled: true omits the replica field"
  • :241 Appendix finding 4, "Removing that declaration under autoscaling (§3) makes the entire ownership problem disappear", which is the site #3954's own code comment cites as authority

:101 is the sharpest of these, since the new design does both of the things that sentence says are unnecessary: it needs a marker annotation, and it leaves the field contested. Which makes the new bullet's scoping wrong too. It claims to supersede "§3's 'omit the field' on the apply-strategy detail only", but what it actually supersedes is §3's chart change, its code block, its title and its central claim. A reader implementing from this document picks whichever of the two designs they read first.

The append-only shape is what produces this. A targeted rewrite of §3, §Testing, Appendix finding 4 and the two §Upgrade bullets is smaller than the note that now sits on top of text contradicting it, and it leaves the document saying one thing.

[MAJOR] PR-level, a merged proposal is revised with a design change and no decision record

design-proposals/README.md:61:

Update the proposal when the divergence is significant. When the divergence came from a decision worth remembering — an approach that failed, a constraint you discovered, an alternative you picked instead — also write a decision record under that proposal and link it from the proposal's Decisions section. Editing the proposal alone loses the reasoning: the revised text reads as though it always said the current thing, and why the design changed course survives only in the pull-request diff.

The "when to write one" list at design-proposals/README.md:93,95 names this PR twice over: "Implementation contradicted an accepted proposal and the design changed course" and "We hit a constraint that now shapes the design — an upstream limitation, a Kubernetes semantic". There is no design-proposals/database-horizontal-autoscaling/decisions/ directory, the proposal has no Decisions section, and the PR body omits the .github/PULL_REQUEST_TEMPLATE.md "Before review" section entirely, so the template's escape hatch ("or says below why none is needed", "Say which it is; that sentence is the whole check") is not taken either. Precedent for the shape exists in two proposals already: design-proposals/compute-plane/decisions/0001-computeplane-ships-as-an-operator-owned-module.md and design-proposals/decision-records/decisions/0001-decision-records-live-with-their-proposals.md.

This is not paperwork. Two of the three findings above are visible only because the reasoning behind the change is compressed into one bullet: the SSA premise and the merge-no-op premise each get one clause, and neither is separable from the other in the current text. A one-page record with the alternatives it beat is where they would have had to stand on their own.

[MINOR] design-proposals/database-horizontal-autoscaling/README.md:175, the seed is not constant across ordinary values edits

Independent of the finding above, and it survives even if that one is wrong. postgres.autoscaling.activeSeed in #3954 is max(.Values.replicas, effectiveMin) where effectiveMin is max(.Values.autoscaling.minReplicas, .Values.quorum.maxSyncReplicas + 1, 2). Three tenant-settable values feed it, so the render is constant only while all three sit still. Raise autoscaling.minReplicas from 2 to 3 on a cluster KEDA has grown to 6 and the manifest moves 2 to 3: old and new now differ, a patch is generated on anyone's reading of merge semantics, and three standbys go. Raising the quorum floor or replicas does the same.

The bullet claims safety "across enable / disable / dry-run", which enumerates three transitions and omits this fourth one. Raising the floor after watching autoscaling work is an ordinary operator action, not an exotic one. #3954's values documentation acknowledges the dryRun and disable arms of the same hazard; neither it nor this document names the raise-the-floor arm.

[MINOR] design-proposals/database-horizontal-autoscaling/README.md:175, "per-app annotation on the ApplicationDefinition" is per-kind, so it would reach every postgres in every tenant

ReleaseConfig is built once at server start-up from the ApplicationDefinition's annotations (pkg/config/config.go:157,164,194, each field documented as "Populated from the release.cozystack.io/... annotation on the ApplicationDefinition at start-up") and convertApplicationToHelmRelease applies it to every Application of that kind. So an annotation on postgres-rd is per-kind, not per-instance: it would switch off server-side apply for every postgres HelmRelease in the cluster, including the overwhelming majority that never enable autoscaling, to serve a per-instance opt-in feature. "Per-app" reads as per-database, and the blast radius is larger than the phrase conveys.

Worth naming because SSA is load-bearing for that kind on its own account. docs/changelogs/v1.5.0.md:43 records what the platform bought with it: "misplaced chart fields that v2.7 silently dropped are now hard errors (fixed here for foundationdb, kafka, kubevirt-instancetypes, vm-instance, and the platform chart)". Turning it off for postgres restores the silent drop for postgres.

Claim mismatches

[MISSING] "forces CLIENT-SIDE (three-way-merge) apply for the tenant HelmRelease via a per-app annotation (release.cozystack.io/helm-server-side-apply: "false" on the ApplicationDefinition)", the annotation exists nowhere in cozystack/cozystack main and nowhere in #3954's diff; no consumer code reads it; convertApplicationToHelmRelease sets no ServerSideApply field on install, upgrade or rollback.

[MISSING] "under which the constant render is a merge no-op and KEDA's live value survives", refuted. CreateThreeWayJSONMergePatch computes add-and-change from live to new manifest (apimachinery@v0.35.0/pkg/util/jsonmergepatch/patch.go:42), and the strategic path does the same. An unchanged manifest value against drifted live state produces a patch.

[PARTIAL] "The platform's helm-controller (v1.5.0) defaults to server-side apply", the version pin and the install default are correct; the upgrade default is auto, which resolves to the previous apply method and to client-side apply for any release created before Helm v4. Existing databases are not on SSA.

[PARTIAL] "Implemented in cozystack/cozystack#3954", the constant seed is implemented; the forced client-side apply and the annotation are not. #3954 is open, unmerged, and depends on #3951.

[PARTIAL] "deterministic and safe across enable / disable / dry-run with no handoff race", the three named transitions are the ones #3954 handles; an edit to replicas, autoscaling.minReplicas or quorum.maxSyncReplicas on a live autoscaled cluster is a fourth one that moves the rendered value and is not covered.

[UNVERIFIABLE] "Validated live on dev9", this review is hermetic and does not touch clusters, so what ran on dev9 cannot be confirmed or refuted here. The two observations recorded do not span the enable / disable / dry-run matrix the same sentence claims safety across. The second one ("under the forced client-side apply the constant seed is a no-op that preserves the /scale value") is inconsistent with the merge implementation above; the likeliest confound is that no Helm upgrade action occurred during the observation window, since helm-controller applies only on a chart or values change and drift detection is off, in which case the observation shows nothing about the merge.

Caveats

  • Verified and correct, recorded so it is not re-litigated: the bullet's finding (1) mechanism holds where SSA is in play. helm-controller hardcodes force-conflicts to the SSA setting with no user override, install.ForceConflicts = install.ServerSideApply // We always force conflicts on server-side apply. (v1.5.0 internal/action/install.go:53, same line in upgrade.go:77 and rollback.go:93), so a rendered field is overwritten and ownership transferred rather than a conflict being surfaced.
  • Verified and correct: the bullet's finding (2) is right. The HPA's scale write sits entirely inside if rescale { with rescale = desiredReplicas != currentReplicas (pkg/controller/podautoscaler/horizontal.go, checked on master and release-1.33), and the scale write is what transfers managedFields ownership of the replica path on the parent object (apiextensions-apiserver/pkg/registry/customresource/etcd.go via managedfields.NewScaleHandler(...).ToParent(...), which "steal[s] the replicas path from the main resource entry"). No write means no ownership, so a cluster idle at its floor genuinely never owns the field.
  • Verified and correct: CNPG's scale subresource maps to the right field and the quorum-fatal consequence is real. +kubebuilder:subresource:scale:specpath=.spec.instances,statuspath=.status.instances,selectorpath=.status.selector (api/v1/cluster_types.go), and Instances carries +kubebuilder:default:=1, rendered as default: 1 in the CRD.
  • The HPA's resulting field-manager name is an inference, not a quotation. It is assembled from AddUserAgent plus prefixFromUserAgent and comes out as the controller-manager binary name rather than horizontal-pod-autoscaler. Nothing in this review turns on the name, only on whether a write happens at all.
  • Phases keyed on rendering and packaging are genuinely not applicable to this diff and were not run in chart form: no chart, values, template, RBAC, cozyrds, PackageSource, bundle, image reference or migration is touched, chart_lint and shell_lint are empty, and the mechanical anti-pattern sweep (swallowed error on a decision command, over-broad RBAC verb, NotFound conflated with can't-ask, fixture validity) has no surface in a markdown file. Their intent was run against the claim surface instead, which is where findings 1 through 3 come from.
  • The upgrade analysis here is documentary, not executed. Nothing in this PR reaches a cluster, so there is no upgrade to replay; the upgrade-shaped defect is that the document's premise about existing releases is wrong, not that this PR breaks one.
  • DCO sign-off is present on 6960d71 and the commit subject follows Conventional Commits. Reviewed at 6960d7109e642a77411c546f332344d481115b3f against merge-base ebbd9d44211f48a9cf0bd44f906060321adbef39.

Recommended follow-ups

  • Re-derive the resolution against Helm's actual client-side patch construction before recording any resolution, then write the outcome as a decision record under design-proposals/database-horizontal-autoscaling/decisions/0001-*.md with the alternatives it beat, and link it from a new Decisions section.
  • Until that lands, leave the Open questions item at :219 open. The scale-subresource pin it names as the fallback is still the surviving candidate and the strike-through hides it.
  • The apply-strategy question needs settling on a disposable dev stand rather than in a document: take a CNPG Cluster whose live .spec.instances has been raised out of band, run a helm-controller upgrade with the chart rendering the lower constant, and record whether the live value survives on each of the four combinations (install-era SSA or client-side, times annotation present or absent). That belongs in a cozystack-pr-test run against #3954, not here.

…n 0001

Address review on #66: the append-only "Implementation update" note asserted the
new seed+client-side design on top of §3/§Testing/Appendix text that still taught
the omit design, so the document said both things at once. Replace it with a
targeted rewrite that leaves the proposal saying one thing — §3 now describes the
constant seed applied client-side, §Upgrade reframes the enable/steady-state
bullets around it (no field-deletion handoff), §Testing asserts the seed render
plus the client-side apply invariant, the Open-questions item and Appendix
finding 4 point at the record, and Alternatives lists the omit design as rejected.

Add decision record 0001, which is where the reasoning now lives: why chart-only
omit does not survive helm-controller v1.5.0 (SSA force-revert of rendered fields;
idle-at-floor prune to CNPG's default of 1), and why forcing client-side apply
makes a constant seed safe — refuting the review objection that Helm's three-way
merge re-asserts the constant from live. Helm v4 routes unstructured/CRD objects
through a two-way CreateMergePatch(original, modified) unless
threeWayMergeForUnstructured is set, which helm-controller v1.5.0 never sets;
verified empirically on Helm 4.0.4.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
@scooby87

scooby87 commented Sep 2, 2026

Copy link
Copy Markdown
Author

Thanks for the depth here — this is exactly the kind of review that's worth answering carefully. I dug into each point; the summary is that the CRITICAL does not hold, but several of the surrounding findings do, and I've acted on all of them.

On the CRITICAL — client-side apply does preserve the seed

The objection rests on CreateThreeWayJSONMergePatch computing add-and-change from live. That function is real, but it is not the path Helm takes here by default. Helm v4's createPatch (helm.sh/helm/v4 pkg/kube/client.go) branches for unstructured/CRD objects on a threeWayMergeForUnstructured flag:

  • flag false (the default)jsonpatch.CreateMergePatch(oldData, newData), a two-way patch between the previous rendered manifest and the new rendered manifest. Live state is not read, so an unchanged constant produces no patch.
  • flag true → the CreateThreeWayJSONMergePatch(original, modified, current) path you quoted, which does consult live.

helm-controller v1.5.0 never sets that flag — there is no ThreeWayMergeForUnstructured reference anywhere in the controller — so the default two-way path applies to the CNPG Cluster.

I verified this empirically rather than trusting the read, using Helm 4.0.4 (the same SDK helm-controller v1.5.0 embeds) against a real custom resource (the JSON-merge path, not a built-in strategic-merge type):

apply mode chart change live instances after
client-side (--server-side=false) none preserved
client-side an unrelated field changed (your "every platform release bumps the chart" case) preserved
SSA --force-conflicts (Flux's default) unrelated field changed reverted

So the second row is precisely the scenario in your finding, and the constant survived. Only SSA+force reverts — which is why the fix is to force client-side, not to rely on SSA. This is written up as decision record 0001 in this PR, with the source citations and the matrix, and it's called out in "Revisit if": if helm-controller ever flips that flag's default, the no-op breaks and your analysis becomes correct.

On the findings that were right

  • The annotation didn't exist in the pushed code. Correct, and a fair hit — the doc's present tense was ahead of the branch. It's now implemented in feat(postgres): database read-replica autoscaling via KEDA cozystack#3954: a per-ApplicationDefinition annotation release.cozystack.io/helm-server-side-apply parsed in pkg/config, read in pkg/cmd/server/start.go, and applied to Install/Upgrade.ServerSideApply in convertApplicationToHelmRelease (pkg/registry/apps/application/rest.go), guarded by a test on that builder. The stale package_reconciler_test guard on the wrong builder is removed.
  • "Defaults to SSA" holds only for new releases. Right, and it's why the override sets both Install.ServerSideApply=false and Upgrade.ServerSideApply=disabled rather than relying on auto — so behaviour does not split by install date; every Postgres applies client-side uniformly. Noted in the record's Consequences.
  • The document asserted both designs / no decision record. Right on both. The append-only note is gone; §3, §Upgrade, §Testing, the Open-questions item, Appendix finding 4 and Alternatives are rewritten so the proposal says one thing, and decision 0001 now carries the reasoning. Thanks for pointing at the two existing records as precedent.
  • The seed is not constant across floor edits (MINOR). Correct, and it survives the CRITICAL being wrong, as you noted. Editing replicas/minReplicas/quorum.maxSyncReplicas moves the seed, so that one reconcile does rebase .spec.instances. It's now documented as the floor-change caveat in the chart values.yaml, in §Upgrade, and in the record's Consequences, with a suspend-and-pin recipe.
  • "Per-app" is per-kind (MINOR). Correct — the annotation reaches every Postgres, not only autoscaled ones, and turning off SSA for the kind trades away the v1.5.0 misplaced-field hard-error benefit for Postgres. Stated as an accepted consequence in the record, with a per-instance apply strategy named as the follow-up that would let SSA stay on for non-autoscaled databases.

Not settled here

The apply-strategy matrix on a dev stand (install-era × annotation) that you recommend is the right verification and belongs against #3954, not this doc PR; the empirical run above covers the load-bearing cell (client-side preserves under a chart change). Happy to keep the Open-questions item open if you'd still prefer, but with the two-way path established at the source I've marked it resolved and pointed it at the record.

@IvanHunters

Copy link
Copy Markdown
Contributor

Re-reviewed at 1d77dc8ec. Withdrawing my CRITICAL first, then what the rewrite still leaves open.

Withdrawing the CRITICAL

You are right about the upgrade path, and my CRITICAL was wrong. I traced the merge to the wrong arm of the branch and the verdict rested on it.

Where the misread was: I quoted // Strategic Merge Patch is not supported on objects like CRDs as evidence that a CNPG Cluster routes to CreateThreeWayJSONMergePatch. That comment sits above if isUnstructured || isCRD, which has two arms, and the three-way JSON merge is the arm gated on threeWayMergeForUnstructured. The default arm is the two-way fallback. I verified the body of CreateThreeWayJSONMergePatch in apimachinery and never checked whether Helm reaches it for this object type. The call site decides, and I hadn't read it.

Re-verified against the version actually shipped. helm-controller v1.5.0 pins helm.sh/helm/v4 v4.1.1 (go.mod:44):

  • pkg/action/upgrade.go:469-471 passes ClientUpdateOptionForceReplace, ClientUpdateOptionServerSideApply and ClientUpdateOptionUpgradeClientSideFieldManager, and not ClientUpdateOptionThreeWayMergeForUnstructured, so the option keeps its zero value.
  • pkg/kube/client.go createPatch, unstructured arm with the flag false: jsonpatch.CreateMergePatch(oldData, newData). currentData is fetched a few lines above and goes unused on that arm.
  • grep -rn 'ThreeWayMergeForUnstructured' over helm-controller v1.5.0 returns nothing.

So on the upgrade action an unchanged rendered seed produces an empty patch and the live value survives. The second row of your table is right and the decision is sound. I'm not reopening it.

The rewrite also closes what I raised about the document asserting both designs. I checked each site rather than taking the summary: §3 heading and code block, §3 prose, the mermaid NOTE, "Why this changed", §Upgrade's two bullets, §Testing, Alternatives, Appendix finding 4, and the Open-questions item all now say one thing, and the new ## Decisions section plus decisions/0001-*.md matches what design-proposals/README.md asks for. Record 0001's header block matches both existing records field for field.

Two things remain. Both are text, not design.

[MAJOR] "never from live state" is unqualified, and it is false on the path this decision switches on

The document states the invariant absolutely in four places:

  • §3: "under client-side apply helm-controller patches the CNPG Cluster (a CRD) from the previous-versus-new rendered manifest and does not consult live state"
  • §Upgrade, steady state: "computed from the previous versus the new rendered manifest, never from live state"
  • Appendix finding 4: "Flux computes its patch from the previous-versus-new manifest and never re-asserts the field against the HPA's live count"
  • Record 0001, Decision: "does not consult live state"

That holds for upgrade. It doesn't hold for install with adoption, and what turns it off is this decision itself, not a future upstream change. Chain on today's code:

  1. feat(postgres): database read-replica autoscaling via KEDA cozystack#3954 sets helmRelease.Spec.Install.ServerSideApply = &ssa to false (pkg/registry/apps/application/rest.go), alongside Upgrade.ServerSideApply = disabled.

  2. helm-controller carries that into the install action at internal/action/install.go:85, and :91 sets install.TakeOwnership = !obj.GetInstall().DisableTakeOwnership, which is true by default.

  3. helm v4.1.1 pkg/action/install.go:503:

    updateThreeWayMergeForUnstructured := i.TakeOwnership && !i.ServerSideApply

    With SSA forced off and TakeOwnership on this is true, and it is passed into ClientUpdateOptionThreeWayMergeForUnstructured two lines down.

  4. createPatch with the flag true takes the arm my original objection quoted, CreateThreeWayJSONMergePatch(oldData, newData, currentData), whose add-and-change half is CreateMergePatch(current, modified). Live is read.

  5. requireAdoption (pkg/action/validate.go:41) appends a copy of the rendered Info, calling helper.Get only to test existence and discarding the result into _. So oldData equals newData: the deletion half of the patch is empty and the add-and-change half against live is not.

Since #3954 renders instances: {{ include "postgres.autoscaling.activeSeed" . }}, the adopted patch carries the field. Live instances: 6 against a rendered seed of 2 yields {"spec":{"instances":2}}.

Record 0001 carries the consequence in one sentence. "Revisit if" says the no-op is "gated today only by that flag's default". The flag's default is not the gate: install.go:503 derives the flag from !ServerSideApply, so forcing client-side apply enables it for the install action now, with no upstream change. That sentence is the one place a future engineer would look after hitting this, which is why I am raising it against a record that freezes on merge rather than filing it against #3954 alone. The empirical matrix has the same shape: all three rows are helm upgrade, and install-with-adoption is precisely the cell where the two-way reasoning does not apply.

What I haven't established, and am not claiming: how often helm-controller reaches an install action against an existing Cluster. One thing narrows it, packages/apps/postgres/templates/ carries no helm.sh/resource-policy: keep, so an ordinary HelmRelease deletion takes the Cluster with the release. That leaves release-storage loss with resources intact, and install remediation that uninstalls then reinstalls. Narrow, and both are recovery paths where an operator is already handling an incident and would lose standbys with no signal. A qualifier on the four sentences plus a corrected "Revisit if" is enough for this PR; the matrix cell belongs to #3954.

[MAJOR] §5's dry-run bullet still describes the mechanism the implementation rejected

dryRun: false ships in the tenant-facing example at §User-facing changes, but §5 still defines the mode as:

Dry-run / recommendation — render the dashboard and alert rules but not the ScaledObject (or use KEDA's pause annotation), so behavior can be observed before actuation is enabled.

#3954 rejects both halves of that, and says why in cozy-lib.keda.scaledObject:

Deliberately not the full paused=true, which in KEDA 2.20.2 deletes the HPA and blinds the dashboard/alerts.

So it stamps autoscaling.keda.sh/paused-scale-in and paused-scale-out and asserts autoscaling.keda.sh/paused is absent. Not rendering the ScaledObject at all is strictly worse than the full pause for the same reason: no HPA, no served trigger metric, nothing for the dashboard the mode exists to read. This PR reconciled §3, §Upgrade, §Testing, Alternatives and the Appendix, and left the section that defines this mode pointing at the discarded approach.

Two related gaps, same fix:

  • §Upgrade documents staging .Values.replicas for enable and for disable, but not for dry-run, and #3954 documents dry-run as a third count-changing transition with the identical hazard: "flipping a LIVE autoscaled cluster into dryRun re-renders instances: replicas [...] if replicas is below the live count, stage it to the live count first [...] or the cluster sheds down to replicas".
  • §Upgrade phase 1 reads "(paused, minReplicaCount pinned to N) so KEDA stands up its HPA warm". Read against KEDA's annotation set, "paused" names the one annotation that deletes the HPA, which contradicts "warm" in the same clause. #3954 uses the directional pair on purpose. Someone implementing the next engine from this document, which the multi-engine roadmap invites, would reach for the wrong annotation and lose the HPA in both dry-run and migration phase 1.

Verified and correct, so it does not get re-litigated

  • Rollback needs nothing here. helm-controller leaves rollback.ServerSideApply = "auto" (internal/action/rollback.go:102), which resolves to the previous revision's apply method, and helm v4.1.1 pkg/action/rollback.go:231 passes ClientUpdateOptionThreeWayMergeForUnstructured(false) explicitly. Rollback is two-way regardless.
  • The floor-change cross-reference in record 0001's Consequences checks out: §Upgrade's steady-state bullet does document the replicas / minReplicas / maxSyncReplicas rebase.
  • instances default 1 with minimum: 1 is real in the vendored CNPG CRD (packages/system/postgres-operator/charts/cloudnative-pg/templates/crds/crds.yaml), so the omit-collapses-to-1 argument stands.
  • Force-conflicts is hardcoded to the SSA setting with no override, at internal/action/install.go:53, upgrade.go:77 and rollback.go:93 on the v1.5.0 tag.

[MINOR] SDK version in record 0001

"Confirmed empirically on Helm 4.0.4 (the same SDK helm-controller v1.5.0 embeds)": the embedded SDK is v4.1.1 per go.mod:44. The branch structure is identical in both, so the conclusion is unaffected, but the record freezes after merge and currently names a version it wasn't run against.

Verdict

NOT LGTM, two text fixes away. The design decision is sound and I'm not reopening it. What blocks is that record 0001 freezes on merge while its "Revisit if" names the wrong gate, and that §5 still defines dry-run by the mechanism #3954 discarded. Qualify the four "never from live state" sentences, correct "Revisit if", rewrite the §5 dry-run bullet and add dry-run to §Upgrade's staging list, and I have nothing further.

Alexey Artamonov added 6 commits September 3, 2026 13:34
Reconcile four review findings on #66 against the mechanism actually
implemented in cozystack/cozystack#3954:

- §5 dry-run bullet described KEDA's full `paused` annotation, which
  deletes the HPA and leaves no recommendation to observe. The
  implementation pauses both directions via `paused-scale-in`/
  `paused-scale-out`, keeping the HPA up and serving its metric while
  freezing desiredReplicas; the bullet now says so.
- The four "does not/never consult live state" sentences now carry the
  qualifier that the no-op holds only under helm-controller's current
  two-way-merge default -- the same gate recorded in decision 0001's
  "Revisit if" -- so a future default flip does not silently falsify
  the prose.
- Decision 0001's freeze note exempted nothing, contradicting the
  post-merge "Revisit if" trigger. It now marks "Revisit if" an active
  trigger acted on by superseding the record via the maintained header,
  not by editing the frozen body.
- §Upgrade phase-1 names the pause mechanism precisely and adds an
  optional dry-run observation stage before actuation is handed over.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
Round-2 review follow-up on the same four findings:

- §Upgrade's resolved Open-questions item carried a fifth, unqualified
  "never re-asserted from the live count" -- the same claim the other
  four sentences now gate. Qualify it with helm-controller's current
  two-way-merge default so the whole set is consistent.
- Adding the optional dry-run stage left "two-phase order ... both
  phases" describing three bolded stages. Note the observation window
  is between the two phases and count all three stages in the
  same-count invariant.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
The "PoC first" bullet gated the whole plan ("gates everything else")
on a claim that was both wrong and CNPG-only:

- It named the bare `spec.subresources.scale` as the assumption, but
  the scale subresource existed even on the 1.27.x the PoC ran on --
  what the HPA actually requires is `status.selector`, absent below
  the CNPG 1.28.4 floor.
- It asserted the assumption is "present in the currently vendored
  CNPG", contradicting §Scope/§PoC (vendored 1.28.2 is below the
  1.28.4 floor).
- It wrote the gate as CNPG-specific, but §"Engine scope of the MVP"
  covers MariaDB (`MariaDB.spec.replicas`) too, and the selector
  requirement is a stock-HPA property against any scale subresource --
  the same gate MariaDB must clear and the one Redis/MongoDB fail
  outright.

Restate it per-engine: the scale subresource must serve
`status.selector`; CNPG >= 1.28.4 for the shipping PostgreSQL, re-clear
for MariaDB, N/A for the subresource-less engines.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
The proposal still described the version floor as unmet: "currently
vendors CNPG 1.28.2, two patch releases below the floor", "the bump is
a separate cozystack PR", "currently ships 1.28.2 ... does not serve
it". That bump has merged -- cozystack/cozystack#3951 raised CNPG to
1.30.0 on 2026-08-26, so main already clears the >= 1.28.4 floor.

Update the three present-tense spots (§PoC Resolution, §Scope
Precondition, §Testing) to state the floor is satisfied and reference
#3951, so the PoC gate reads as "confirm the running operator serves
the selector", not "wait for a pending dependency".

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
The illustrative §3 template had only two arms (enabled -> seed,
else -> replicas), but §Upgrade phase-1 and §5 dry-run both rely on a
third rendering mode: enabled=true yet rendering the raw replica count
via a `transition`/`dryRun` sub-flag. A reader could not reconcile the
two sections.

Gate the seed arm on `enabled AND not transition AND not dryRun`, note
that the two staging modes fall to the static-count arm, and align the
prose that described the branch as keying off `autoscaling.enabled`
alone.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
…ersion

Findings from an independent three-lens re-review, all verified:

- [MAJOR] The "one residual write path" was scoped to three tenant
  value edits, but a platform chart upgrade that changes how the seed
  is rendered (effectiveMin/seed helper or a schema default) rebases an
  HPA-grown .spec.instances with no tenant edit. Generalize the caveat
  in §Upgrade and decision 0001, and add an upgrade-of-active-cluster
  case to Testing.
- [MINOR] Decision 0001 Status was Accepted while the proposal PR is
  open and unreviewed; set it to Proposed (flips on merge, per the
  record's own convention).
- [MINOR] Decision 0001 cited "Helm 4.0.4 (the same SDK helm-controller
  v1.5.0 embeds)"; v1.5.0 pins helm.sh/helm/v4 v4.1.1. State both and
  note the createPatch branch is identical.
- [MINOR] "suspend-and-pin recipe in ... §Upgrade" pointed at a recipe
  §Upgrade did not carry; attribute it to values.yaml and name the
  stage/transition procedure §Upgrade actually documents.
- [MINOR] Note that MariaDB does not inherit Postgres's basis for the
  per-kind client-side-apply trade and must re-justify it.
- [NIT] "per application" -> "per ApplicationDefinition (per kind)".

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>

@IvanHunters IvanHunters 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.

Verdict

NOT LGTM

The record's central refutation holds only for the upgrade path: Helm itself turns on three-way merge for unstructured objects during install-with-adoption, and the enabling condition is the very annotation this decision introduces.

Findings

[MAJOR] design-proposals/database-horizontal-autoscaling/decisions/0001-client-side-apply-preserves-the-autoscaler-seed.md:27, the "refuted at the source" argument covers upgrade only; Helm enables three-way merge for unstructured objects itself on install-with-adoption

The bullet concludes: "helm-controller v1.5.0 never sets it (no reference anywhere in the controller), so the default two-way path applies." The first half is right, and I confirmed it (zero hits for threeWayMerge across the v1.5.0 tree). The conclusion does not follow, because helm-controller is not the only party that can set the flag. Helm's own install action sets it, and both of its conjuncts are satisfied by this design:

// helm/helm @ v4.1.1, pkg/action/install.go:503 (branch taken when len(toBeAdopted) > 0)
updateThreeWayMergeForUnstructured := i.TakeOwnership && !i.ServerSideApply // Use three-way merge when taking ownership (and not using server-side apply)
_, err = i.cfg.KubeClient.Update(
    toBeAdopted, resources,
    kube.ClientUpdateOptionServerSideApply(i.ServerSideApply, i.ForceConflicts),
    kube.ClientUpdateOptionThreeWayMergeForUnstructured(updateThreeWayMergeForUnstructured),
// fluxcd/helm-controller @ v1.5.0, internal/action/install.go:91
install.TakeOwnership = !obj.GetInstall().DisableTakeOwnership   // default true

TakeOwnership defaults to true, and Install.ServerSideApply=false is exactly what the annotation in the Decision paragraph forces. toBeAdopted is populated from live objects, not from release history:

// helm/helm @ v4.1.1, pkg/action/validate.go:41
// requireAdoption returns the subset of resources that already exist in the cluster.

And on that path the add-and-change half of the patch is computed from the live object toward the rendered one, which is precisely the reading the bullet calls refuted:

// k8s.io/apimachinery @ v0.35.0, pkg/util/jsonmergepatch/patch.go:28-41
// Create a 3-way merge patch based-on JSON merge patch.
// Calculate addition-and-change patch between current and modified.
// Calculate deletion patch between original and modified.
func CreateThreeWayJSONMergePatch(original, modified, current []byte, ...) ([]byte, error) {
	addAndChangePatch, err := jsonpatch.CreateMergePatch(current, modified)
// helm/helm @ v4.1.1, pkg/kube/client.go:1023 and :1047-1055
currentObj, err := helper.Get(target.Namespace, target.Name)   // the live GET
...
if isUnstructured || isCRD {
    if threeWayMergeForUnstructured {
        patch, err := jsonmergepatch.CreateThreeWayJSONMergePatch(oldData, newData, currentData, preconditions...)

So a Cluster the HPA has grown to 6, adopted by an install that renders the constant seed 2, receives {"spec":{"instances":2}}. Adoption during install is reachable whenever the object outlives the release record: release history lost while objects remain, an uninstall on a resource carrying helm.sh/resource-policy: keep, or a chart split that moves the Cluster under a new release name, which is a recurring shape in this codebase.

The Revisit if line at :39 locates this risk in a future upstream change ("helm-controller starts enabling threeWayMergeForUnstructured by default"). One of its two conjuncts is already set by helm-controller today, and the other is set by this decision, so the condition is satisfiable now on the install path rather than later.

Concretely: scope the claim. State that the two-way guarantee holds on upgrade, name the install-with-adoption path as the exception with the TakeOwnership && !ServerSideApply condition, and say what the design does about it. DisableTakeOwnership: true on the emitted HelmRelease is the one knob that removes the conjunct, and if it is rejected the residual path belongs in Consequences, not in a future-tense trigger. Whatever the resolution, this record freezes its prose on merge, so the scoping has to land before then.

[MAJOR] design-proposals/database-horizontal-autoscaling/README.md:178, migration phase 1 is not inert on a cluster below the rendered floor, and minReplicaCount is not pinned to N

The bullet says the ScaledObject is rendered "paused in both directions (autoscaling.keda.sh/paused-scale-in/paused-scale-out, minReplicaCount pinned to N)", that KEDA "begins observing load while actuating neither way", and that the phase-1b window "is inert on the running cluster". Two separate problems.

The pause is a behavior policy, and the HPA controller only consults behavior policies on the metrics branch:

// kedacore/keda @ v2.20.2, controllers/keda/hpa.go:86-99
if scaledObject.NeedToPauseScaleIn() {
    disabledPolicy := autoscalingv2.DisabledPolicySelect
    behavior.ScaleDown.SelectPolicy = &disabledPolicy
// kubernetes/kubernetes @ v1.33.4, pkg/controller/podautoscaler/horizontal.go:811-824, 868-871
rescale := true
...
} else if currentReplicas < minReplicas {
    rescaleReason = "Current number of replicas below Spec.MinReplicas"
    desiredReplicas = minReplicas
} else {
    ... normalizeDesiredReplicasWithBehaviors(...)   // only here
    rescale = desiredReplicas != currentReplicas    // only here
}
if rescale {
    scale.Spec.Replicas = desiredReplicas
    _, err = a.scaleNamespacer.Scales(hpa.Namespace).Update(ctx, targetGR, scale, metav1.UpdateOptions{})

rescale starts true and is only recomputed inside the metrics branch. When currentReplicas < minReplicas the controller writes /scale without ever reaching normalizeDesiredReplicasWithBehaviors, so SelectPolicy: Disabled does not apply.

The second problem: the implementation this PR points at renders the bound unconditionally from the quorum helper, with no transition override:

# cozystack/cozystack#3954, packages/apps/postgres/templates/scaledobject.yaml
"minReplicaCount" (int (include "postgres.autoscaling.effectiveMin" .))
"maxReplicaCount" (int (include "postgres.autoscaling.effectiveMax" .))

That matches §5's own formula at README.md:123, which carries no transition exception either. So "pinned to N" describes neither the implementation nor §5.

The narrative: a tenant with a single-instance Postgres follows phase 1 as written. It stages replicas = 1 (the live count), sets enabled: true with transition: true. The chart renders instances: 1; KEDA creates an HPA with minReplicas: 2 (max(minReplicas, maxSyncReplicas+1, 2)) and both pause annotations. The HPA sees currentReplicas(1) < minReplicas(2), takes the below-min branch, and writes /scale = 2. CNPG provisions a second instance with its PVC and DRBD volume during the window the document says is safe to hold in. That is the same footprint change README.md:182 insists must be "a conscious enablement decision, not a surprise", arriving during the phase whose stated purpose is to observe before committing.

Fix either the claim or the mechanism. If phase 1 is meant to be non-actuating, the transition branch has to render minReplicaCount at the live count and the document has to say so and reconcile it against §5's "quorum wins, never clamp below a safe quorum" rule for the N < floor case. If it is not meant to be non-actuating, drop "inert" and "actuating neither way" and state the scale-up as part of phase 1. Either way the bracketed "minReplicaCount pinned to N" should stop asserting something no template does.

[MAJOR] design-proposals/database-horizontal-autoscaling/README.md:126, the branch switch into the static-count path is a third write path on .spec.instances, and its target is not floor-clamped

decisions/0001-...md:34 enumerates the residual write paths as "Two triggers": a tenant edit to the seed's inputs, and a platform upgrade that changes how the seed is rendered. Both move the seed while autoscaling stays active, so both land on max(replicas, effectiveMin) and the parenthetical guarantee "never below the quorum floor, never a collapse to 1" holds for them.

There is a third. Setting dryRun or transition on a live autoscaled cluster switches the render from the seed expression to the raw one at README.md:99:

  instances: {{ .Values.replicas }}   # static count — off, or the transition / dry-run staging modes (§Upgrade, §5)

.Values.replicas is not clamped to effectiveMin, so this write path can go below the quorum floor, unlike the two the record lists. §5's dry-run bullet presents the mode as observation before actuation and says nothing about it touching the live count at all.

The implementation already documents what the proposal and the record do not:

# cozystack/cozystack#3954, packages/apps/postgres/values.yaml, @field dryRun
Like disabling, flipping a LIVE autoscaled cluster into dryRun re-renders `instances: replicas`
(the raw static value, which can be below the autoscaling floor); if `replicas` is below the
live count, stage it to the live count first (see the enablement note) or the cluster sheds
down to `replicas`.

The chart also carries a {{- fail }} on the static branch for replicas <= maxSyncReplicas, which catches the wedge case but not the general shed. So the documents under review are less accurate about this than the code they describe. Add the branch switch as a third trigger in the record's Consequences, note that its target is the unclamped value, and give §5's dry-run bullet the same "stage replicas to the live count first" precondition the enable and disable paths already carry.

[MINOR] design-proposals/database-horizontal-autoscaling/README.md:180, the disable bullet still describes the omit mechanism

"Setting autoscaling.enabled: false re-introduces instances: {{ .Values.replicas }}" and "so Flux reasserts the current value rather than dropping to the default" both belong to the design §3 no longer has. Under the new §3 the field is present in every branch, so nothing is re-introduced; and when the previous and new renders agree the two-way patch is empty, so Flux asserts nothing rather than reasserting the current value. The prescribed steps still produce the right outcome, but their stated rationale is the old one. This also narrows the PR body's claim that the supersession touches "the apply-strategy detail only": this bullet's correctness argument rested on three-way delete semantics and was left untouched.

[MINOR] design-proposals/database-horizontal-autoscaling/decisions/0001-client-side-apply-preserves-the-autoscaler-seed.md:15, the Context conflates the ForceConflicts assignment with the apply-strategy default for upgrade and rollback

The cited line is exact:

// fluxcd/helm-controller @ v1.5.0, internal/action/install.go:53
install.ForceConflicts = install.ServerSideApply // We always force conflicts on server-side apply.

upgrade.go:77 and rollback.go:93 do carry the same ForceConflicts assignment, so "same in upgrade.go/rollback.go" is right about that. The default is not the same:

// internal/action/upgrade.go:103
upgrade.ServerSideApply = "auto" // This must be the upgrade default regardless of UseHelm3Defaults.
// internal/action/upgrade.go:74
serverSideApply = lastReleaseTyped.ApplyMethod == "ssa"

Upgrade and rollback default to auto, which inherits the previous release's apply method, and the install default is itself behind a controller-wide feature gate (internal/features/features.go:118-120, UseHelm3Defaults: false). "Defaults new HelmReleases to server-side apply" is accurate for a new release; the parenthetical reads as though the upgrade path defaults to SSA too. It matters for the record's own reasoning: a Postgres release predating v1.5.0 was already applying client-side on every upgrade, so the "reverted on every apply" failure never touched that population. That is the same split the last Consequences bullet handles from the other direction, and stating it here would make the two consistent.

[MINOR] design-proposals/database-horizontal-autoscaling/README.md:107, autoscaling.transition is load-bearing in the render condition but absent from the document's own values contract

The seed branch keys off "enabled, and neither transition nor dryRun", and the phase-1 runbook at :178 turns on transition. The tenant-facing block at :137-147 lists enabled, minReplicas, maxReplicas, target, maxReplicationLagSeconds and dryRun, with no transition. §5 states that block is "validated by values.schema.json, like every other cozystack knob", so as documented the migration procedure sets a key the contract does not admit. The implementation does declare it (values.schema.json lists transition in required, plus a ## @field {bool} transition), so this is the proposal trailing the code.

[MINOR] design-proposals/database-horizontal-autoscaling/README.md:126, no KEDA version floor for the per-direction pause annotations

The annotations are real, and I verified them:

// kedacore/keda @ v2.20.2, apis/keda/v1alpha1/scaledobject_types.go:58-61
const PausedScaleInAnnotation = "autoscaling.keda.sh/paused-scale-in"
const PausedScaleOutAnnotation = "autoscaling.keda.sh/paused-scale-out"

They were introduced in v2.18.0 (CHANGELOG.md:309-310, "Add support for pause scale in annotation (#6902)", "Add support for pause scale out annotation (#7022)"). Below that version KEDA ignores unknown annotations, so a ScaledObject the document calls non-actuating would actuate immediately, with nothing reporting the difference. This PR is what made the two annotations load-bearing in both §5 and phase 1, and it is where the "or use KEDA's pause annotation" hedge became specific. The proposal states the CNPG floor precisely twice and leaves KEDA's version to ## Open questions. Give it a floor of KEDA >= 2.18.0 the same way, especially since README.md:222 no longer lists this as open.

[MINOR] design-proposals/database-horizontal-autoscaling/decisions/0001-client-side-apply-preserves-the-autoscaler-seed.md:5, Status: Proposed is outside the documented enumeration

design-proposals/decision-template.md gives Status: Accepted | Superseded by NNNN | Reverted, and design-proposals/README.md:103 lists "A decision that has not been made yet" among the things not to write a record for. Proposed (flips to Accepted on merge of the proposal PR) invents a fourth state. If a record has to exist before its own approval, the state belongs in the template rather than in one record, so the next author does not have to guess.

Claim mismatches

[PARTIAL] "forces client-side (three-way-merge) apply for the tenant HelmRelease" (PR body). The attached record's entire refutation is that the client-side path for unstructured objects is a two-way merge (helm/helm @ v4.1.1, pkg/kube/client.go:1061, jsonpatch.CreateMergePatch(oldData, newData)). The body names the mechanism the record spends a paragraph refuting.

[PARTIAL] "This supersedes §3's 'omit the field' on the apply-strategy detail only; the ownership goal is unchanged" (PR body). §Upgrade's disable bullet at README.md:180 and §5's dry-run bullet at :126 also rested on omit-era semantics and were not brought forward (findings above).

[PARTIAL] "deterministic and safe across enable/disable/dry-run with no handoff race" (PR body). Enable is covered. Dry-run on a live autoscaled cluster has an undocumented write path to the unclamped replicas, and disable's stated mechanism no longer describes what happens.

Operational risks

  • decisions/0001-...md:34 names a platform chart upgrade that changes the seed helper as a residual write path that rebases an HPA-grown count "with no tenant edit at all", then prescribes staging .Values.replicas to the live count as the mitigation. In this platform the two are owned by different actors: the tenant owns the application values, the platform release schedules the chart bump. README.md:179's "treat a chart bump of an autoscaling-active database the same way rather than applying it blind" does not say who performs it or how a platform operator identifies which tenant databases are autoscaling-active before rolling a release. As written the mitigation is not reachable by the actor who would need to apply it. Either name the mechanism that makes it reachable (a release-note gate, a pre-upgrade check that enumerates autoscaling-active databases, or a seed helper versioned so a bump cannot move it silently), or record that the trigger is accepted unmitigated.

Caveats

  • Hermetic review, no cluster contacted. Nothing was rendered: this repository contains no charts. The configuration-corner analysis is against the §3 snippet plus the templates read in cozystack/cozystack#3954, which is OPEN and unmerged (verified: state: OPEN, mergedAt: null). To turn that reading into a render, the command is helm template r packages/apps/postgres --set autoscaling.enabled=true --set autoscaling.transition=true --set replicas=1 on that branch, plus helm unittest packages/apps/postgres.
  • Corners walked, and where each is addressed: enabled=false (:180, stale mechanism); enabled alone (:103, :179, covered); enabled+transition (:178, finding 2); enabled+dryRun (:126, findings 2 and 3); enabled+transition+dryRun (not addressed, same static branch, no harm found). Transitions: off to transition (both render replicas, empty patch); transition to active (moves only when replicas < effectiveMin, the document states this parenthetically); active to dryRun and active to off (finding 3); active to active under a changed seed helper (Operational risks).
  • §Testing at :207-208 adds the client-side invariant assertion and the chart-bump rebase exercise. Three corners of this design's own mechanism have no test named: install-with-adoption re-asserting the seed (finding 1), flipping dryRun on a live autoscaled cluster (finding 3), and a paused ScaledObject whose minReplicaCount exceeds the live count (finding 2).
  • Verified sound, so these should not need re-litigating. Install.ServerSideApply *bool and Upgrade.ServerSideApply as enum enabled;disabled;auto (fluxcd/helm-controller @ v1.5.0, api/v2/helmrelease_types.go:593-596 and :840-846), so §Testing's Install.ServerSideApply=false / Upgrade.ServerSideApply=disabled pair is exactly right, asymmetry included. The annotation is real and threaded as described (pkg/config/config.go:18523 in the PR diff, HelmServerSideApplyAnnotation = "release.cozystack.io/helm-server-side-apply", consumed in rest.go convertApplicationToHelmRelease). Two-way as the unstructured default with no threeWayMerge reference anywhere in helm-controller v1.5.0. helm.sh/helm/v4 v4.1.1 in go.mod:44. HPA writes /scale only when desiredReplicas != currentReplicas (horizontal.go:866) and sets InvalidSelector before any metric-type branching (horizontal.go:305, :386-392). CNPG spec.instances defaults to 1 (api/v1/cluster_types.go:256-259 @ v1.30.0) and the webhook rejects maxSyncReplicas >= instances (internal/webhook/v1/cluster_webhook.go:1582-1587). cloudnative-pg#8996 merged 2026-06-04; labelSelectorPath: .status.selector present in the v1.28.4, v1.29.2 and v1.30.0 CRDs and absent from v1.28.3, v1.29.1 and v1.27.4, so the stated floor and the no-backport claim both check out. cozystack#3951 merged 2026-08-26, and packages/system/postgres-operator/charts/cloudnative-pg/Chart.yaml on main reads appVersion: 1.30.0. KEDA's full paused annotation does delete the HPA (controllers/keda/scaledobject_controller.go:361-363), so the contrast drawn at :126 is correct.
  • The record's reproduction was run on Helm 4.0.4 while the platform embeds v4.1.1, and the record says so and argues the carry-over from source. I re-read v4.1.1's createPatch and the two-way arm is identical, so the carry-over holds on the upgrade path. It is finding 1's install path that the reproduction does not reach, not the version gap.
  • The annotation is applied only on the aggregated-apps-API path (rest.go, guarded by if r.releaseConfig.HelmServerSideApply != nil). A HelmRelease for the postgres chart authored directly rather than through the apps API keeps the platform default and would revert the seed. The record's "per-kind, so it reaches every Postgres" scope statement at :33 reads wider than the code path.
  • Per Phase 0 I did not read the PR conversation or any prior review. The reviewer objection quoted in the record's Context is taken from the record's own account of it, not from the thread.

Recommended follow-ups

  • The record's footer at :41-49 restates the shared template's frozen-prose rule with an added clarification about Revisit if. On re-reading it agrees with design-proposals/README.md:83 rather than contradicting it, so nothing is wrong here, but per-record divergence in the format boilerplate is how the template stops being the reference. If the clarification is worth keeping, it belongs in decision-template.md.
  • The reviewer objection that the record exists to answer is attributed at :17 with a link to the pull request root rather than to the comment. design-proposals/README.md:118 asks that every rejected alternative be sourced to "the comment, review or pull request it came from", and here the objection is the pivot of the whole record, so a comment permalink would make it checkable in one click instead of a thread scroll.
  • dryRun and transition are independent booleans that select the same render branch and the same paused ScaledObject, differing only in intent (permanent versus migration phase 1). The composition is well defined and I found no failure in the both-set corner, but two flags for one rendered outcome is a schema shape worth revisiting before it reaches the values contract for a second engine.

Alexey Artamonov added 2 commits September 3, 2026 16:31
…n staging, ignoreNullValues review findings

Prose-only fixes to the Database Horizontal Autoscaler proposal from
cozy-review. The seed + client-side apply mechanism is unchanged.

- Install-with-adoption reads live today. Forcing ServerSideApply=false
  with TakeOwnership defaulting true makes Helm's install action compute
  a three-way patch (helm v4.1.1 install.go:503), so a recovery reinstall
  against a surviving Cluster rebases an HPA-grown count back to the seed.
  Qualify the "does not consult live state" invariant to the upgrade /
  steady-state path (README sec.3, Upgrade, Open questions, Appendix and
  decision 0001 Decision); add it as a residual write path in
  Consequences; fix the "Revisit if" gate (it is live by design, not a
  future upstream flag flip).
- Single-instance phase-1 is not inert. The HPA's currentReplicas <
  minReplicaCount branch writes /scale unconditionally (k8s v1.33.4
  horizontal.go), bypassing the pause annotations, so a single-instance
  source is bumped to effectiveMin immediately. Add the caveat to the
  Upgrade phase-1 note and the Enablement constraint.
- Third residual write trigger. A standalone dryRun/transition flip on a
  grown cluster renders the static instances: replicas (not floor-clamped)
  and sheds the live count. Add it to decision 0001 Consequences ("Three
  triggers") and to the Upgrade staging guidance.
- Missing ignoreNullValues: "false" in the rendered ScaledObject. KEDA's
  default reads an empty/stale PromQL result as 0 and scales to the floor,
  inverting the "never scale blind" fail-safe. Add the field to the render
  and explain it in the fail-safe note.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
…e rebase path

Verification of the round-1 fix surfaced three text-only gaps left by the
same absolute-claim cleanup; the mechanism is unchanged.

- §5 dry-run/recommendation now carries the single-instance caveat already
  in phase-1b: the both-directions pause does not freeze a source at N=1,
  since the HPA floor branch writes /scale=effectiveMin unconditionally,
  bypassing the pause annotations. dryRun is a tenant-settable schema flag,
  so the caveat is stated at its canonical definition, not only phase-1.

- backup/restore (cozystack#3959 purgeExistingCluster) is documented as a
  fourth residual write path: it deletes the Cluster+PVCs and re-creates
  from bootstrap.recovery, so Helm takes the Create arm and the cluster
  comes up at the seed, not the HPA-grown count. The staging recipe is
  inapplicable by construction — the purge erases live state on purpose.
  Recorded in decision 0001 Consequences, README §Upgrade, and §Testing.

- the §Decisions index bullet gets the same install-with-adoption
  exception the five other absolute-claim sites already carry.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
@scooby87

scooby87 commented Sep 3, 2026

Copy link
Copy Markdown
Author

Thanks — you're right on all three, and I've corrected the text rather than the mechanism. Summary of what changed and why.

1. install-with-adoption reads live today (the main one)

I traced the chain you pointed at and it holds exactly as you described. On the install action (not upgrade), helm v4.1.1 computes updateThreeWayMergeForUnstructured := TakeOwnership && !ServerSideApply (pkg/action/install.go:503). TakeOwnership defaults to true (helm-controller v1.5.0 internal/action/install.go, !DisableTakeOwnership), and this design is precisely what forces ServerSideApply=false — so both conjuncts are already satisfied, with no future upstream flag flip. requireAdoption sets oldData == newData, so the whole patch collapses to current → new, i.e. it reads live and rebases an HPA-grown .spec.instances back to the rendered seed.

So the decision 0001 invariant was stated too absolutely. It is true for the upgrade / steady-state path (the two-way merge for unstructured objects, live never read), but not for the narrow install-with-adoption recovery path — a Cluster that survives while its HelmRelease release-storage is lost (etcd recovery, namespace-secret cleanup, a Flux storage migration), where the next reconcile runs install against the existing object. The postgres templates carry no helm.sh/resource-policy: keep, so an uninstall/reinstall remediation is excluded (it would take the Cluster with it), which is what makes this the reachable path rather than a hypothetical.

The rebase is bounded — the seed is max(replicas, effectiveMin), so it never drops below the quorum floor and never collapses to 1 — but it is a real shrink of the grown replica set during a recovery, so it belongs on the record, not hidden behind an absolute claim.

What I changed:

  • decision 0001 · Decision — qualified "does not consult live state" to the upgrade action every steady-state reconcile takes, and added the install-with-adoption exception as a pointer to Consequences.
  • decision 0001 · Consequences — added install-with-adoption as a second residual write path, with the install.go:503 derivation and the bounded semantics spelled out. Noted it is live today by design and therefore not a "Revisit if" condition.
  • decision 0001 · Revisit if — rewrote the gate. The threeWayMergeForUnstructured default flip is the revisit trigger for the upgrade path only; the install-adoption three-way read is called out as separate, already-live, and handled as a Consequences caveat, not a revisit trigger. The old "gated today only by that flag's default" wording is gone — it was wrong for the install path.
  • README — qualified the four "does not consult live state / never from live count" phrases (§3, §Upgrade steady-state, Open questions RESOLVED item, Appendix finding 4) to the upgrade/steady-state path, each pointing at the install-adoption exception in decision 0001.

2. single-instance phase-1 is not "inert"

Also correct. For a single-instance source, effectiveMin ≥ 2 forces minReplicaCount ≥ 2, and the stock HPA controller's currentReplicas < minReplicaCount branch writes /scale = minReplicaCount unconditionally (k8s v1.33.4 pkg/controller/podautoscaler/horizontal.go) — it never reaches normalizeDesiredReplicasWithBehaviors, so the paused-scale-in/paused-scale-out annotations do not apply to it. So a cluster at N=1 is bumped straight to effectiveMin the moment KEDA's HPA comes up; phase 1 is not observation-only there.

What I changed: added the caveat to §Upgrade phase-1 (no longer calling that window "inert" for single-instance) and cross-linked it from the "Enablement constraint — minReplicas ≥ 2" bullet. The guidance: stage .Values.replicas = effectiveMin before phase 1, or accept the one-step bump to the floor as a conscious enablement step.

3. third residual write trigger (dryRun/transition flip)

Added. A standalone dryRun:false→true (or transition) flip on an already-grown cluster, with enabled still true, drops out of the seed branch and renders the static instances: {{ .Values.replicas }}, which is not floor-clamped (only CNPG's quorum webhook backstops it), so it sheds the live count down to replicas. decision 0001 Consequences now reads "Three triggers" (with this one distinguished as leaving the seed branch and therefore not floor-bounded), and §Upgrade staging guidance now says to stage .Values.replicas to the live count before such a flip, same as the enable/disable flows.

Also folded in (minor)

  • §Security blast-radius no longer says "one" platform-wide surface — it now lists the three (APIService, CNPG clusters/scale RBAC, per-kind client-side apply).
  • Added the "6 → floor" dip note to the "never below the quorum floor, never a collapse to 1" phrasing so it does not read as "no dip at all".
  • Added ignoreNullValues: "false" to the rendered ScaledObject (matching the #3954 fix) and explained in the fail-safe note that without it an empty/stale PromQL result is read as 0 and scales down, inverting "never scale blind".

Commit ab4ce85. Happy to split any of these out or reword if you'd rather the caveats land differently.


Round 2 (commit 2614afd) — three follow-ups a re-verification of the round-1 fix surfaced

The round-1 cleanup that qualified the "does not consult live state" claim left three text-only gaps behind; all three are corrected on top of ab4ce85. The mechanism is still unchanged.

1. §5 dry-run pause is not inert for a single-instance source either

The round-1 fix added the single-instance caveat to §Upgrade phase-1b (a cluster at N=1 gets bumped straight to effectiveMin because the stock HPA's currentReplicas < minReplicaCount branch writes /scale unconditionally, bypassing the pause annotations). But §5's "Dry-run / recommendation" bullet — the canonical definition phase-1 points at — still said the paused HPA's desiredReplicas is "frozen to the current count" without qualification, describing the exact same both-directions pause. Since dryRun is an ordinary values-schema flag a tenant can set directly (not only a phase-1 step), a tenant enabling dryRun on a single-instance database would hit the same floor bump the phase-1b caveat warns about, while §5 promised a freeze. I qualified §5 to match: the freeze holds only for a source already at/above the floor; for N=1 the floor branch bumps to effectiveMin regardless of the pause, cross-linked to phase-1b and the Enablement constraint.

2. backup/restore is a fourth residual write path (cross-feature, found separately)

This one is a cross-feature interaction the SSA/ownership analysis had no reason to open: #3959 (fix(postgres): let in-place restore re-bootstrap instead of wedging, merged to main 2026-08-28) added purgeExistingCluster to internal/backupcontroller/cnpgstrategy_controller.go, which — on an in-place restore, or a restore into an existing autoscaling-active application — deletes the CNPG Cluster and its PVCs outright (client.Delete) before the resumed HelmRelease re-creates the Cluster from bootstrap.recovery. Because the live object is physically gone, Helm's apply takes the Create arm (helper.GetNotFound), not a two-way or three-way patch — so the fresh Cluster renders straight from the chart at the seed max(replicas, effectiveMin), i.e. the quorum floor, not the count the HPA had grown to. The restore controller is autoscaling-unaware (it patches only Bootstrap/Backup fields; a grep for replicas/autoscaling in internal/backupcontroller is empty), and #3954 never touches internal/backupcontroller — neither feature knows about the other.

It stays inside the same safety envelope as the documented triggers (bounded by max(replicas, effectiveMin) — never below the quorum floor, never a collapse to 1), and the ScaledObject/HPA survive the purge, so the autoscaler re-grows the count to load once recovery catches up — so it is an operator surprise, not a data-loss path. The key point is that the staging recipe used for the other write paths ("stage .Values.replicas to the live count first") is inapplicable by construction here: the purge exists precisely to erase live state, so pre-staging cannot preserve the grown count. I recorded it as a distinct residual write path — a Create, not a patch — in decision 0001 §Consequences, with a bullet in README §Upgrade and a §Testing item ("restore of an autoscaling-active cluster does not drop below quorum and the HPA restores the count").

3. §Decisions index bullet — the sixth absolute-claim site

The round-1 fix qualified the "does not re-assert the constant against the live count" phrasing in five places but missed the one-line teaser under the ## Decisions index. Qualified it the same way (upgrade/steady-state; install-with-adoption is the documented exception, see decision 0001), kept short since it is only an index pointer.

Commit 2614afd, prose-only, on top of ab4ce85.

@scooby87
scooby87 requested a review from IvanHunters September 3, 2026 14:00

@IvanHunters IvanHunters 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.

Verdict

NOT LGTM

Re-reviewed at 2614afdd. Two of the three blockers from my last round are properly closed, and the install-with-adoption fix in particular is more thorough than what I asked for. What blocks now is a different set: the version floor the proposal restates as a hard precondition admits two CNPG releases where the mechanism provably does not work, the newly qualified "paused observation window" is not inert in the mirror case the qualification skipped, and record 0001's only tripwire watches a setting the named component does not have.

Closed since my last round

  • install-with-adoption reads live state today. The invariant is now scoped to upgrade/steady-state in all its restatements, the exception is in Consequences with the condition, the file, the line and the version, and the bounding premise checks out: grep -rn resource-policy across all fifteen files of packages/apps/postgres/templates at db-autoscaler-keda returns nothing, so an uninstall/reinstall remediation genuinely cannot reach it. The Revisit if line is a separate matter and is the third finding below.
  • The staging-mode flip as a third write path. Now enumerated in both documents, with the unclamped target stated in bold in the same §Upgrade bullet that carries the runbook, and the same warning duplicated on dryRun in the implementation's values.schema.json. Nothing left here.

Findings

[MAJOR] design-proposals/database-horizontal-autoscaling/README.md:37, the CNPG >= 1.28.4 floor is not monotone: 1.29.0 and 1.29.1 do not serve status.selector

Lines 29 and 37, both rewritten in this revision, and line 208 state the precondition as a lower bound: "On any CNPG >= 1.28.4 the approved mechanism ... works byte-for-byte", and "Precondition — CNPG >= 1.28.4. The Cluster scale subresource must serve status.selector, or the HPA rejects it with InvalidSelector and never scales". The selector landed on the 1.28 patch branch and on main, but the 1.29.0 and 1.29.1 releases were cut without it and it only returns in 1.29.2. Two versions that satisfy the documented floor fail the property the floor exists to guarantee, and by this proposal's own §Testing wording the HPA then reports ScalingActive=False, reason=InvalidSelector before any metric branching, with no other signal.

Verified on two independent artefacts per tag, the kubebuilder marker and the CRD that actually ships:

$ for t in v1.28.3 v1.28.4 v1.29.0 v1.29.1 v1.29.2 v1.30.0; do
    gh api "repos/cloudnative-pg/cloudnative-pg/contents/api/v1/cluster_types.go?ref=$t" \
      --header 'Accept: application/vnd.github.raw' | grep -m1 'kubebuilder:subresource:scale'; done
v1.28.3   (no selectorpath)
v1.28.4   selectorpath=.status.selector
v1.29.0   (no selectorpath)
v1.29.1   (no selectorpath)
v1.29.2   selectorpath=.status.selector
v1.30.0   selectorpath=.status.selector

$ # config/crd/bases/postgresql.cnpg.io_clusters.yaml, count of labelSelectorPath:
v1.29.0  0
v1.29.1  0
v1.29.2  1

Nothing ships broken today, since main vendors 1.30.0, so this is a wrong gate rather than a live break. But the gate is what a reader applies to an external CNPG, to a pin on the 1.29 line, or to a future downgrade, and it is repeated at lines 7, 15, 23, 29, 37 and 208. Either state it as >= 1.28.4 excluding 1.29.0 and 1.29.1 (equivalently >= 1.29.2 on the 1.29 line), or make the gate behavioural rather than numeric, which is how §Testing already frames it: the check is whether .../scale serves a non-empty status.selector. A version number is a proxy, and it has now been shown to leak.

[MAJOR] design-proposals/database-horizontal-autoscaling/README.md:179, the phase-1 window is not inert for a source above maxReplicaCount either, and staging cannot prevent that one

This is the same defect I raised last round, in its mirror case. The bullet now says the paused window "is inert on the running cluster for a multi-instance source (N >= the floor)" and carves out exactly one exception, N = 1, on the correct grounds that the HPA's below-min branch writes /scale without consulting the pause annotations. The above-max branch behaves identically and is not carved out:

// kubernetes/kubernetes@v1.33.4 pkg/controller/podautoscaler/horizontal.go:790,811-871
currentReplicas := scale.Spec.Replicas
...
rescale := true
} else if currentReplicas > hpa.Spec.MaxReplicas {
    rescaleReason = "Current number of replicas above Spec.MaxReplicas"
    desiredReplicas = hpa.Spec.MaxReplicas
} else if currentReplicas < minReplicas {
    desiredReplicas = minReplicas
} else {
    ... desiredReplicas = a.normalizeDesiredReplicasWithBehaviors(...)
    rescale = desiredReplicas != currentReplicas
}
if rescale {
    scale.Spec.Replicas = desiredReplicas
    _, err = a.scaleNamespacer.Scales(hpa.Namespace).Update(...)
}

Both pause annotations are implemented as behavior.Scale{Up,Down}.SelectPolicy = Disabled (kedacore/keda@v2.20.2 controllers/keda/hpa.go:85-118), and SelectPolicy is only read inside normalizeDesiredReplicasWithBehaviors, which both out-of-range branches skip while rescale keeps its initialised true. currentReplicas comes straight off scale.Spec.Replicas, so the branch fires on the spec value at the first HPA sync.

autoscaling.maxReplicas defaults to 6 (values.schema.json at db-autoscaler-keda). So an existing 8-instance Postgres taken through the documented phase 1 gets minReplicaCount: 2, maxReplicaCount: 6, both pause annotations, and instances: 8. The operator stages .Values.replicas = 8 exactly as instructed, the Helm patch is empty, and the HPA still writes /scale = 6 the moment it comes up, shedding two standbys and their PVCs inside the window this document calls an observation window. §5's "at most one standby is removed per period" does not hold here either, for the same reason: the above-max branch never reaches the scaleDown.policies the chart renders.

Unlike the seed-branch rebases, the staging recipe is structurally unable to help, because the trigger is maxReplicaCount against the live count, not the rendered instances. There is no render guard to catch it either: the chart fails on effectiveMin > maxReplicas and on replicas <= maxSyncReplicas, but nothing rejects or warns on replicas > maxReplicas.

You have most of the prose for this already. The implementation's values.yaml documents the branch under @field maxReplicas: "HAZARD: lowering maxReplicas below the live count makes the HPA cut straight to the new maximum in one step (the current>max branch bypasses the scale-down pacing)". That note covers the deliberate-lowering entry point, not this one, where the live count is above the default maximum before anything is lowered. Qualify the phase-1 and §5 dry-run claims for N > maxReplicaCount the way they were just qualified for N < effectiveMin, and add to the enable recipe that autoscaling.maxReplicas has to be raised to at least the live count before the flip.

[MAJOR] design-proposals/database-horizontal-autoscaling/decisions/0001-client-side-apply-preserves-the-autoscaler-seed.md:41, the Revisit-if trigger watches a helm-controller setting that does not exist

The record designates one trigger for its central invariant: "helm-controller starts enabling threeWayMergeForUnstructured by default, which would make the upgrade-path patch read live state and re-assert the constant". helm-controller has no such setting to enable or to default, which this record establishes correctly two paragraphs earlier at :27 ("helm-controller v1.5.0 never sets it (no reference anywhere in the controller)"). The two statements cannot both be load-bearing.

The lever lives in the embedded Helm SDK, and it is an asymmetry between two actions rather than a flag anyone flips:

$ gh api "repos/helm/helm/contents/pkg/action/upgrade.go?ref=v4.1.1" --header 'Accept: application/vnd.github.raw' \
    | grep -c ThreeWayMergeForUnstructured
0
$ # ... and the three options upgrade.go does pass, at :469-471:
kube.ClientUpdateOptionForceReplace(u.ForceReplace)
kube.ClientUpdateOptionServerSideApply(serverSideApply, u.ForceConflicts)
kube.ClientUpdateOptionUpgradeClientSideFieldManager(upgradeClientSideFieldManager)

$ # install.go computes it; rollback.go pins it off explicitly:
install.go:503   updateThreeWayMergeForUnstructured := i.TakeOwnership && !i.ServerSideApply
rollback.go:231  kube.ClientUpdateOptionThreeWayMergeForUnstructured(false)

$ # and on the controller side, for completeness:
$ gh api "search/code?q=threeWayMerge+repo:fluxcd/helm-controller" --jq .total_count
0

upgrade.go omits the option, so it falls to the Go zero value, while install.go:503 computes it and helm-controller sets TakeOwnership true by default on both actions (internal/action/install.go:91, internal/action/upgrade.go:113). A Helm release that makes upgrade consistent with install flips this invariant for every client-side release, arrives through an ordinary Flux image bump, and changes nothing a maintainer watching helm-controller settings would see. Since no repeatable test holds the behaviour, the trigger is the only protection there is, which is why aiming it at the wrong component matters more here than the wording suggests.

Restate it against helm.sh/helm/v4 pkg/action/upgrade.go gaining ClientUpdateOptionThreeWayMergeForUnstructured, and pin the SDK version the record was verified against so the next bump has something to diff. What would change my mind: evidence that helm-controller acquired such a knob after v1.5.0, which would make the sentence forward-looking rather than misdirected.

[MINOR] design-proposals/database-horizontal-autoscaling/README.md:123, §5 still documents a maxReplicaCount auto-raise that the implementation replaced with a hard render failure

"When the floor would exceed maxReplicas, the helper raises maxReplicaCount to the floor too (quorum wins, never clamp below a safe quorum) and the alert rules flag that the configured maximum was overridden." The implementation fails the render instead, deliberately, and says so in its own guard:

postgres: the effective minimum instance count (2 = max(minReplicas 2, maxSyncReplicas+1 1, 2))
exceeds autoscaling.maxReplicas (1); raise maxReplicas to at least the quorum floor.

The line sits outside this diff, but reconciling the proposal to the implementation is what this PR is for and §5 is a section it revised. Two schema-valid inputs now behave the opposite of what §5 promises.

[MINOR] design-proposals/database-horizontal-autoscaling/README.md:126, dryRun on its own renders nothing at all, with no signal

This revision adds "dryRun is an ordinary values-schema flag a tenant may set directly, not only a phase-1 step, so this caveat applies wherever it is used", which invites use outside the enable flow. templates/scaledobject.yaml:1 gates the whole file on .Values.autoscaling.enabled, so dryRun (and transition) without enabled: true renders output identical to the default: no ScaledObject, no HPA, no dashboard recommendation, and no render-time message saying why. Say in §5 that both staging flags require enabled: true, and ideally add a render-time fail for the combination on the implementation side.

[MINOR] design-proposals/database-horizontal-autoscaling/decisions/0001-client-side-apply-preserves-the-autoscaler-seed.md:1, the record is about twice the length the convention sets, and grew that way across the review rounds

design-proposals/README.md gives decision records a length of "one page" and warns that "A record that grows into a second design document will not get read". This one is 1801 words against 997 for the only existing example, and the growth is monotone across this PR's own rounds: 999 at 1d77dc8, 1061 at 0edc72f, 1191 at fb0f55f, 1603 at ab4ce85, 1801 at 2614afd. It landed at the precedent's length, and each round answered a review point by appending another qualifier paragraph to a document the convention says is frozen once merged. Consequences now carries three residual write paths at paragraph length each. Most of that detail already exists, better placed, in packages/apps/postgres/values.yaml and the _autoscaling.tpl header comment, which the convention explicitly prefers: "Where a decision's content is a fact about a type, a field or an invariant that code must respect, its primary home is a comment at that site plus a test". Link out to those and keep the record to the decision and the reason the alternatives lost. I am partly responsible for this growth, so treat it as a request to consolidate rather than to add.

Still open from my earlier rounds

These are not new. Each was raised in an earlier round on this PR and is still present at 2614afdd; a separate pass that saw only the checklist and the code, not the diff or the earlier verdicts, reached the same conclusion on each.

[MINOR] README.md:179, "minReplicaCount pinned to N" contradicts the same bullet and the chart. Raised as part of a MAJOR last round; the mechanism half is now fixed, so what remains is the stale parenthetical and I am dropping it to MINOR. It still reads "(autoscaling.keda.sh/paused-scale-in/paused-scale-out, minReplicaCount pinned to N)", and phase 1b leans on it ("because the count is pinned to N throughout"), while four sentences later the same bullet says the opposite, correctly: "the quorum floor forces minReplicaCount to effectiveMin >= 2". scaledobject.yaml:42 passes minReplicaCount as effectiveMin with no transition or dry-run branch, and tests/autoscaling_test.yaml:256-290 asserts spec.instances and both pause annotations for phase 1 but never minReplicaCount, so nothing pins the parenthetical's reading either. An operator who skims the parenthetical and stops there reads the false half. Drop it, or write effectiveMin.

[MINOR] README.md:141, autoscaling.transition is still absent from the document's own values contract. It gates the render condition printed at :96, it is the flag the phase-1 runbook at :179 tells the operator to set, and :121 claims the block is "validated by values.schema.json, like every other cozystack knob". The block at :136-148 lists enabled, minReplicas, maxReplicas, target, maxReplicationLagSeconds and dryRun. The real schema carries transition as a required property, so the document's contract is narrower than the thing it says it is showing.

[MINOR] README.md:182, the disable bullet still carries its omit-era rationale. "re-introduces instances: {{ .Values.replicas }}" and "so Flux reasserts the current value rather than dropping to the default" both describe a design §3 no longer has: the field is present in both arms, so nothing is re-introduced, and with replicas staged to M the phase-2 render is identical and the two-way patch is empty, so Flux asserts nothing. Two bullets earlier, at :180, the document states that correctly: "re-asserts nothing". The steps are right; the reasoning contradicts the mechanism the rest of the section relies on.

[MINOR] decisions/0001-...md:5, Status: Proposed is still outside the enumeration. decision-template.md:8 gives Accepted | Superseded by NNNN | Reverted, the status table in design-proposals/README.md lists the same three, the string "Proposed" appears in neither file, and the one in-tree precedent lands as Accepted. The added parenthetical also relies on a manual post-merge edit of the one block the same README says must track reality. Land it as Accepted.

[MINOR] decisions/0001-...md:15, the Context still conflates the ForceConflicts assignment with the apply-strategy default. "defaults new HelmReleases to server-side apply and hardcodes ForceConflicts = ServerSideApply (internal/action/install.go:53, same in upgrade.go/rollback.go)" is right about ForceConflicts, which really is identical in all three. The defaults are not: upgrade.go:103 and rollback.go:102 both set the literal auto, which resolves by reading the previous release's ApplyMethod (upgrade.go:74), and the source annotates that as deliberate ("regardless of UseHelm3Defaults"), while the install default is the one behind that gate. Neither document mentions auto, the inheritance, or the gate anywhere. It matters for the record's own argument: a Postgres release predating v1.5.0 was already applying client-side on every upgrade, so the "reverted on every apply" failure never touched that population.

[MINOR] README.md:126 and :179, still no KEDA version floor for the per-direction pause annotations. This revision made them more load-bearing, not less: both sections now reason in detail about their behaviour. They were introduced in KEDA v2.18.0 (CHANGELOG.md, "Add support for pause scale in annotation (#6902)" and "... scale out annotation (#7022)", both under the v2.18.0 heading; declared at apis/keda/v1alpha1/scaledobject_types.go:60-61 in v2.20.2). Below that version KEDA ignores unknown annotations, so a ScaledObject this document calls non-actuating would actuate immediately with nothing reporting the difference. The CNPG floor is stated precisely in six places; KEDA's version is still parked in §Open questions as a packaging question with no owner. Give it the same treatment.

Claim mismatches

[PARTIAL] "Under client-side apply the constant seed is a merge no-op, so KEDA's live value survives, deterministic and safe across enable/disable/dry-run with no handoff race" (PR body). The merge no-op is verified: helm.sh/helm/v4@v4.1.1 pkg/kube/client.go:1012-1064 routes unstructured objects to jsonpatch.CreateMergePatch(original, modified) unless threeWayMergeForUnstructured, pkg/action/upgrade.go:466-471 never passes it, original is built from the stored release manifest, and an identical render short-circuits with no API call. "Safe across enable/disable/dry-run" does not survive: the documents' own carve-outs shed the live count when .Values.replicas is unstaged, and per the second finding the paused window sheds it even when replicas is staged, whenever the live count exceeds maxReplicaCount.

[PARTIAL] "any field the chart renders is force-owned by Flux and reverted from the HPA's live value on every apply" (PR body, and 0001:15). The force-own half is verified (install.go:53, upgrade.go:76, rollback.go:92, all ForceConflicts = ServerSideApply). "On every apply" is right, but the record's phrasing at :21, "the upgrade action every steady-state reconcile takes", is not: DetermineReleaseState returns ReleaseStatusInSync for an unchanged release and no Helm action runs on that tick. The invariant is safer than stated, so this is imprecision rather than risk, but it is the sentence a future reader will quote.

Caveats

  • Hermetic review, no cluster contacted. The configuration-corner work is against the §3 snippet plus renders of the chart in cozystack/cozystack#3954 (branch db-autoscaler-keda, still OPEN and unmerged), not against a live apply.
  • Checked and sound, so these should not need re-litigating: helm-controller v1.5.0 pinning helm.sh/helm/v4 v4.1.1; the install-with-adoption three-way rebase behaving exactly as the record now describes, including requireAdoption putting the rendered object in originals so only the three-way arm reads live; TakeOwnership true by default on both actions; Install.ServerSideApply=false plus Upgrade.ServerSideApply=disabled threaded through pkg/registry/apps/application/rest.go and pinned by TestConvertApplicationToHelmRelease_ServerSideApplyOverride; the annotation name itself; CNPG's +kubebuilder:default:=1 on Instances and the webhook rejecting maxSyncReplicas >= instances; ignoreNullValues: "false" genuinely pinned in cozy-lib/templates/_keda.tpl and present in the render; cozystack#3951 merged and main vendoring CNPG 1.30.0; cozy-lib stamping the two per-direction annotations rather than KEDA's full paused, which would delete the HPA; every anchor and relative link in the touched README resolving.
  • Two write paths the record does not name are unreachable rather than missing, so they are not findings: rollback, because cozystack emits Upgrade.Strategy.Name=RetryOnFailure and never sets Upgrade.Remediation, so the retry path returns before a rollback remediation is selected; and drift correction, because DriftDetection.GetMode() defaults to disabled and the HelmRelease builder never sets it. Worth noting the annotation covers Install and Upgrade only, so "forced client-side" is exact for the two reachable actions and inherited from release history for the third.
  • The annotation is applied only on the aggregated-apps-API path. A HelmRelease for the postgres chart authored directly rather than through the apps API keeps the platform default and would revert the seed, so the record's per-kind scope statement still reads wider than the code path. Carried from an earlier round as a note, not a finding, because no such HelmRelease exists today.
  • Implemented in: cozystack/cozystack#3954 points at an open, unmerged PR. The template permits a PR link, but the header needs revisiting if that PR changes shape.
  • Per Phase 0 the reviewing pass did not read this PR's conversation or any earlier review; the reconciliation above was done separately, after the verdict was formed, so convergence between the two is not anchoring.

Recommended follow-ups

  • §5's lag brake specifies the write gate as rate(cnpg_pg_stat_replication_sent_diff_bytes[5m]) > 0 while the implementation uses rate(cnpg_collector_wal_records[5m]) and its comment asserts the proposal's metric "does not" exist. That assertion is wrong: sent_diff_bytes is defined in cloudnative-pg@v1.30.0 config/manager/default-monitoring.yaml. It is declared usage: "GAUGE" though, a bytes-behind figure rather than a monotonic counter, so rate() over it is not a write-rate signal whatever its availability. §Open questions still lists the lag-clamp shape as unsettled, so this belongs wherever that lands, not in this PR.
  • The record contemplates re-enabling SSA for non-autoscaled Postgres. Moving a live Cluster from server-side to client-side apply leaves the earlier helm Apply-type entry in managedFields untouched, because helm only migrates in the other direction (upgradeClientSideFieldManager requires serverSideApply). Re-enabling SSA later would reactivate that stale field set against an object whose .spec.instances has since been written by something else. Not exercisable without a cluster, so it is a note for whoever builds the per-instance apply strategy.
  • Once the two floor findings are settled, a live run is the only way to close the rest: replay a chart bump against an autoscaling-active grown cluster and confirm .spec.instances is untouched, and run the enable flow on a cluster whose live count exceeds autoscaling.maxReplicas.

Alexey Artamonov added 4 commits September 3, 2026 21:06
… a version floor

IvanHunters round 2 [MAJOR]: `CNPG >= 1.28.4` is not monotone. The
`status.selector` the HPA requires shipped per release line (1.28.4,
1.29.2, 1.30.0) and is absent from 1.29.0 and 1.29.1, which were cut
before the 1.29-line backport. A `>= 1.28.4` bound admits two releases
where the mechanism provably does not work.

Restate the gate behaviourally in all six places (frontmatter PoC line,
Overview note, PoC-finding intro, Resolution, Scope Precondition,
Testing): the precondition is that the `Cluster` /scale subresource
serves a non-empty `status.selector`, and the version is a proxy that
leaks. The full carrying matrix (1.28.4+, 1.29.2+, 1.30.0+; not
1.29.0/1.29.1) lives once in Scope Precondition; the other spots defer
to it. cozystack vendors 1.30.0 (#3951), which serves it.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
Two [MAJOR] findings from IvanHunters round 2:

- Above-max shed (mirror of the covered N<effectiveMin case). The HPA's
  currentReplicas>maxReplicaCount branch writes /scale=maxReplicaCount
  unconditionally, bypassing the pause annotations and the scale-down
  pacing, so an 8-instance cluster under the default maxReplicas 6 is
  cut to 6 on cold start, shedding standbys during the "observation"
  window. Staging replicas cannot help (trigger is maxReplicaCount vs
  live count). Add the mirror to phase-1, the §5 dry-run bullet and the
  §5 scale-down pacing bullet; add "raise autoscaling.maxReplicas to at
  least the live count before the flip" to the recipe; note the chart
  has no replicas>maxReplicas render guard today (follow-up for #3954).
- Revisit-if trigger. It named "helm-controller starts enabling
  threeWayMergeForUnstructured", a setting helm-controller does not
  have (the record says so two paragraphs up). Retarget it to the real
  lever: helm.sh/helm/v4 pkg/action/upgrade.go gaining/passing
  ClientUpdateOptionThreeWayMergeForUnstructured to match install.go:503,
  and pin the verified SDK version (v4.1.1) for the next bump to diff.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
- §5 quorum floor: the helper fails the render (not an auto-raise of
  maxReplicaCount) when the floor would exceed maxReplicas, matching the
  implementation's guard.
- Tenant values block: add the schema-required `transition` sub-flag.
- §5 dry-run: both `dryRun` and `transition` require `enabled: true`,
  else scaledobject.yaml renders nothing with no signal.
- KEDA version floor: the per-direction pause annotations need KEDA
  >= 2.18.0 (below it they are ignored and the "paused" ScaledObject
  actuates); stated at §5 and settled in the Open-questions KEDA item.
- Disable bullet: replace the omit-era rationale ("re-introduces
  instances"/"Flux reasserts") with the constant-seed/empty-two-way-patch
  reality.
- ADR: Status Proposed -> Accepted (template enumeration); correct the
  ForceConflicts-vs-apply-default conflation (upgrade/rollback default to
  auto, install behind UseHelm3Defaults); consolidate the verbose prose
  and point field-level detail at values.yaml / _autoscaling.tpl, without
  dropping any citation, version pin, or finding response.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
Independent cozy-review pass on 977f003:

- [MINOR] Phase-1 parenthetical said the paused ScaledObject pins
  `minReplicaCount` to N, but the chart renders it as `effectiveMin`
  unconditionally (no transition/dryRun override), and the same bullet
  says so four sentences later. Correct the parenthetical to
  `minReplicaCount = effectiveMin`.
- [NIT] decision 0001 Decision: "the upgrade action every steady-state
  reconcile takes" overstates — an unchanged release short-circuits to
  InSync and runs no Helm action. Qualify to "any applying reconcile".
- [NIT] §5 replication-lag brake: note it is opt-in
  (maxReplicationLagSeconds defaults to 0) and the clamp query shape is
  a still-open item.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
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.

2 participants