fix(maintenance): a job given up after draining its node makes the node active again (#6387)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m48s
CI / publish (push) Successful in 20s

On 2026-10-25 the 09:00 job on VDNT-Docker02 drained the node, which ran
Traefik and Steward. The agent's result reaches Steward through Traefik,
and Traefik took 11.5 minutes to start on Docker03 (its image had to be
pulled). The job gave up at the 10-minute drain deadline and finished
timed_out where it stood, so the node stayed drained for hours.

- A job that fails or times out while its node may be drained (the drain
  was handed out, or went through and wasn't undone) goes to undraining
  first. The outcome is kept in the new column ends_as, and the job
  finishes as that once the node is active again. If the undrain goes
  wrong too, detail says both.
- The drain deadline is 30 minutes instead of 10, so a result held up while
  the proxy moves still lands.
- Migration host_agent_012_ends_as. get_maintenance_job returns ends_as.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 09:05:57 -04:00
co-authored by Claude Opus 5.5
parent 2a561210c3
commit d102fc3333
7 changed files with 157 additions and 9 deletions
+45 -5
View File
@@ -29,6 +29,11 @@ failed / timed_out. A node is drained at most once a job: before the update
when Docker is upgraded, else before the reboot. settling and recovering are
Steward's own waits, moved on by the minute tick. An agent older than
CHECK_AGENT gets no check: its job starts at updating.
A job that fails or is given up while its node may be drained makes the node
active again first: it goes to undraining with the outcome kept in
`ends_as`, and finishes as that once the undrain is through. A job never ends
leaving behind a drain it made.
"""
from __future__ import annotations
@@ -49,7 +54,10 @@ STEP_ACTION = {"checking": "check", "updating": "update", "draining": "drain",
DEADLINES = {
"queued": timedelta(hours=1),
"checking": timedelta(minutes=30), # two listings, the helper stops each at 10
"draining": timedelta(minutes=10),
# The drain's result comes back the way the drain can take down: draining
# the node that runs the proxy (or Steward) moves them, and the result
# waits until they run elsewhere, image pull included.
"draining": timedelta(minutes=30),
"settling": timedelta(minutes=15), # the tick moves it on at DRAIN_SETTLE
"updating": timedelta(minutes=90), # the helper itself stops at 60
"rebooting": timedelta(minutes=30),
@@ -94,6 +102,25 @@ def _finish(job, state: str, now: datetime, detail: str | None = None) -> None:
_enter(job, state, now)
def may_be_drained(job) -> bool:
"""Whether the job may have left its node drained: its drain was handed
out (a result may just not have come back), or went through and the node
hasn't been made active again."""
return drains(job) and (job.state == "draining" or
(job.drained_at is not None and job.state in ("settling", "updating", "rebooting")))
def _give_up(job, state: str, now: datetime, detail: str) -> None:
"""End the job as `state` (failed or timed_out), making its node active
again first when the job may have drained it."""
if may_be_drained(job):
job.detail = detail[:DETAIL_MAX]
job.ends_as = state
_enter(job, "undraining", now)
else:
_finish(job, state, now, detail)
def can_check(agent_version) -> bool:
"""Whether an agent this version hands the helper a check."""
try:
@@ -209,8 +236,9 @@ def apply_result(job, event: dict, now: datetime) -> bool:
what = STEP_ACTION[job.state]
detail = f"{what} failed: {event.get('detail') or 'no detail'}"
if job.state == "undraining":
detail += " (the node is still drained)"
_finish(job, "failed", now, detail)
_finish(job, "failed", now, _after_given_up(job, detail + " (the node is still drained)"))
else:
_give_up(job, "failed", now, detail)
return True
if job.state == "checking":
_after_check(job, event, now)
@@ -223,7 +251,10 @@ def apply_result(job, event: dict, now: datetime) -> bool:
job.drained_at = now
_enter(job, "settling", now)
elif job.state == "undraining":
_recover(job, now)
if job.ends_as: # given up: the node is active again, the job ends as it was
_enter(job, job.ends_as, now)
else:
_recover(job, now)
else: # rebooting: the boot completes it, not this
return False
return True
@@ -284,10 +315,19 @@ def check_deadline(job, now: datetime) -> bool:
"rebooting": "the host didn't come back",
"undraining": "the node wasn't made active again",
"recovering": "its containers weren't seen again"}[job.state]
_finish(job, "timed_out", now, f"{waiting} within {int(limit.total_seconds() // 60)} min")
detail = f"{waiting} within {int(limit.total_seconds() // 60)} min"
if job.state == "undraining":
_finish(job, job.ends_as or "timed_out", now, _after_given_up(job, detail))
else:
_give_up(job, "timed_out", now, detail)
return True
def _after_given_up(job, detail: str) -> str:
"""What an undrain going wrong adds to why a given-up job ended."""
return f"{job.detail}; then {detail}" if job.ends_as and job.detail else detail
def window_end(job, now: datetime) -> datetime:
"""When the job's maintenance window should end: the longest the rest of
the job could take, or now once it has finished."""
+4 -1
View File
@@ -119,6 +119,7 @@ def _job_row(job, names: dict, now) -> dict:
"drained_by": names.get(job.drain_host_id, job.drain_host_id),
"drained_at": iso(job.drained_at),
"came_back_at": iso(job.came_back_at),
"ends_as": job.ends_as,
"docker_update": job.docker_update,
"checked": job.checked,
"detail": job.detail,
@@ -203,7 +204,9 @@ async def get_maintenance_job(host: str = "", job: str = "", limit: int = 10) ->
Docker, else before a reboot; `drained_at` is when, and its tasks get 5
min to move (settling). `state` moves queued → [checking →] [draining →
settling →] updating → [draining → settling →] [rebooting →]
[undraining →] [recovering →] done, or failed / timed_out. recovering
[undraining →] [recovering →] done, or failed / timed_out; a job given
up while its node may be drained goes through undraining first, and
`ends_as` is what it then finishes as. recovering
waits up to 10 min for those containers to run again; `detail` says why
a job failed, naming any that didn't. A job failed only for not coming
back into service in time has `came_back_at`, when its host was first
@@ -0,0 +1,22 @@
# plugins/host_agent/migrations/versions/host_agent_012_ends_as.py
"""host_agent: what a job given up while its node may be drained finishes as,
once the node is active again
Revision ID: host_agent_012_ends_as
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_012_ends_as"
down_revision: Union[str, None] = "host_agent_011_came_back"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("host_maintenance_jobs", sa.Column("ends_as", sa.String(16), nullable=True))
def downgrade() -> None:
op.drop_column("host_maintenance_jobs", "ends_as")
+3
View File
@@ -124,6 +124,9 @@ class HostMaintenanceJob(Base):
# A job failed for not coming back into service in time: when its host
# was first found back (#6175). From then on the job no longer flags it.
came_back_at = Column(DateTime(timezone=True), nullable=True)
# A job given up (failed or timed_out) while its node may be drained makes
# the node active again first; ends_as is what it finishes as after that.
ends_as = Column(String(16), nullable=True)
class HostMaintenancePlan(Base):