Skip to content

fix(primitives): stop reporting stuck workload rollouts as healthy - #220

Merged
sourcehawk merged 6 commits into
mainfrom
fix/rollout-health--stale-rollout
Oct 3, 2026
Merged

sourcehawk merged 6 commits into
mainfrom
fix/rollout-health--stale-rollout

Conversation

@sourcehawk

@sourcehawk sourcehawk commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Description

Fixes #217

A Deployment or StatefulSet whose rollout is stuck, or whose controller has not observed the current spec, could report
Healthy. This PR makes the default converging and grace handlers of both kinds use one rule for health, so a stuck
rollout reports Updating during the grace period and Degraded or Down after it. A StatefulSet with the OnDelete
strategy and a paused Deployment still report Healthy once their replicas are ready, because their controllers do not
roll out new pods on their own.

Changes

  • DefaultConvergingStatusHandler of both kinds reports Healthy only when the controller observed the current
    generation, all desired replicas are ready, and the rollout is complete. A ready but unfinished rollout reports
    Updating (Creating right after create).
  • Deployment rollout check: updatedReplicas must equal the desired count, and replicas must not be more than
    updatedReplicas (no old pods remain, which matters with a surge). A paused Deployment only waits for extra replicas to scale down,
    because the deployment controller scales it but does not roll it out. The generation and readiness checks still apply.
  • StatefulSet: replicas must not be more than the desired count under every update strategy, because the
    controller removes extra pods whatever the strategy is. The rollout check then follows the update strategy:
    • RollingUpdate without a partition: updatedReplicas equals the desired count and currentRevision equals
      updateRevision.
    • RollingUpdate with a partition more than zero: updatedReplicas reaches the desired count minus the partition.
      The revisions are not compared, because the pods below the partition keep the current revision.
    • OnDelete: no rollout check.
  • DefaultGraceStatusHandler of both kinds reports Down when replicas are desired and none are ready, Healthy only
    when the converging handler reports Healthy, and Degraded for all other states, including a stale
    observedGeneration. This removes the "Grace inconsistency detected" log for these cases.
  • New "Status Handlers" sections in docs/primitives/deployment.md and docs/primitives/statefulset.md, synced to the
    plugin references. GoDoc on the builders and resources no longer says that the handlers compare only ReadyReplicas.

Challenges

The StatefulSet controller sets currentRevision to updateRevision only when a rolling update completes, and it
never does so while a partition holds pods back. That is why the revision comparison applies only to RollingUpdate
without a partition. A partition at or above the replica count gives a target of zero updated replicas, so such a
StatefulSet is healthy when its replicas are ready. The partition counts from spec.ordinals.start, as in the
controller, so the target does not depend on the start ordinal.

Related

  • The DaemonSet grace handler has the same stale-generation gap when desiredNumberScheduled > 0, and neither DaemonSet
    handler reads updatedNumberScheduled.
  • The ReplicaSet grace handler does not check observedGeneration. A ReplicaSet has no rollout, so only the generation
    part applies.
  • The MessageQueue grace handler example in docs/custom-resource.md copies the same pattern without a generation
    check.

Testing

TestDefaultHandlers_UnfinishedRollout in both packages calls the converging handler (what the component reports
before the grace period expires) and the grace handler (what it reports after) on the same object. The cases are a
stale generation with all replicas ready, a stale generation with none ready, a rollout stuck with all old replicas
ready, and a just-created object. The StatefulSet cases add a revision mismatch with all replicas updated and a
partitioned rollout that is not done. Every case except the stale generation with none ready failed before the fix. That
case pins the Down result. TestDefaultHandlers_RolloutHeldBack covers
the partition (also with a custom start ordinal) and OnDelete cases that must stay Healthy.
TestDefaultHandlers_PausedRollout covers a paused Deployment: Healthy with all old replicas ready, and not healthy
with a stale generation or missing ready replicas. A case with more updated replicas than desired (a scale-down in progress) reports
Waiting for scale-down in both packages, also for a StatefulSet with a partition or OnDelete and for a paused
Deployment. The existing healthy fixtures now set updatedReplicas
and replicas, because the contract now reads them.

A new e2e spec, "Grace Period — Stuck Rollout", rolls a two-replica Deployment with maxUnavailable: 0 to a pod
template whose readiness probe always fails. It checks that the status shows two ready and one updated replica, that the
condition is Updating before the grace period expires, and that it is Degraded after. make all passes, and
make e2e-primitives passes for PRIMITIVE=deployment and PRIMITIVE=statefulset.

🤖 Generated with Claude Code

The default status handlers of the Deployment and StatefulSet primitives
read only ReadyReplicas. The deployment controller keeps old pods until
new pods are ready, so a rollout whose new pods never become ready kept
ReadyReplicas at the desired count. Convergence then reported Healthy.

The grace handlers also did not check observedGeneration. With a stale
generation, convergence reported Updating and grace reported Healthy.
After the grace period the component logged "Grace inconsistency
detected" and kept the progress reason.

Both handlers of each kind now use the same rule. The controller must
observe the current generation, all desired replicas must be ready, and
the rollout must be complete. For a Deployment, updatedReplicas must
reach the desired count and no old replicas can remain. For a
StatefulSet, the rule follows the update strategy:

- RollingUpdate without a partition: updatedReplicas reaches the desired
  count and currentRevision equals updateRevision.
- RollingUpdate with a partition: updatedReplicas reaches the desired
  count minus the partition. The revisions stay different by design.
- OnDelete: no rollout check, because the controller does not replace
  pods.

The grace handlers report Down when replicas are desired and none are
ready, Healthy only when convergence is Healthy, and Degraded otherwise.

Fixes #217

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 2, 2026 16:19

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

StatefulSet custom ordinals are calculated incorrectly, and the new E2E test contains a data race.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity · 1 Low severity

Open (3)
What changed in this PR

Fixes #217 by preventing stuck Deployment and StatefulSet rollouts from reporting healthy.

Changes:

  • Aligns converging and grace health checks with rollout status.
  • Adds unit and Deployment E2E coverage.
  • Documents and syncs the updated status-handler behavior.
File Description
pkg/​primitives/​deployment/​handlers.go Adds rollout-aware health checks.
pkg/​primitives/​deployment/​handlers_test.go Tests stale and stuck rollouts.
pkg/​primitives/​deployment/​builder.go Updates builder GoDoc.
pkg/​primitives/​deployment/​resource.go Updates resource GoDoc.
pkg/​primitives/​statefulset/​handlers.go Adds strategy-aware rollout checks.
pkg/​primitives/​statefulset/​handlers_test.go Tests rollout strategies and failures.
pkg/​primitives/​statefulset/​builder.go Updates builder GoDoc.
pkg/​primitives/​statefulset/​resource.go Updates resource GoDoc.
e2e/​primitives/​deployment_test.go Adds stuck-rollout E2E coverage.
docs/​primitives/​deployment.md Documents Deployment status handlers.
docs/​primitives/​statefulset.md Documents StatefulSet status handlers.
plugin/​skills/​using-primitives/​references/​primitives/​deployment.md Syncs Deployment documentation.
plugin/​skills/​using-primitives/​references/​primitives/​statefulset.md Syncs StatefulSet documentation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/primitives/statefulset/handlers.go
Comment thread e2e/primitives/deployment_test.go Outdated
Comment thread docs/primitives/statefulset.md Outdated
sourcehawk and others added 2 commits October 2, 2026 21:21
…hange

The deployment controller does not roll out a paused Deployment. After a
pod template change, updatedReplicas stays below the desired count until
the Deployment is resumed, so the new rollout check reported Updating and
then Degraded for a pause that the user chose. The default handlers now
skip the rollout check for a paused Deployment. The generation and
readiness checks still apply.

The stuck-rollout e2e spec now stores its readiness switch in an
atomic.Bool, because the reconcile goroutine reads it while the test
goroutine writes it.

The StatefulSet docs now say that the partition counts from
spec.ordinals.start, as in the statefulset controller, and a test pins
the partition target for a custom start ordinal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some GoDoc sentences for the Deployment and StatefulSet status handlers
were false for some inputs:

- The converging handlers listed which progress status each operation
  gives. A ready count that differs is checked before the rollout, and
  a stale generation gives Updating for any operation, so the list was
  wrong. The GoDoc now only says that the handler reports Creating,
  Updating, or Scaling.
- The grace handlers said Down needs Spec.Replicas above zero. A nil
  Spec.Replicas counts as 1, so the GoDoc now names the desired count.
- The builder and resource GoDoc said that the rollout is complete,
  which is not true for a paused Deployment or an OnDelete StatefulSet.
  They now refer to the exceptions in DefaultConvergingStatusHandler.
- The StatefulSet GoDoc said that an OnDelete rollout is always
  complete. It now says that Healthy does not mean the pods run the
  update revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 2, 2026 20:26
@sourcehawk

Copy link
Copy Markdown
Owner Author

Review round 1. The three Copilot threads have their own replies. The review had no suppressed comments. This comment records one more finding from a local review, which has no thread.

Finding Verdict Change
The rollout check ignored Spec.Paused. After a paused Deployment's template changed, it reported Updating and then Degraded, although the controller never rolls out a paused Deployment. Accepted Fixed in b48faa5. The rollout check now skips a paused Deployment. The generation and readiness checks still apply. TestDefaultHandlers_PausedRollout covers it, and the GoDoc and docs/primitives/deployment.md say so.

cfdf4d0 corrects handler GoDoc sentences that were false for some inputs, for example a nil Spec.Replicas.

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

The new end-to-end test can miss the temporary Updating state and fail on a slow cluster.

Review effort: Balanced
Findings: None

Resolved since last review (3)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Poll for Updating before waiting on replica counts

e2e/​primitives/​deployment_test.go:391

This assertion can fail on a slow cluster even when the handlers work correctly. The preceding replica-status wait allows 120 seconds, but grace expires 30 seconds after the condition becomes unready. If the expected counts arrive after grace expires, reconciliation can already have changed the condition to Degraded; this permanently stuck rollout will not return to Updating. Poll for Updating immediately after triggering the template change, before waiting for the replica counts. Use the normal timeout to allow the initial reconciliation to start.

The stuck rollout spec waited up to two minutes for the stall counts
before it checked for Updating. The grace period is 30 seconds, so on a
slow cluster the condition could already be Degraded at that check, and
a stuck rollout never returns to Updating. The spec now checks Updating
right after the template change, then waits for the stall counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 2, 2026 20:39
@sourcehawk

Copy link
Copy Markdown
Owner Author

Review round 2. Copilot resolved the three round-1 findings. It reported one more finding in the review body, under "Previously missed", so the finding has no thread.

Finding Verdict Change
e2e/primitives/deployment_test.go: the stuck-rollout spec waited up to 120s for the stall counts before it checked for Updating. The grace period is 30s, so on a slow cluster the condition could already be Degraded. Accepted Fixed in 0eb25d4. The spec checks for Updating right after the template change, then waits for the stall counts, then for Degraded after the grace period. The deployment e2e passes 9 of 9.

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Deployment health can still be reported before excess current-revision replicas are removed.

Review effort: Balanced
Findings: None

Previously missed (1)

In code that hasn't changed since last review

Medium severity Deployment health passes before updated replicas reach desired count

pkg/​primitives/​deployment/​handlers.go:84

With 3 desired replicas, ReadyReplicas == 3 and UpdatedReplicas == Replicas == 4, both handlers still return Healthy. This can occur while the latest ReplicaSet is scaling down: no old replicas remain, but the total count has not reached the desired count. Require UpdatedReplicas to equal the desired count, retaining the existing check for old replicas. Add this case to the regression tests and update the Deployment GoDoc and documentation to match.

The rollout check accepted updatedReplicas above the desired count. A
Deployment whose newest ReplicaSet still scales down could report
Healthy with 4 updated replicas and 3 desired, while one of the 4 pods
was not ready. A StatefulSet without a partition had the same gap.

Both handlers now require updatedReplicas to equal the desired count,
and report "Waiting for scale-down" while it is higher. A StatefulSet
with a partition keeps the lower bound only, because all of its pods
count as updated when the revision did not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 2, 2026 20:53
@sourcehawk

Copy link
Copy Markdown
Owner Author

Review round 3. Copilot reported one more finding in the review body, under "Previously missed", so the finding has no thread.

Finding Verdict Change
pkg/primitives/deployment/handlers.go: with 3 desired, 3 ready, and 4 updated and 4 total replicas, both handlers returned Healthy while the new ReplicaSet was still scaling down. Accepted Fixed in 354bb65. UpdatedReplicas must now equal the desired count, and the handlers report Waiting for scale-down: 4/3 replicas. The StatefulSet had the same gap for RollingUpdate without a partition, and the same commit fixes it there. Each kind has a new test case, and the GoDoc and docs say "equals".

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

StatefulSet strategy exceptions can still report Healthy before scale-down completes.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Comment thread pkg/primitives/statefulset/handlers.go Outdated
The StatefulSet handlers returned early for OnDelete and for a
partitioned RollingUpdate, so a StatefulSet with more replicas than
desired reported Healthy under those strategies. The controller removes
extra pods whatever the update strategy is, so both handlers now check
status.replicas against the desired count before the strategy rules and
report "Waiting for scale-down" while it is higher.

A paused Deployment had the same gap: the paused early return skipped
the scale-down check. The deployment controller still scales a paused
Deployment, so the paused branch now waits for status.replicas to reach
the desired count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 2, 2026 21:08

Copilot AI 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.

Copilot review overview

🟢 Approval recommended

The handlers match the documented behavior, have focused regression and end-to-end coverage, and have no unresolved blocking findings.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@sourcehawk
sourcehawk merged commit ff42b5b into main Oct 3, 2026
8 checks passed
@sourcehawk
sourcehawk deleted the fix/rollout-health--stale-rollout branch October 3, 2026 08:21
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.

Deployment and StatefulSet handlers report a stuck rollout as healthy

2 participants