fix: clear upgrade_started_at when agent upgrades faster than one checkin interval - #7784
fix: clear upgrade_started_at when agent upgrades faster than one checkin interval#7784ycombinator wants to merge 11 commits into
Conversation
|
This pull request does not have a backport label. Could you fix it @ycombinator? 🙏
|
There was a problem hiding this comment.
🟡 Changes recommended
The newly added unit test matcher can pass even if upgraded_at is missing, reducing the reliability of the regression test.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes a fast-upgrade race where an Elastic Agent can finish upgrading within a single check-in interval, leaving upgrade_started_at stuck on the agent document and the agent permanently shown as “updating” in Fleet UI.
Changes:
- Adjusts
markUpgradeCompleteto treatupgrade_started_atwithoutupgrade_detailsas a completed upgrade and clear upgrade fields accordingly. - Adds a unit test covering the “fast upgrade race” scenario.
- Adds a changelog fragment describing the bug and fix.
File summaries
| File | Description |
|---|---|
| internal/pkg/api/handleCheckin.go | Extends the no-op guard so upgrade_started_at is cleared even when no upgrade_details were ever persisted. |
| internal/pkg/api/handleCheckin_test.go | Adds a test case covering the fast-upgrade scenario (with one matcher issue noted in review). |
| changelog/fragments/1788912764-fix-upgrade-started-at-stuck-fast-upgrade.yaml | Documents the bug and the behavior change in a release fragment. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
🟡 Changes recommended
The updated guard can incorrectly mark upgrades complete on the first check-in after bulk_upgrade (before the agent receives the upgrade action), potentially clearing upgrade_started_at prematurely.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
The new NOP guard can prevent self-healing of already-stuck agents (including during mixed-version rolling upgrades), leaving upgrade_started_at stuck indefinitely in some deployments.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
5f56045 to
cbc530d
Compare
…ckin interval When an agent upgrades faster than one checkin interval (~60s), Fleet Server never observes any intermediate upgrade_details states. markUpgradeComplete was a NOP when upgrade_details was nil on the agent doc, leaving upgrade_started_at set indefinitely and the agent permanently "updating". Extend the NOP guard to also require upgrade_started_at to be unset. When upgrade_started_at is set but upgrade_details is nil, treat the next checkin with nil upgrade_details as a successful completion. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Use a two-value map lookup + type assertion to check that upgraded_at is present and is a non-empty string, rather than relying on != "" which also passes when the key is absent (absent map key returns nil, and nil != "" is true). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…n after dispatch The previous fix (clear upgrade_started_at when upgrade_details is nil but upgrade_started_at is set) would incorrectly mark an upgrade complete on the first checkin after bulk_upgrade, before the agent has even received the upgrade action. Distinguish the two cases using the ver parameter (non-empty only when the agent's reported version differs from its stored version): - Fast upgrade race: ver != "" (agent restarted at new version) → clear - First checkin after dispatch: ver == "" (same version) → NOP Also adds a test case asserting no Update is called when the agent checks in at the same version immediately after bulk_upgrade. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…dated in doc If a fast-upgrade checkin hits an older fleet-server instance (rolling upgrade), that instance updates the agent version in the doc but does not clear upgrade_started_at. On subsequent checkins to the new fleet-server, ver == "" (same version), so the NOP guard would apply indefinitely. Add a staleness fallback: if upgrade_started_at is older than upgradeStartedAtStalenessThreshold (10m), treat it as stale and clear it even when ver == "". The threshold is set well above the maximum checkin poll duration (~5m) to avoid prematurely clearing upgrade_started_at on the first checkin after bulk_upgrade. Also update the function-level comment on processUpgradeDetails to accurately describe the new markUpgradeComplete semantics, and fix the "first checkin after dispatch" test case to use a fresh timestamp so it correctly stays below the staleness threshold. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
ab6bc42 to
1c5fb80
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The new staleness/self-heal logic can incorrectly mark upgrades complete (including setting upgraded_at) without evidence of a successful upgrade, and the configured staleness threshold/comment mismatch needs correction.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
internal/pkg/api/handleCheckin.go:804
- markUpgradeComplete sets upgraded_at even when there’s no evidence the upgrade actually completed (no stored upgrade_details and ver == ""). In the staleness branch this can incorrectly mark an upgrade as successful for agents that simply haven’t received the upgrade action yet (e.g., offline/delayed), producing a false upgraded_at timestamp.
doc := bulk.UpdateFields{
dl.FieldUpgradeDetails: nil,
dl.FieldUpgradeStartedAt: nil,
dl.FieldUpgradedAt: time.Now().UTC().Format(time.RFC3339),
}
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
… check Kibana may store upgrade_started_at with fractional seconds (e.g. "2024-01-01T00:00:00.000Z"), which time.RFC3339 cannot parse. A parse failure caused an early return, silently defeating the stale self-heal path. Try RFC3339Nano as a fallback; keep the NOP if both parses fail. Adds a test case covering a fractional-second upgrade_started_at that is beyond the staleness threshold. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
🟡 Changes recommended
The new staleness threshold logic can become too small (or zero) with certain CheckinMaxPoll configurations, potentially clearing upgrade_started_at prematurely.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/pkg/api/handleCheckin.go:651
- The comment references
upgradeStartedAtStalenessThreshold, but there is no such identifier in this file; the implementation usesstalenessThreshold(). This makes the doc misleading for future maintainers.
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
The new staleness guard currently treats an unparseable upgrade_started_at as a NOP (preventing self-heal) and the PR description doesn’t reflect the added staleness-clearing behavior.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
internal/pkg/api/handleCheckin.go:652
- The comment mentions bypassing the guard when upgrade_started_at is older than "upgradeStartedAtStalenessThreshold", but that identifier doesn't exist here (the logic uses ct.stalenessThreshold()). Update the comment to match the actual implementation to avoid confusion.
This issue also appears in the following locations of the same file:
- line 775
- line 781
internal/pkg/api/handleCheckin.go:787
- In the staleness guard, a parse failure of upgrade_started_at currently causes an early return, which can leave agents permanently stuck in "updating" if the timestamp is malformed (no self-heal path). Treat an unparseable value as stale so upgrade_started_at is cleared (without setting upgraded_at).
t, err := time.Parse(time.RFC3339, agent.UpgradeStartedAt)
if err != nil {
t, err = time.Parse(time.RFC3339Nano, agent.UpgradeStartedAt)
}
if err != nil || time.Since(t) < ct.stalenessThreshold() {
return nil
}
internal/pkg/api/handleCheckin.go:780
- The PR description says the fix is extending the NOP guard, but the implementation also adds a new "stale upgrade_started_at" self-heal path (clearing after 2× CheckinMaxPoll). If this behavior is intended, it should be called out explicitly in the PR description and release notes; otherwise consider removing it to keep the change narrowly scoped.
// Exception: if upgrade_started_at is clearly stale (older than 2× CheckinMaxPoll), clear it
// to self-heal agents stuck in the updating state after a rolling fleet-server upgrade where
// the version was already updated by an older instance. The threshold uses 2× CheckinMaxPoll
// so a legitimately long-running poll (up to CheckinMaxPoll) cannot be mistaken for staleness.
// In the stale case upgraded_at is NOT set because the upgrade outcome is unknown.
if agent.UpgradeDetails == nil && agent.UpgradeStartedAt != "" && ver == "" {
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
This comment has been minimized.
This comment has been minimized.
The long-poll logic (line 415) treats CheckinMaxPoll < 1m as effectively 1m. stalenessThreshold must apply the same floor so that a near-zero CheckinMaxPoll does not produce a threshold of ~0 and prematurely clear upgrade_started_at on the first checkin after dispatch. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
🟡 Changes recommended
The new staleness self-heal path currently becomes a no-op on upgrade_started_at parse errors, which can still leave agents stuck in “updating” indefinitely.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
internal/pkg/api/handleCheckin.go:652
- The comment references
upgradeStartedAtStalenessThreshold, but no such identifier exists in this file; it looks like the intended reference is thestalenessThreshold()logic below. This can confuse future readers when debugging upgrade state transitions.
internal/pkg/api/handleCheckin.go:787
- In the staleness self-heal branch, an unparseable
upgrade_started_atcurrently triggers an early return, which can leave the agent stuck in an "updating" state indefinitely. Since this branch is explicitly intended to self-heal stuck agents, consider treating parse failures as stale and clearing the field.
t, err := time.Parse(time.RFC3339, agent.UpgradeStartedAt)
if err != nil {
t, err = time.Parse(time.RFC3339Nano, agent.UpgradeStartedAt)
}
if err != nil || time.Since(t) < ct.stalenessThreshold() {
return nil
}
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
Document both fixes included in the PR: 1. Fast-upgrade race: version change triggers upgrade completion. 2. Stale self-heal: upgrade_started_at older than 2×CheckinMaxPoll is cleared unconditionally to unblock the Fleet UI in mixed fleet-server deployments; upgraded_at is not set since the outcome is unknown. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
🟢 Approval recommended
The behavior change is well-scoped, covered by new unit tests, and only a minor doc-comment wording nit was found.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/pkg/api/handleCheckin.go:652
- The comment mentions
upgradeStartedAtStalenessThreshold, but that identifier doesn’t exist; the staleness guard is implemented viastalenessThreshold(). Updating the wording avoids confusion for future readers.
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
This comment has been minimized.
This comment has been minimized.
TL;DR
Remediation
Investigation detailsRoot CauseI verified one confirmed flaky-test pattern and one inconclusive test assertion due limited logs:
PR scope check: this PR changes only Evidence
Verification
Follow-upIf you can provide the full failing What is this? | From workflow: PR Buildkite Detective Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not. |
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
🟢 Approval recommended
The functional change is small, well-targeted, and includes unit test coverage for the newly handled upgrade edge cases.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/pkg/api/handleCheckin.go:651
- The comment references
upgradeStartedAtStalenessThreshold, but the implementation uses thestalenessThreshold()helper. Using a non-existent identifier in comments is misleading when searching/grepping for the actual logic.
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
Replace hardcoded time.Hour and time.Minute in stalenessThreshold with config.DefaultCheckinMaxPoll and config.CheckinMaxPollFloor, and use DefaultCheckinMaxPoll in InitDefaults as well. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
🟡 Changes recommended
There are documentation/scope discrepancies in the updated comments/PR description that should be corrected to accurately reflect the newly introduced behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/pkg/api/handleCheckin.go:651
- The doc comment refers to
upgradeStartedAtStalenessThreshold, but there is no such identifier in this file (the helper isstalenessThreshold). This makes the behavior harder to follow and suggests a stale rename in the comment.
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
| // Exception: if upgrade_started_at is clearly stale (older than 2× CheckinMaxPoll), clear it | ||
| // to self-heal agents stuck in the updating state after a rolling fleet-server upgrade where | ||
| // the version was already updated by an older instance. The threshold uses 2× CheckinMaxPoll | ||
| // so a legitimately long-running poll (up to CheckinMaxPoll) cannot be mistaken for staleness. | ||
| // In the stale case upgraded_at is NOT set because the upgrade outcome is unknown. |
…ession Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
🟢 Approval recommended
The change is narrowly scoped, includes unit test coverage for the new edge cases, and no blocking correctness issues were found.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/pkg/api/handleCheckin.go:652
- This doc comment mentions
upgradeStartedAtStalenessThreshold, but the code usesstalenessThreshold(); referencing a non-existent identifier makes the behavior harder to follow when reading the function docs.
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Lite
What is the problem?
When an Elastic Agent upgrades faster than one checkin interval (~60 seconds), Fleet Server never observes any intermediate
upgrade_detailsstates (downloading, extracting, watching). The agent completes its upgrade and restarts at the new version within a single checkin gap. In practice, this is unlikely to happen with an actual Elastic Agent. But it is possible with Horde drones and, in theory, it could happen with Elastic Agents too.In this case:
upgrade_started_atis set on the agent document (written by Kibana'sbulk_upgradeAPI) when the upgrade action is dispatched;upgrade_detailsisnullon the agent document;upgrade_details: nullin the checkin body.processUpgradeDetailscallsmarkUpgradeCompletewhen the checkin body hasupgrade_details: null. However,markUpgradeCompletehad an early-return guard:Since
agent.UpgradeDetailsis alsonil(no intermediate states were ever stored), this was a no-op.upgrade_started_atwas never cleared, leaving the agent permanently in the "updating" state in Fleet UI — even though it had successfully upgraded to the target version.This was observed in a 30,000-agent scale test (build #6981) where one agent (
eh-ThoseChillwave-LHTb) remained stuck in "updating" state, blocking Step10 from converging toonline == 30,000.How does this PR solve the problem?
The NOP guard is extended to also require
upgrade_started_atto be empty:If
upgrade_started_atis set butupgrade_detailsis nil, an upgrade was dispatched (by Kibana) but no intermediate states were ever recorded by Fleet Server. When the agent then checks in withupgrade_details: null, Fleet Server now correctly clearsupgrade_started_at, setsupgraded_at, and marks the upgrade complete — regardless of whether any intermediateupgrade_detailsstates were observed.How to test locally
POST /api/fleet/agents/bulk_upgrade. This setsupgrade_started_aton the agent document.upgrade_details, manually setupgrade_details: nulland the new version on the agent document (simulating a fast upgrade).upgrade_details: null(the default for a running, non-upgrading agent).upgrade_started_atand setsupgraded_aton the agent document.Alternatively, run the unit tests:
The new test case
"agent has upgrade_started_at but no upgrade_details, checkin details are nil (fast upgrade race)"covers this scenario.Design Checklist
Checklist
./changelog/fragmentsusing the changelog tool