SCF-830: Added state management for cluster hibernation#17
Conversation
Coverage Report for CI Build 29651611343Warning No base build found for commit Coverage: 44.986%Details
Uncovered Changes
Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
serdardalgic
left a comment
There was a problem hiding this comment.
Some minor issues @adshin21. After that, I would like to review it again.
| if c.Statefulset == nil || c.Statefulset.Spec.Replicas == nil { | ||
| return nil | ||
| } | ||
| return c.Statefulset.Spec.Replicas |
There was a problem hiding this comment.
| 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?
There was a problem hiding this comment.
Yes, but to get the current status, we need to make an API call to k8s.
| const ( | ||
| LifecycleActionNone LifecycleAction = iota | ||
| LifecycleActionHibernate // Running -> Stopping (initiate hibernate) | ||
| LifecycleActionStoppingCompleted // Stopping -> Stopped (pods fully terminated) |
There was a problem hiding this comment.
Are you using LifecycleActionStoppingCompleted ? If not, please remove it.
…sist lifecycle spec changes in Sync
serdardalgic
left a comment
There was a problem hiding this comment.
So, some stuff that caught my attention, here it is.
|
|
||
|
|
There was a problem hiding this comment.
This is also a mistake, I guess.
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.
7f48801 to
c378463
Compare
| // - (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) { |
There was a problem hiding this comment.
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 ?
|
|
||
|
|
| // 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 { |
There was a problem hiding this comment.
So, AFAICS, currently hibernate implementation is as follows:
A cluster is patched to hibernate, operator calls Update():
- Lock the cluster, so nothing else can touch it.
handleHibernateAndWakeUp()detects hibernate request and callssyncStateLocked(), which callsprepareLifecycleTransition()that initiates Hibernation viainitiateHibernate()that sets the Spec'snumberOfInstancesto 0.- Then the
handleHibernateAndWakeUp()callscompleteStoppingTransition()when the action is Hibernate. This one callswaitStatefulsetPodsGone()which is a blocking function. In a Retry loop, it keeps asking "are the pods gone yet?" every 3 seconds (ResourceCheckIntervaldefault value), until 10 minutes timeout (ResourceCheckTimeoutdefault). - 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:
- Lock the cluster, same as before
syncStateLocked(),initiateHibernate(), same as before- Now, you can ask Kubernetes
are the pods gone yet?once, with something likecheckStoppingCompleted()instead ofcompleteStoppingTransition()/waitStatefulsetPodsGone() - If yes, write
Stopped, done, unlock. SamepersistStoppingCompletedTransition()implementation. - If no, just write/leave
Stoppingand 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.
No description provided.