Skip to content

Rework task runners - #1226

Draft
mxsrc wants to merge 19 commits into
mainfrom
rework-task-runners
Draft

Rework task runners#1226
mxsrc wants to merge 19 commits into
mainfrom
rework-task-runners

Conversation

@mxsrc

@mxsrc mxsrc commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Rework all 13 tasks_runner_* services onto a single shared driver (task_runner_base.py), replacing each hand-rolled while True-loop with a RunnerSpec whose handler does only domain work and signals outcomes via TaskDefer/TaskRetry/TaskAbort/TaskProgress instead of touching task state directly. This standardizes DB-error-to-exit, max_retry semantics, task-lease application, and retry/backoff across runners that had drifted apart via copy-paste and independent incident fixes, and generalizes the concurrency + per-key exclusion model node_add/restart already had to every runner.

All task-row writes now go through compare-and-set (db.atomic_update) rather than full-object write_to_db(), closing the lost-update class of bug behind the 2026-07-29 double-restart incident. tasks_runner_backup_merge.py is reclassified as backup_merge_service.py (a plain periodic service, not task-based); lvol_migration and batch_migration are deliberately left on their own loops for a follow-up PR since they're under active, fast-moving upstream development. Migrating each runner surfaced and fixed a few latent bugs (e.g. retryable waits that were silently consuming retry budget), called out per-commit. Verified by introducing new driver-level tests (test_task_runner_base.py, test_runner_specs.py) pinning the exception-vocabulary contract.

@mxsrc
mxsrc force-pushed the rework-task-runners branch from 20dfeb2 to 69f2330 Compare August 7, 2026 15:37
Comment thread simplyblock_core/services/task_runner_base.py Fixed
Comment thread simplyblock_core/services/tasks_runner_backup.py Fixed
Comment thread simplyblock_core/services/tasks_runner_restart.py Fixed
Comment thread simplyblock_core/controllers/tasks_controller.py Fixed
Comment thread simplyblock_core/controllers/tasks_controller.py Fixed
@mxsrc
mxsrc force-pushed the rework-task-runners branch 5 times, most recently from 8c69ae5 to 358ae48 Compare August 8, 2026 15:21
mxsrc added 19 commits August 8, 2026 17:37
This is to make failures visible and avoid any chance for latently
lingering broken connections which may occur in FDB. This relies on the
deployment mechanism to a) monitor the failures, and b) restart the task
runner.
This is strictly only necessary for task runners that run concurrently.
It guards against incidental duplicate execution during rollouts, and
prepares tasks to be parallelizable.
The executor does not drive execution of a specific task but simply
periodically evaluates backup policies. This matches the pattern that
existing services have, so the executor is reclassified.
Without this, a task that fails once and succeeds later finishes DONE
while still carrying the previous attempt's failure message, because
_succeed only fills in "completed" when the result is empty.
The hand-written loop, lease claim, cancel/max-retry handling and status
transitions all move to the driver; what remains is the backup call itself.

A failed create_backup previously left the task RUNNING and re-ran it
every cycle without consuming a retry, so its max_retry ceiling never
applied. It is now a TaskRetry, which suspends the task, consumes a retry
and backs off like every other runner's failure path.
The loop, lease, cancel/max-retry handling and every task write move to
the driver; the handler keeps only the resume decision and signals
through TaskDefer (node offline, sibling task on the node, cluster not
fully online), TaskAbort (node gone, compression not needed) and
TaskRetry (resume failed).

This also fixes the not-all-nodes-online branch, whose `continue`
resumed the enclosing node scan rather than skipping the task, so a
single offline node suspended the task and then issued the RPC anyway.

Runners on the driver no longer carry a retry counter, so they drop out
of the retry-ceiling harness's source-discovered parametrization. They
are now listed explicitly instead, guarded by a test that they really
did hand the counter over.
The loop, lease, cancel/max-retry handling and the _finalize state
machine move to the driver. Every branch _finalize was called with
failure was a suspend-and-retry, so they become TaskRetry; the success
path keeps only the replication-state update and the timestamps.

The failure path's fixed 3s sleep is dropped in favour of the driver's
per-task backoff, which no longer stalls the other tasks in the cycle.

Drops the stale comment claiming a missing source node proceeds with a
best-effort ANA flip — the code has always retried instead.
Both task families it serves move to the driver's contract. The sync-op
handler comes back from tasks_controller, where it only lived because the
runner used to execute its loop at import time; with the loop behind
serve(SPEC) it belongs next to the runner it serves.

The sync-delete failure paths (RPC exception, delete rejected) suspended
the task without ever incrementing retry, so its declared max_retry=10
could never bind and a permanently failing delete re-issued forever.
They are TaskRetry now, which makes the ceiling effective.

The primary's del-sync lock is released through the driver's new
on_finish hook, so it is also freed on the terminal paths the handler
never sees — cancellation and the retry ceiling — instead of only on the
two the old inline code happened to cover.
Both keep their domain step machines; what goes is the surrounding
lifecycle. The backup runner's three families (backup, restore, merge)
become void handlers over a shared dispatch, with the node lookup and
transfer-state poll factored out since all three did them identically.

_terminate_task becomes the driver's on_finish hook. It was previously
reached only from the timeout and the retry ceiling, so a cancelled
backup task left its backup PENDING forever and a cancelled merge left
the old backup stuck in MERGING; a restore that gave up on a missing
node left its lvol RESTORING. All of those now finalize. Each branch was
already guarded on the resource still being in flight, so a successful
task passes through untouched.

Cluster expand loses its inner per-task loop, which re-ran a single
expansion back-to-back with its own backoff and starved every other
task in the cycle. The driver polls it once per cycle and backs off the
same way.
The hand-rolled thread pool, in-flight sets and per-worker retry loop
map directly onto the driver's concurrency, exclusion_key and backoff,
so all of that goes; the node_addr guard that keeps two tasks off the
same host survives as the exclusion key.

The reboot-aware branch, which used to infer "this attempt should not
count" by comparing task.retry before and after and rolling it back,
becomes a plain TaskDefer. An add_node that raises is now treated the
same as one that reports failure — it consumed no retry before, so a
permanently crashing add could never reach its ceiling.
The driver wrote task state with task.write_to_db() on the copy it
fetched before running the handler. A handler runs for minutes, and the
row changes underneath it: set_node_status(ONLINE) cancels restart tasks
via cancel_pending_node_restart_tasks, an operator cancels a task,
another host's heartbeat stamps the lease. Writing the whole stale
object back reverted all of it — an un-canceled task and a reclaimed
lease are the two lost updates behind the 2026-07-29 double restart, and
the reason upstream converted the restart runner's own writes to CAS.

Every transition now runs as a mutator against the current row, refusing
a row another actor has finished (and, for non-terminal transitions, one
it has canceled), and reporting whether it won. Retry increments land on
the fresh count instead of a stale one. on_finish only runs for the
caller that won the terminal transition, so a resource is not released
twice.

Dispatch gets the same treatment: serialized execution now submits to
the pool and waits rather than running inline. The inline path
registered nothing in the inflight map, which is exactly the split that
let a dispatch-mode flip re-enter a task still running and force-shut an
already-recovered node. Serialization becomes a per-task predicate, since
node restart chooses its mode from live cluster state rather than a
fixed concurrency.

Tests assert on the committed row via a store that models both write
paths, so each of these reproduces as a failure against the previous
implementation.
Both cancellation paths wrote the whole task object from a copy read
before they decided to cancel — cancel_pending_node_restart_tasks off a
bulk get_job_tasks scan, so stale by construction. The runner driving
that task writes the same row meanwhile, and the write put all of it
back: the owner lease cleared (handing the task to the next runner host
that polls, which runs it again), a finished task reverted to running,
retry and handler progress rolled back.

This is the same lost update as the 2026-07-29 double restart, arriving
from the canceller's side rather than the runner's, so it is fixed the
same way: a mutator against the current row.

cancel_task now sets only the flag and leaves the task where the runner
had got to, which reads it on its next pass. It no longer emits a second
task-canceled event for an already-canceled task.
cancel_pending_node_restart_tasks re-checks on the fresh row and leaves a
task that reached its own outcome in between alone, rather than
overwriting that outcome with "canceled: node back online".
The last full-object cancellation write: node shutdown flagged the
node's migration tasks canceled from a bulk get_job_tasks scan, wiping
the owner lease of any the migration runner was driving — the same
double-execution vector just fixed in the other two paths.

It moves behind tasks_controller.cancel_node_tasks so all three
cancellers share one CAS. This one declines a task that finished between
the scan and the write, where an operator's explicit cancel_task stays
deliberately unconditional.
Both are needed by the restart runner and generalize beyond it.

checkpoint() lets a handler record progress the moment an expensive or
destructive step succeeds, rather than when the handler returns: restart
must not repeat its cleanup shutdown after a crash between the shutdown
and the restart. It doubles as the cancellation probe such a handler
needs before its next destructive step.

on_cycle covers upkeep a runner owns that is attached to no task —
restart's watchdog for nodes stranded in a transitional state with no
task owning them.

The CAS commit moves to a module-level function so both the driver's
transitions and checkpoint() share it.
The runner that motivated the driver's CAS writes and single dispatch
path now uses them: _task_finish/_task_update, the thread pool, both
inflight maps and the per-task backoff schedule are all the driver's,
which is most of the 200 lines this removes.

Its parallel-vs-serialized choice becomes the spec's serialize
predicate, and the condition now has one definition — the dispatch loop
and the handler's peer-exclusion pre-check computed it separately, and
had they ever disagreed a task fanned out in parallel would have
immediately deferred on the peers it was dispatched alongside.

The give-up side effects move to on_finish, keyed off the task's own
state rather than off having just written it: parking the node OFFLINE
and re-queueing when the ceiling terminates a node task, exhausting a
device's retries when its task is canceled. Side effects the handler can
still reach stay in the handler.

Two behaviours needed re-expressing rather than moving:

- "stop, a restart is already in flight" was inferred from the task
  still being NEW/SUSPENDED, which distinguishes nothing now that the
  driver sets RUNNING before calling. It records the fact directly
  instead, which also covers an attempt that issued the restart and then
  died — that RESTARTING is ours to finish, not to defer to.
- The cleanup shutdown's once-flag is written through the driver's
  checkpoint, keeping it persisted the moment the shutdown succeeds
  rather than when the handler returns.

Shutdown and restart raising stay defers, as before: neither consumed a
retry, and restart's give-up has side effects that a changed verdict
would start triggering where it previously could not.
The three runners drive the same data-plane operation and differ only in
what they migrate and when they may start, so the shared half moves to
migration_task_common: the status poll, the start-and-record step, the
cluster/expansion gate, the settle wait and the recovery gate.
utils.handle_task_result and tasks_controller.defer_task_for_expansion
were the last two places outside a runner that wrote task state; both
are gone.

The vocabulary gained TaskProgress for this family. An in-progress poll
cannot suspend the task: get_active_node_mig_task keys the family's
mutual exclusion on a sibling being RUNNING, so a suspended migration
would let a second one start on the same node.

Three things needed re-expressing rather than moving:

- "has this task started its migration" was read off task.status being
  RUNNING, which the driver now sets before every call. It reads the
  marker the start step writes, which is what the status stood in for.
- The failed-migration runner tagged its device by asking whether any
  task for it was still open — correct only because handle_task_result
  had already written this one DONE. It moves to on_finish, where that
  is true by construction rather than by accident.
- The master-task roll-up becomes per-cycle upkeep. It reads every
  sub-task of a master anyway, so running it once a cycle covers status
  changes that finish no sub-task, and drops the redundant recomputation
  each sibling used to trigger.
Both are mechanical translations of their suspend points into the
handler vocabulary, and neither had a retry counter to hand over — every
gate in them deferred.

Node removal's "incomplete, retry later" pass becomes TaskProgress. It
is progress, not failure: the removal waits on device failure-migration
that can take hours, and treating each poll as a retry would have earned
it an exponential backoff it never had.

Port allow keeps its documented opt-out — no IN_ACTIVATION eligibility
gate, because activation needs exactly these ports open. Two of its
paths returned bare, leaving the task untouched for the loop to revisit;
under the driver a bare return means success, so both are now explicit
defers. Its abort-on-half-open-hublvol path keeps the abort side effect
in the handler and raises TaskAbort for the outcome.

Adds an import-smoke over all thirteen migrated runners that also
asserts no two of them claim the same task function name — the lease
guards against a second host, not against a second spec.
@mxsrc
mxsrc force-pushed the rework-task-runners branch from 358ae48 to ef9388d Compare August 8, 2026 15:38
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