Rollout state machine
How the release controller decides the rollout state, the verdict and the rollback in each reconcile.
The rollout state machine is in the release controller. Each reconcile reads the persisted
status.rollout, makes at most one transition, applies the render that the new state selects,
and writes the status. This page describes the implementation. For the user-level behavior, see
Progressive rollout.
Two decision points#
Only two functions decide a rollout transition:
| Function | When | Transitions |
|---|---|---|
planRollout | Before the apply, after the render | All transitions that the health verdict causes |
waveHealthGate | During the apply, at a sync wave that waits | The transitions that a sync-wave timeout causes |
planRollout performs no side effect. It returns a plan: the next rollout status and an action.
The action is actionApplySource or actionApplyLastHealthy. reconcile executes the action.
Order in a reconcile#
reconcile does these steps:
stageRenderfetches the source, resolves the Cluster and renders. If it fails,reconcilereturns. The outcome has no rollout.- If the Release has no pin,
planRolloutmakes the plan. With a pin, the plan isactionApplySourcewith no rollout. - For
actionApplyLastHealthy,applyLastHealthyapplies the last healthy render, andreconcilereturns. stageGateschecks the manual sync mode and the cluster wave gate. If a gate stops the reconcile, the outcome takes the rollout of the plan.stageSyncruns the dry run, the persist, the hooks, the waves and the prune. IfwaveHealthGateset a rollout, that rollout replaces the plan. Otherwise the outcome takes the rollout of the plan.
Transition table#
R is the render of this reconcile. prior is the persisted status.rollout. The first
matching row applies:
| Condition | New status.rollout | Action |
|---|---|---|
No spec.failurePolicy | none | apply R |
Forced sync, or prior is empty, or prior.renderRef is not R | Progressing{R}, startedAt = now | apply R |
prior is Succeeded | unchanged | apply R |
prior is RolledBack | unchanged | apply last healthy |
prior is Failed, verdict Healthy | Succeeded{R}, same startedAt | apply R |
prior is Failed, other verdict | unchanged | apply R |
prior is Progressing, verdict Healthy | Succeeded{R}, same startedAt | apply R |
prior is Progressing, verdict Failed, rollback eligible | RolledBack{R}, same startedAt | apply last healthy |
prior is Progressing, verdict Failed, not eligible | Failed{R}, same startedAt | apply R |
prior is Progressing, verdict Pending | unchanged | apply R |
rollbackEligible is true when spec.failurePolicy.rollback is not Never and
status.lastHealthy is set. It does not check that a render store exists.
A new render always gives Progressing first. The verdict for R applies only at a later
reconcile. Thus a verdict of an earlier render cannot give an early Succeeded or Failed.
Verdict#
verdict reads the per-resource health from the ReleaseInventory. The observer writes this
health. The function returns one of three values:
| Verdict | Condition |
|---|---|
Healthy | spec.healthAssessment is not set. Or each inventory entry has assessedRenderRef = R and state Healthy. |
Failed | At least one entry has assessedRenderRef = R and state Progressing or Degraded, and the time since startedAt is more than spec.healthAssessment.deadline. |
Pending | All other cases. This includes an empty R, an empty or unreadable inventory, an Unknown state and an entry assessed for another render. |
The controller owns the deadline. The clock starts at status.rollout.startedAt, which the
controller writes when it opens the rollout. Thus each render has its own deadline. The
observer does not decide the rollout failure.
If the observer stops, the inventory receives no new health. The verdict stays Pending, so convoy
waits and does not roll back.
The wave gate#
waveHealthGate runs when a sync wave with a health strategy waits for the previous wave. It is
the only transition at apply time. A wave timeout is visible only during the wave loop, so
planRollout cannot decide it before the apply.
When the time since status.waves[].startedAt of the previous wave is more than the wave timeout:
spec.failurePolicy | Result |
|---|---|
rollback: OnWaveFailure, rollback eligible, and applyLastHealthy succeeds | RolledBack{R}, reason RolledBack |
| Set, other cases | Failed{R}, reason RolloutFailed |
| Not set | No rollout, reason WaveFailed |
priorRolloutStartedAt keeps the startedAt of prior when prior.renderRef is R. Otherwise
it uses the current time. When the wave did not time out, the gate holds with reason
WaitingForWaveHealth and sets no rollout.
The wave gate runs only when stageSync runs. With prior in RolledBack, reconcile returns
at step 3, so the wave gate cannot change a RolledBack rollout.
Apply of the last healthy render#
applyLastHealthy is one code path for three cases: the first rollback, the hold at each later
reconcile, and the rollback after a wave timeout. It does these steps:
- It returns without work when
status.lastHealthyis empty or there is no render store. - It reads the blob of
status.lastHealthy.renderReffrom the render store. - It applies all objects with the deployer in one pass. There are no sync waves, no dry run and no hooks.
- It writes
status.lastHealthy.renderRefas the render of each applied object. - It prunes with
spec.syncPolicy.prune. - It writes the applied objects to the
ReleaseInventory.
On success, the outcome has applied true and reason RolledBack. status.lastAttempted and
status.lastSuccessful then contain the last healthy render. applyLastHealthy does not add a
history entry and does not run the garbage collection.
If a step fails, applyLastHealthy logs the error and returns false. In the hold path,
reconcile then returns an outcome with reason RolledBack and the planned rollout. It does not
apply R. It returns no error, so the requeue interval starts the next attempt. The error
backoff does not apply.
Status assembly#
syncHandler writes the status after reconcile:
status.lastAttemptedis the attempt of the outcome.status.lastSuccessfulchanges only when the outcome hasappliedtrue.status.lastHealthyis the render of the attempt whenverdictfor that render returnsHealthy. Otherwise it keeps its value. A failure does not clear it.- When the outcome has a rollout, convoy writes it to
status.rollout. - When the outcome has no rollout and
spec.failurePolicyis set,status.rolloutkeeps its value. This keeps aRolledBackorSucceededstate after a transient source, render or cluster error. - When
spec.failurePolicyis not set, convoy removesstatus.rollout. - The
RolloutSucceededcondition followsstatus.rollout.state:TrueforSucceeded,FalseforFailedandRolledBack,UnknownforProgressing. Without a rollout, convoy does not change the condition. - When a forced sync is pending, convoy writes its time to
status.lastHandledForceSyncAt. This happens also when the reconcile fails, so a forced sync has an effect one time.
The lastHealthy check runs for every Release, also without a failure policy.
Wake-ups#
A Progressing rollout needs a new reconcile to read a new verdict. Three triggers start it:
| Trigger | Source |
|---|---|
| Health change | The controller-manager watches ReleaseInventory updates. When inventory.HealthChanged is true, it adds the Release key to the queue at once. |
| Poll | requeueAfter uses spec.syncPolicy.interval. For a Progressing rollout, it uses at most 10 seconds. |
| Informer resync | The --resync period of the controller-manager. The default is 2 minutes. |
inventory.HealthChanged compares the state and assessedRenderRef of each entry. A change of
the entry count also counts. Without the health trigger, a healthy render could pass the
deadline before the next poll, and convoy could roll it back.
A Failed or RolledBack rollout uses only the normal interval. With an interval of 0, only the
informer resync starts the next reconcile.
In the code
convoy v0.9.0
internal/controller/release.(*Controller).reconcileinternal/controller/release/controller.go:824internal/controller/release.(*Controller).planRolloutinternal/controller/release/controller.go:1511internal/controller/release.(*Controller).verdictinternal/controller/release/controller.go:1424internal/controller/release.(*Controller).waveHealthGateinternal/controller/release/controller.go:1593internal/controller/release.(*Controller).applyLastHealthyinternal/controller/release/controller.go:1644internal/controller/release.(*Controller).syncHandlerinternal/controller/release/controller.go:199internal/controller/release.lastHealthyinternal/controller/release/controller.go:1461internal/controller/release.rollbackEligibleinternal/controller/release/controller.go:1568internal/controller/release.requeueAfterinternal/controller/release/controller.go:325internal/inventory.HealthChangedinternal/inventory/apistore.go:194