Skip to content

fix(operator): apply persistence volume size increases online - #1634

Closed
sylr wants to merge 3 commits into
release/v3.0from
fix/operator-pvc-volume-expansion
Closed

fix(operator): apply persistence volume size increases online#1634
sylr wants to merge 3 commits into
release/v3.0from
fix/operator-pvc-volume-expansion

Conversation

@sylr

@sylr sylr commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Editing spec.persistence.<vol>.size on a Cluster (e.g. data: 2Ti → 3Ti) did nothing to the disks. The StatefulSet reconcile only detected volumeClaimTemplate name changes (volumeClaimTemplatesChanged compares the name set), and the normal update path deliberately skips the immutable VCT. The size bump did change the pod-template spec-hash — so pods rolled — while the volumes stayed at the old size. The STS volumeClaimTemplates kept reporting the old size.

Change

  • New reconcilePVCExpansion (internal/controller/reconcile_pvc_expansion.go): patches each existing PVC's spec.resources.requests.storage upward, triggering CSI online expansion. Grow-only.
  • Wired into reconcileStatefulSet: after the expansion pass, delete-recreate the StatefulSet with DeletePropagationOrphan (pods/PVCs retained and re-adopted, no restart) whenever VCT names changed or a size grew, so future scale-out ordinals inherit the new template size.
  • README: new "Volume Resizing" section documenting the online-grow flow, the allowVolumeExpansion: true prerequisite, and grow-only semantics.

Behavior notes

  • Grow-only. A smaller desired size emits a VolumeShrinkRejected warning event and leaves the PVC untouched (K8s cannot shrink a PVC). Each successful expansion emits VolumeExpanded.
  • Shrink is decided from the template delta, not per-PVC size — a PVC manually expanded past the new template size is left alone rather than falsely warned every reconcile. Warns once per volume, not per PVC.
  • Skipped during scale-down so the recreate's early return does not bypass deleteScaledDownPVCs and orphan the removed ordinals' PVCs (also closes the same latent issue in the pre-existing name-change branch). The template change is picked up on the next reconcile once the scale-down settles.

Why not operator-owned PVCs?

Keeping volumeClaimTemplates preserves per-ordinal RWO identity for the Raft nodes for free. Owning PVCs directly means either shared storage (broken) or reimplementing StatefulSet (unacceptable risk). The gap was only the missing grow path — same pattern as Prometheus Operator / CloudNativePG.

Review

Reviewed by Codex (gpt-5.5): confirmed the resource.Quantity.Cmp approach, the requests.storage patch API, and orphan re-adoption. It found two Medium issues — false shrink warnings for over-expanded PVCs, and the resize+scale-down cleanup bypass — both fixed in this branch and covered by tests.

Tests

  • 6 unit tests in reconcile_pvc_expansion_test.go (grow-all + recreate signal, no-op, shrink rejected, missing ordinal skipped, over-expanded left alone with no event, shrink-warns-once-per-volume).
  • Full internal/controller package passes; golangci-lint 0 issues; go build ./... clean.

Not covered (needs e2e follow-up)

The full delete-recreate + pod re-adoption cycle and the resize+scale-down deferral are not unit-testable with a fake clientset (raftScaleDown execs into pods).

A change to spec.persistence.<vol>.size was silently dropped: the
StatefulSet reconcile only detected volumeClaimTemplate *name* changes,
and the normal update path skips the immutable VCT entirely — so the
size bump bumped the pod-template spec-hash (rolling the pods) while the
disks stayed at their old size.

Add a grow-only PVC expansion pass: patch each existing PVC's
spec.resources.requests.storage upward (CSI online expansion), then
delete-recreate the StatefulSet with orphan propagation so future
scale-out ordinals inherit the new template size. Grow-only — a shrink
is rejected with a VolumeShrinkRejected warning event; each expansion
emits VolumeExpanded.

Shrink intent is decided from the template delta, not per-PVC size, so a
PVC manually expanded past the new template size is left alone rather
than warned about on every reconcile. The expansion/recreate is skipped
while a scale-down is in progress so it does not bypass the
deleteScaledDownPVCs cleanup and orphan the removed ordinals' PVCs.

Constraint: StatefulSet volumeClaimTemplates are immutable; live PVCs can only grow via a direct requests.storage patch, and the template can only be re-emitted by orphan delete-recreate
Constraint: StorageClass must have allowVolumeExpansion=true for the CSI patch to take effect
Rejected: Operator-owned PVCs instead of volumeClaimTemplates | loses per-ordinal RWO identity or forces reimplementing StatefulSet — unacceptable risk for a Raft cluster
Rejected: Spec-only shrink validation in validateClusterConfig | it cannot see the prior size, so it cannot detect a shrink
Confidence: high
Scope-risk: moderate
Directive: shrink detection MUST stay keyed on the template delta, not the per-PVC current size, or over-expanded PVCs emit false shrink warnings every reconcile
Not-tested: full delete-recreate + pod re-adoption cycle and the resize+scale-down deferral (raftScaleDown execs into pods; not unit-testable with a fake clientset) — needs e2e
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dffb1cdc-1d2c-4087-8672-696ae6967644

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/operator-pvc-volume-expansion

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.

@NumaryBot

NumaryBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

✅ Approve — automated review

Both major issues identified in the automated review — the rejected-shrink size leaking into the recreated StatefulSet template, and the resize-only pod-template hash causing an unintended pod rollout — were acknowledged by the author and addressed in commit 08227de. The fix floors each desired template size to the largest live PVC (grow-only, sourced from live PVCs that survive the orphan delete), clamps rejected shrinks with a VolumeShrinkRejected event, and applies the clamp unconditionally on every reconcile path including the post-delete create. Regression tests covering the mixed grow/shrink scenario were also added. The sole independent reviewer found no additional correctness issues beyond what was already discussed and resolved. No remaining blocker or major findings are present.

No findings.

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

NumaryBot posted 1 new inline finding.

Summary: #1634 (comment)

Comment thread misc/operator/internal/controller/reconcile_statefulset.go

@flemzord flemzord left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed live head f064a124. The existing NumaryBot finding on this same head is a correctness/runtime blocker: if one volume grows while another is requested smaller, the PVC expansion pass rejects the shrink but the grow still triggers StatefulSet recreation from the unmodified desired templates. The recreated template therefore persists the rejected smaller size for future ordinals. Please sanitize rejected shrink templates back to their existing sizes (and add the mixed grow/shrink regression) before recreating. I am not duplicating the inline finding. E2E CI is still running and is not the basis of this request for changes.

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.46%. Comparing base (e430164) to head (97c4450).

Additional details and impacted files
@@               Coverage Diff                @@
##           release/v3.0    #1634      +/-   ##
================================================
- Coverage         74.75%   74.46%   -0.29%     
================================================
  Files               446      446              
  Lines             47485    47485              
================================================
- Hits              35497    35360     -137     
- Misses             8777     8929     +152     
+ Partials           3211     3196      -15     
Flag Coverage Δ
e2e 74.46% <ø> (-0.29%) ⬇️
scenario 74.46% <ø> (-0.29%) ⬇️
unit 74.46% <ø> (-0.29%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

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

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Addresses the mixed grow/shrink review finding (NumaryBot + @flemzord):
when one volume grew and another was requested smaller in the same
Cluster update, the grow forced a StatefulSet delete-recreate that
rebuilt every volumeClaimTemplate from spec — leaking the rejected
smaller size into the recreated template and every future scale-out
ordinal.

Floor each desired template size up to the largest live PVC for that
volume (grow-only), applied on every reconcile path — including the
create path right after the orphan delete, where the old template is
gone. The floor comes from the live PVCs, which survive the orphan
delete, so it is stable across the recreate. A spec size below the live
disk is rejected with a single VolumeShrinkRejected event per volume and
the template keeps the current size.

Merge the clamp and grow into a single PVC pass and run it
unconditionally (previously only inside the STS-found branch), so the
create path is also clamped.

Constraint: the StatefulSet template is destroyed by the orphan delete-recreate, so the pre-shrink size must be recovered from a source that survives it — the live PVCs
Rejected: floor against the existing StatefulSet template | the template is gone on the post-recreate create pass, so it cannot floor the rebuilt template
Confidence: high
Scope-risk: narrow
Directive: the grow-only floor MUST be sourced from live PVCs, not the STS template, or the mixed grow/shrink leak returns on the recreate path
Not-tested: end-to-end recreate + pod re-adoption cycle (raftScaleDown execs into pods; not unit-testable with a fake clientset)
@sylr

sylr commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 08227de.

Root cause: the grow forced a StatefulSet delete-recreate that rebuilt every volumeClaimTemplate from spec, and my shrink handling didn't survive the recreate — on the post-delete reconcile the old template is gone, so the rejected smaller size leaked back into the rebuilt template.

Fix: floor each desired template size up to the largest live PVC for that volume (grow-only), applied on every reconcile path — including the create path right after the orphan delete. The floor is sourced from the live PVCs (which survive the orphan delete), not the StatefulSet template, so it's stable across the recreate. A spec size below the live disk is rejected with a single VolumeShrinkRejected event per volume and the template is clamped back to the current size — so a rejected shrink can no longer reach a rebuilt template or future scale-out ordinals.

The clamp + grow are now a single PVC pass run unconditionally (previously only inside the STS-found branch), so the create path is clamped too.

Regression tests added (reconcile_pvc_expansion_test.go):

  • MixedGrowShrinkDoesNotLeakShrink — data 2Ti→3Ti grows while wal 5Gi→4Gi is clamped back to 5Gi; asserts the desired wal template stays 5Gi.
  • ClampSurvivesRecreatePath — models the post-orphan-delete reconcile (existingTemplates == nil); the live PVC still floors the rebuilt template.

Full internal/controller package passes; golangci-lint 0 issues.

Thanks @flemzord for the catch.

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

NumaryBot posted 2 new inline findings.

Summary: #1634 (comment)

Comment thread misc/operator/internal/controller/reconcile_statefulset.go
Comment thread misc/operator/internal/controller/reconcile_statefulset.go
Addresses review finding: a volume-size change bumped computeSpecHash,
which is embedded in the pod-template annotation. On the online-expansion
recreate the new StatefulSet then derived a different ControllerRevision
from the adopted pods and (Partition 0) rolled them — so the "online,
no-restart" expansion actually restarted every pod.

Size, storageClass, accessMode and volumeAttributesClassName map to the
VolumeClaimTemplates / live PVCs, never to the pod template
(buildPodTemplate ignores them), so exclude them from the hash — same
rationale as the already-excluded DeletionProtection. HostPath is kept:
switching PVC<->hostPath genuinely changes the pod template.

Also clarify at the recreate site that the branch only deletes; the
replacement template is built on the next reconcile, which re-clamps
desired against the live PVCs — so a rejected shrink cannot leak into the
recreated template (the second reviewer finding, already handled by the
live-PVC floor, was a false positive on the deferred-recreate pattern).

Constraint: adopted-pod ControllerRevision is derived from the pod template incl. the spec-hash annotation, so any hash input that is not a real pod-template field forces an unwanted rollout
Confidence: high
Scope-risk: narrow
Directive: keep HostPath in the hash — it is the one VolumeSpec field that changes the pod template
Not-tested: observing zero pod restarts on a live cluster resize (needs e2e)
@sylr

sylr commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Second review pass addressed in 97c4450.

Finding B — resize alters pod-template hash → pod rollout (valid, fixed).
computeSpecHash included persistence.*.size, so a resize bumped the pod-template annotation; on the expansion recreate the new StatefulSet derived a different ControllerRevision from the adopted pods and (Partition 0) rolled them — so the "no-restart" expansion actually restarted every pod. Fixed by excluding the PVC-only VolumeSpec fields (size, storageClass, accessMode, volumeAttributesClassName) from the hash — they map to the VolumeClaimTemplates/PVCs and never appear in buildPodTemplate, same rationale as the already-excluded DeletionProtection. HostPath is deliberately kept (PVC↔hostPath does change the pod template). Tests added in hash_test.go (size + PVC-only fields excluded; hostPath still included).

Finding A — rejected shrink persisted into recreated template (already handled; false positive on this head).
The templateGrew branch only deletes the StatefulSet with orphan propagation and returns — it does not build the replacement template. The new StatefulSet is created on the next reconcile, which re-runs reconcilePVCExpansion and re-clamps desired against the live PVCs (which survive the orphan delete), so the shrunk volume is floored back to its current size before the template is ever rebuilt. That's exactly what the ClampSurvivesRecreatePath test covers (existingTemplates == nil, live PVC still floors the rebuilt template), and MixedGrowShrinkDoesNotLeakShrink asserts the wal template stays 5Gi. The suggested "sanitize against the existing StatefulSet VCTs" wouldn't work here — on the post-delete reconcile the old StatefulSet is gone, which is why the floor is sourced from PVCs instead. Added a comment at the recreate site to make the deferred-recreate flow explicit.

All CI green on the prior head; internal/controller package + lint clean locally.

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

NumaryBot review complete: no remaining inline findings.

Resolved 2 stale NumaryBot review threads (2 fixed, 0 outdated).

Summary: #1634 (comment)

@flemzord

Copy link
Copy Markdown
Member

Revalidated live head 97c44509. The correctness blocker from my earlier changes-requested review is fixed on this head, and NumaryBot reports no remaining finding; CI is green. I found no remaining correctness/runtime blocker. GitHub approval is intentionally disabled by this cron configuration.

@Azorlogh

Copy link
Copy Markdown
Contributor

Non-expandable StorageClass turns a size bump into a wedged reconcile. The PersistentVolumeClaimResize admission plugin (default-enabled) rejects the requests.storage patch with 403 when the SC lacks allowVolumeExpansion: true; reconcilePVCExpansion returns that error and reconcileStatefulSet aborts before the CreateOrUpdate — blocking image bumps, replica changes, scale-down PVC cleanup, and volume-protection labels on every requeue until the spec size is reverted. No event is emitted on this path. Suggest catching the forbidden error, emitting a warning event, and skipping that volume so the rest of the pass proceeds.

@flemzord flemzord left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Revalidated live head 97c44509 after the new Azorlogh comment. I confirm that existing comment as a correctness/runtime blocker: a rejected PVC expansion patch returns before the normal StatefulSet update and the remaining reconciliation passes, so an unsupported StorageClass can leave unrelated desired changes permanently blocked until the size request is reverted. I am not duplicating the existing details. Please ensure an unsupported expansion cannot wedge the rest of reconciliation. CI is green and is not the basis of this review.

@sylr

sylr commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

superseded by #1674

@sylr sylr closed this Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants