Health loop
How the HealthController of the observer assesses the ReleaseInventory, writes health, polls hooks and wakes the controller.
The health loop is the second controller in convoy-observer. It runs next to the drift loop in
the same process. For each Release, it reads the ReleaseInventory, reads each recorded object
from the target cluster and evaluates the health rules. It writes the result to the
ReleaseInventory and to the Healthy condition. It also polls the workloads of lifecycle
hooks. It never renders. For the rules and the states, see
Health assessment.
Queue and wakeups#
The loop is a HealthController on the shared harness in internal/controller/base. The queue
key is the namespace/name of the Release. These events add a key:
| Event | Source |
|---|---|
| A Release is added, has a spec change, or comes from the periodic resync | Release informer |
A ReleaseInventory is added | ReleaseInventory handler in observermanager |
The membership of the inventory changes (MembershipChanged) | ReleaseInventory handler |
The controller arms a hook again (HooksArmChanged) | ReleaseInventory handler |
The controller applied a different render to an entry (RenderChanged) | ReleaseInventory handler |
| The loop schedules its next pass | scheduleHealthRecheck |
| This replica gets new shards | sharding coordinator |
The three ReleaseInventory checks compare only fields that the controller-manager owns. So the
health writes of the loop do not wake the loop again. A /sync annotation and a status write
also do not wake it.
One pass#
(*HealthController).syncHandler does these steps in this order:
- It reads the Release from the cache. If the Release does not exist, it returns. The controller-manager deletes the
ReleaseInventory. - Shard gate. If this replica does not own the shard of the target
Cluster, it returns. - Deletion. If the Release has a deletion timestamp, it only polls hooks. The section “Deletion” below shows the steps.
- Hooks.
pollHookspolls the hook workloads. A polling error is logged, not returned. - Health. Without
spec.healthAssessment, it writesHealthy=Truewith reasonNotConfiguredand an empty summary. Else it callsassessand writes the result. - Wake the controller. On an edge, it sends
POST /sync. - Next pass.
scheduleHealthRecheckrequeues the key.
Assessment#
assess works on the inventory, not on the render. So an object gets an assessment as soon as
the controller records it after the apply of its wave.
collectRulescollects the rules. It lists theGlobalHealthRuleSetobjects that matchglobalRuleSetSelector. Then it lists theHealthRuleSetobjects in the namespace of the Release that matchlocalRuleSetSelector. A missing selector matches nothing.health.NewEvaluatorcompiles all rules. A compile error stops the assessment of the full Release.- It reads the
ReleaseInventoryfrom the cache. No inventory, or an empty one, gives an empty result. - It reads the target
Cluster. A missing or unreachable cluster stops the assessment. priorAndAnchorsreads the previousstateand thefirstUnhealthytime of each entry. It reads them before this pass writes.assessItemevaluates each entry.- It sets
renderAdvancedwhen an entry has arenderRefthat is different from itsassessedRenderRef. writeInventoryHealthwrites the result of each entry.
If assess returns an error, the pass writes Healthy=Unknown with reason Unknown. It keeps
the previous healthSummary.
Per-entry evaluation#
assessItem gives one state for each inventory entry:
| Case | State | Live read |
|---|---|---|
| The REST mapper does not know the kind | Unknown | No |
| No rule applies to the resource | Healthy | No |
| The object does not exist on the cluster | Unknown | Yes |
| The read fails | Unknown | Yes |
| The rules evaluate | from health.DeriveState | Yes |
health.DeriveState uses the result of the rules and the firstUnhealthy anchor:
- An evaluation error gives
Unknownand keeps the anchor. - A pass gives
Healthyand clears the anchor. - A failure sets the anchor to now if it is not set. Without
healthAssessment.deadline, the state isProgressing. When the time since the anchor is equal to or more than the deadline, the state isDegraded. Else the state isProgressing.
Anchors#
The loop stores firstUnhealthy in the ReleaseInventory. So an observer restart does not reset
the clock of a failing object. The loop does not reset the anchor when the render changes.
The anchor controls only the difference between Progressing and Degraded. The rollout
deadline does not use it. The controller measures the rollout deadline from
status.rollout.startedAt. See Rollout state machine.
Writes#
The loop writes two objects with Server-Side Apply:
writeInventoryHealthsends each entry with its keys andstate,message,firstUnhealthyandassessedRenderRef. The field manager isconvoy-health-inventory.assessedRenderRefis therenderRefof the entry, which is the render that the controller applied for this object. An empty set writes nothing.writeHealthsends theHealthycondition andhealthSummary. The field manager isconvoy-health. It skips the write when nothing changed.
The condition depends on renderCurrent. This is true when every entry has the renderRef of
status.lastAttempted.
renderCurrent | Summary | Healthy | Reason |
|---|---|---|---|
| false | any | Unknown | Pending |
| true | all objects healthy | True | Healthy |
| true | one or more Degraded | False | Degraded |
| true | one or more Unknown, none Degraded | Unknown | Unknown |
| true | other | False | Progressing |
The /health subresource does not run an assessment. HealthResult builds the response from
the entries of the ReleaseInventory. See Status ownership.
Hook polling#
pollHooks reads the hooks list of the ReleaseInventory from the cache. It polls an entry
when the entry has a workloadName and one of these is true:
- The entry has no
finishedAt. createdAtis afterfinishedAt. This means that the controller armed the hook again.
For each polled entry, pollHookWorkload reads the Job or Pod from the target cluster:
| Case | Result |
|---|---|
| The REST mapper does not know the kind | Failed |
| The workload does not exist, and the time since its creation is more than the timeout | TimedOut |
| The workload does not exist, and the timeout has not passed | no change |
| The read fails | Running |
| The workload exists | From the Job conditions or the Pod phase. TimedOut after the timeout. |
The timeout counts from createdAt. writeHookStatus writes state, message and
finishedAt under the field manager convoy-hook-status. It sends every entry that has a state,
not only the polled entries. The controller reads these fields to advance the hook phase. See
Lifecycle hooks.
Wake the controller#
The loop sends POST releases/<name>/sync when one of these edges occurs in this pass:
- Wave complete.
frontierWaveCompletedis true when all entries of the highest wave in the inventory areHealthy, and one or more of them were notHealthybefore this pass. - Render advanced. The assessment reached a render that the controller applied after the previous pass.
- Hook batch complete. One or more hooks reached a terminal state.
These checks are edge triggers. A steady pass does not send a request. The /sync request sets
the annotation convoy.kumodo.io/sync-requested-at, which wakes the controller. If the request
fails, the pass returns an error and the work queue retries the key.
The controller-manager also watches the ReleaseInventory. A change of state or
assessedRenderRef wakes the controller without a /sync. See
Reconcile flow.
Re-check backoff#
scheduleHealthRecheck chooses the delay of the next pass from a list of pending targets:
- Each object that is not
Healthy. - Each hook in the state
Running. - The target
render-pendingwhen the condition isPending.
The loop keeps an attempt count for each target. The count is in memory and is lost at a
restart. Each pass increments the count of each pending target and clears the other targets.
progressBackoff converts a count into a delay:
| Attempts | Delay |
|---|---|
| 1 to 5 | 2 s |
| 6 to 9 | 4 s |
| 10 to 12 | 8 s |
| 13 to 14 | 16 s |
| 15 and more | 32 s |
The next pass uses the shortest delay of all pending targets. So a new pending target brings the
delay back to 2 seconds. syncPolicy.interval is the maximum delay. Without an interval, the
maximum is 32 seconds. The loop adds a random jitter of up to 20%.
When no target is pending, the loop clears the counts. It then requeues after
syncPolicy.interval. Without an interval, only events and the informer resync start the next
pass.
Deletion#
When the Release has a deletion timestamp, a pass does these steps:
pollHookspolls the hook workloads, for example thepreDeleteandpostDeletehooks.- If a hook finished, the loop sends
POST /sync. The controller then continues the finalization. scheduleHealthRecheckuses only the running hooks as pending targets.
The loop does not assess the objects and does not write Healthy. When all hooks have a
finishedAt, pollHooks writes nothing. So the loop stops writing to the ReleaseInventory
before the controller deletes it. See Deletion.
In the code
convoy v0.9.0
internal/observer.(*HealthController).syncHandlerinternal/observer/health.go:115internal/observer.(*HealthController).assessinternal/observer/health.go:318internal/observer.(*HealthController).assessIteminternal/observer/health.go:415internal/observer.priorAndAnchorsinternal/observer/inventoryhealth.go:42internal/health.DeriveStateinternal/health/state.go:37internal/observer.frontierWaveCompletedinternal/observer/health.go:287internal/observer.(*HealthController).pollHooksinternal/observer/hooks.go:82internal/observer.(*HealthController).scheduleHealthRecheckinternal/observer/health.go:533internal/observer.progressBackoffinternal/observer/health.go:552