Skip to content

SCF-830: Added state management for cluster hibernation#17

Open
adshin21 wants to merge 18 commits into
scalefield_v18from
scalefield_v18-SCF-830-cluster-hibernation
Open

SCF-830: Added state management for cluster hibernation#17
adshin21 wants to merge 18 commits into
scalefield_v18from
scalefield_v18-SCF-830-cluster-hibernation

Conversation

@adshin21

Copy link
Copy Markdown

No description provided.

@coveralls

coveralls commented May 13, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 29651611343

Warning

No base build found for commit d16504e on scalefield_v18.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 44.986%

Details

  • Patch coverage: 108 uncovered changes across 7 files (272 of 380 lines covered, 71.58%).

Uncovered Changes

File Changed Covered %
pkg/cluster/lifecycle.go 242 187 77.27%
pkg/cluster/util.go 14 0 0.0%
pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go 23 12 52.17%
pkg/cluster/cluster.go 57 47 82.46%
pkg/apis/acid.zalan.do/v1/util.go 9 3 33.33%
pkg/cluster/sync.go 29 23 79.31%
pkg/util/k8sutil/k8sutil.go 6 0 0.0%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 15747
Covered Lines: 7084
Line Coverage: 44.99%
Coverage Strength: 16.37 hits per line

💛 - Coveralls

@adshin21
adshin21 marked this pull request as ready for review May 21, 2026 07:26
@adshin21
adshin21 requested a review from hughcapet as a code owner May 21, 2026 07:26
@serdardalgic
serdardalgic requested a review from ants May 22, 2026 09:20

@serdardalgic serdardalgic left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Some minor issues @adshin21. After that, I would like to review it again.

Comment thread pkg/cluster/lifecycle.go Outdated
if c.Statefulset == nil || c.Statefulset.Spec.Replicas == nil {
return nil
}
return c.Statefulset.Spec.Replicas

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
return c.Statefulset.Spec.Replicas
return c.Statefulset.Status.Replicas

Shouldn't this be Status as you want to get the current replica count of the StatefulSet?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, but to get the current status, we need to make an API call to k8s.

Comment thread pkg/cluster/cluster.go
Comment thread pkg/cluster/lifecycle.go Outdated
const (
LifecycleActionNone LifecycleAction = iota
LifecycleActionHibernate // Running -> Stopping (initiate hibernate)
LifecycleActionStoppingCompleted // Stopping -> Stopped (pods fully terminated)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Are you using LifecycleActionStoppingCompleted ? If not, please remove it.

@adshin21
adshin21 requested a review from serdardalgic July 14, 2026 08:15

@serdardalgic serdardalgic left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

So, some stuff that caught my attention, here it is.

Comment thread pkg/cluster/cluster_test.go Outdated
Comment thread pkg/cluster/lifecycle.go Outdated
Comment thread docs/reference/cluster_manifest.md Outdated
Comment thread pkg/apis/acid.zalan.do/v1/postgresql_type.go Outdated
Comment thread pkg/cluster/resources.go
Comment on lines +828 to +829


Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change

This is also a mistake, I guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This one still remains @adshin21

Comment thread pkg/cluster/cluster.go
adshin21 added 5 commits July 18, 2026 15:22
Replace the four-line flag-juggling (isWakingUpSimple /
hasPreviousInstances / needsRestore) with a wantsStopped flag plus
three self-documenting guards. This removes the latent bug where
the inner isWakingUp check was being dropped (Stopped + phase=stopped
incorrectly returned WakeUp).
…enum

Introduce LifecyclePhase string type with LifecyclePhaseStopped
constant and Phase.Stopped() helper; the CRD enum accepts "" or
"stopped" via +kubebuilder:validation:Enum="";stopped.

Tighten blockLifecycleUpdate error message and cluster_manifest.md
wake-up text to mention setting lifecycle.phase to an empty string.

Generate DeepCopy for PostgresStatus.PreviousPoolerInstances and use
it in Postgresql.DeepCopyInto so the operator's in-memory cache does
not share map state with the cluster object.
Tighten the second wake-up guard in detectLifecycleTransition to
require PreviousNumberOfInstances > 0. Without a stored replica count,
transitioning to Updating with 0 replicas leaves the cluster stuck
indefinitely: the sync defer skips Running when oldSpec had 0
instances, and the next periodic Sync sees the same dead-end state.

The "Stopped + cleared + no previous instances" test case is renamed
and updated to expect LifecycleActionNone, since the previous behavior
was a recovery attempt on a corrupted state that produced a worse
outcome than the cluster staying in Stopped.

Update initiateWakeUp's doc comment to note that the
PreviousNumberOfInstances == 0 branch is now unreachable in practice
(detect refuses the transition upstream).
* Rewrite the misleading comment in syncStateLocked's defer. The old
  wording claimed oldSpec.Spec.NumberOfInstances == 0 during wake-up
  because initiateWakeUp hadn't run yet; the real reason is that
  c.Postgresql was already hibernated when the reconcile started.

* Skip 0-replica entries in scalePoolerDown's stored map. A pooler
  already at 0 has nothing to restore on wake-up; storing it produced
  a spurious no-op patch on the way up. Update the corresponding test
  case to expect nil instead of {"master": 0}.

* Document the PreviousNumberOfInstances == 0 limitation in
  cluster_manifest.md: the operator will not transition out of
  Stopped if the field is missing (e.g. due to a partial write or a
  manual edit); the user must edit spec.numberOfInstances to wake
  the cluster up manually.
…dates

Fix bug encountered in e2e tests: user annotations (e.g.
last-major-upgrade-failure) were silently dropped when a watch event arrived
mid-lifecycle, causing the v16->v18 major version upgrade to proceed despite
the failure annotation.
@adshin21
adshin21 force-pushed the scalefield_v18-SCF-830-cluster-hibernation branch from 7f48801 to c378463 Compare July 18, 2026 16:15

@serdardalgic serdardalgic left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Here is my review.

Comment thread pkg/cluster/cluster.go
// - (true, nil) if update is blocked and caller should return early
// - (false, nil) if update can proceed
// - (false, error) on error
func (c *Cluster) blockLifecycleUpdate(newSpec *acidv1.Postgresql) (bool, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This function basically doesn't block anything, the name is a bit misleading.
Shouldn't it be better to name it something like shouldBlockLifecycleUpdate or isLifecycleUpdateBlocked ?

Comment thread pkg/cluster/resources.go
Comment on lines +828 to +829


Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This one still remains @adshin21

Comment thread pkg/cluster/util.go
// iteration refreshes c.Statefulset so the in-memory cache stays current.
// NotFound is treated as success (the cluster may have been deleted entirely).
// On timeout, returns an error so callers can surface it to the controller.
func (c *Cluster) waitStatefulsetPodsGone() error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

So, AFAICS, currently hibernate implementation is as follows:
A cluster is patched to hibernate, operator calls Update():

  1. Lock the cluster, so nothing else can touch it.
  2. handleHibernateAndWakeUp() detects hibernate request and calls syncStateLocked(), which calls prepareLifecycleTransition() that initiates Hibernation via initiateHibernate() that sets the Spec's numberOfInstances to 0.
  3. Then the handleHibernateAndWakeUp() calls completeStoppingTransition() when the action is Hibernate. This one calls waitStatefulsetPodsGone() which is a blocking function. In a Retry loop, it keeps asking "are the pods gone yet?" every 3 seconds (ResourceCheckInterval default value), until 10 minutes timeout (ResourceCheckTimeout default).
  4. The lock is released only after the loop ends, either with success or a timeout.

This blocking step 3 is a bit tricky. The pod might be stuck, any referenced resource might be taking a long time to release, maybe the pod gets stuck on a node that's being drained by the autoscaler, yada yada.. there are various scenarios where a single pod ends up blocking the whole reconcilation loop for this cluster.

I think a more solid version would be a non-blocking implementation. Check this one out:

  1. Lock the cluster, same as before
  2. syncStateLocked(), initiateHibernate(), same as before
  3. Now, you can ask Kubernetes are the pods gone yet? once, with something like checkStoppingCompleted() instead of completeStoppingTransition()/waitStatefulsetPodsGone()
  4. If yes, write Stopped, done, unlock. Same persistStoppingCompletedTransition() implementation.
  5. If no, just write/leave Stopping and unlock immediately, No waiting, no loop.

This time, you don't need to babysit the pods until they're gone. Sync() already has a Stopping() recovery branch (if c.Status.Stopping() { return c.completeStoppingTransition(newSpec) }) meant for operator-restart recovery, it can just call checkStoppingCompleted() too, and since it runs on every regular reconcile, it naturally re-checks without needing a loop anywhere.

This design was already there, but it was checking the Spec.Replicas instead of Status.Replicas. This was probably the reason you redesigned this part. You're right that it needs a live API call, I just don't think we need to implement it in a retry loop.

So, I'd suggest implementing a non-blocking version of this check instead.

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.

3 participants