Skip to content

Fix/graceful shutdown (port of #1303 to main) - #1305

Open
wmousa wants to merge 2 commits into
mainfrom
fix/graceful-shutdown-main
Open

Fix/graceful shutdown (port of #1303 to main)#1305
wmousa wants to merge 2 commits into
mainfrom
fix/graceful-shutdown-main

Conversation

@wmousa

@wmousa wmousa commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Port of #1303 (merged to R26.3 as ab1b9d351) onto main.

Both commits cherry-picked cleanly; the applied content is byte-identical to what merged into R26.3 (verified by diffing the two patches).

1. graceful-shutdown must not resurrect removed nodes

cluster_grace_shutdown iterated every node record with no status filter and force-shut-down each one. shutdown_storage_node drives in_shutdown -> offline, so a node that had been REMOVED came back as a plain offline member.

Live 2026-09-03: a single graceful-shutdown resurrected all four nodes removed earlier that day, and the records had to be repaired by hand before the cluster could be activated again. Not cosmetic — failure_domain_host_map skips only STATUS_REMOVED, so the resurrected records immediately reoccupy FD host slots (the cluster went from 8 hosts at 2/2/2/2 to 12 at 3/3/3/3), and a later activation acts on nodes whose devices are already failed_and_migrated.

REMOVED and IN_REMOVAL are now skipped. PENDING_REMOVAL deliberately is not: the node is still up and serving, so a full-cluster shutdown must stop it like any other member.

2. graceful-shutdown must not lose the race with queued restarts

auto_restart_disabled is enforced at enqueue time only; tasks_runner_restart never consults it. A restart row queued before the flag was set survives, executes, and its ONLINE transition clears the flag — the deliberate-stop intent destroyed by the task it was meant to prevent. Live: the command returned successfully having left two nodes ONLINE.

shutdown_storage_node now reaps those rows where it commits the intent, mirroring what set_node_status already does on the ONLINE transition. That also gives the runner a dequeue-side check for free (it already honours task.canceled), with no new model field.

cancel_pending_node_restart_tasks gains exclude_task_id, and it is load-bearing, not defensive: the restart runner drives shutdown_storage_node as its own kill step (tasks_runner_restart.py:542, passing current_restart_task_id), so a blanket cancel would abort the very restart doing the shutting down — turning a fix for a rare race into a failure on every node restart. The suspend-recovery path is unaffected (keep_auto_restart=True, never reaches the block).

cluster_grace_shutdown additionally verifies its own end state: stragglers get one more shutdown, anything still not offline is logged by name. It does not raise — an operator following up needs the list, not a traceback.

Verification on this branch

  • lint clean; mypy clean (233 source files)
  • unit: 2193 passed / 7 skipped / 144 subtests
  • 2 subtest failures in test_service_entrypoints.py (tasks-runner-lvol-migration, tasks-runner-restart) are pre-existing on clean origin/main — reproduced identically on an untouched worktree, unrelated to these commits
  • Mutation-verified: dropping the exclusion fails the own-task test; disabling the settle pass fails the comes-back test

🤖 Generated with Claude Code

cluster_grace_shutdown iterated every node record with no status filter at
all and force-shut-down each one. shutdown_storage_node drives
in_shutdown -> offline, so a node that had been REMOVED came back as a plain
offline member. Live 2026-09-03: a single graceful-shutdown resurrected all
four nodes removed earlier that day, and the records had to be repaired by
hand before the cluster could be activated again.

That is not cosmetic. failure_domain_host_map skips only STATUS_REMOVED, so
the resurrected records immediately start occupying failure-domain host slots
again -- the cluster went from 8 hosts at 2/2/2/2 to 12 at 3/3/3/3 -- and a
later activation or startup then acts on nodes whose devices are already
failed_and_migrated and which own no lvstore.

REMOVED and IN_REMOVAL are now skipped; IN_REMOVAL because
node_removal_orchestrate has already shut that node down and owns the rest of
its lifecycle. PENDING_REMOVAL deliberately is not skipped: the node is still
up and serving then, so a full-cluster shutdown must stop it like any other
member.

The test asserts on which nodes were swept rather than on a return value --
the function returns None, so behaviour is only observable through the calls
it makes. Verified by mutation: emptying the status tuple fails two of the
three cases.
…starts

auto_restart_disabled is enforced at ENQUEUE time only
(tasks_controller.add_node_to_auto_restart, "the single chokepoint for every
auto-restart queue path"); tasks_runner_restart never consults it. So an
FN_NODE_RESTART row queued BEFORE the flag was set survives, executes
unconditionally, and its ONLINE transition clears the flag again -- the
deliberate-stop intent destroyed by the very task it was meant to prevent.

Live 2026-09-03: a cluster graceful-shutdown returned successfully having
left s7457 and zdgtb ONLINE, because two such rows fired seconds after the
sweep passed them. Both had to be shut down by hand.

shutdown_storage_node now reaps those rows where it commits the intent,
mirroring what set_node_status already does on the opposite transition
(ONLINE cancels obsolete restart rows). This also gives the runner its
dequeue-side check for free -- it already honours task.canceled -- with no
new field, and without breaking ensure_node_restart_task, which deliberately
bypasses the flag because an explicit `sn restart` is the operator
intervention the flag waits for. A restart queued after that point is exactly
that and is left alone.

cancel_pending_node_restart_tasks gains exclude_task_id, and that exclusion
is load-bearing rather than defensive: the restart runner drives
shutdown_storage_node as its own kill step (tasks_runner_restart.py:542,
passing current_restart_task_id), so a blanket cancel would abort the very
restart doing the shutting down -- turning a fix for a rare race into a
failure on every node restart. The suspend-recovery path is unaffected; it
passes keep_auto_restart=True and never reaches this block.

cluster_grace_shutdown additionally verifies its own end state instead of
assuming it. The sweep is serial, so a node it already passed can come back
behind it; stragglers get one more shutdown, and anything still not offline
is logged by name. It does not raise -- an operator following up needs the
list, not a traceback.

Mutation-verified: dropping the exclusion fails the own-task test, disabling
the settle pass fails the comes-back test.
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.

1 participant