Shut down workers on idle instead of a static maximum lifetime — Closes #100 - #101
Draft
conradbzura wants to merge 7 commits into
Draft
Shut down workers on idle instead of a static maximum lifetime — Closes #100#101conradbzura wants to merge 7 commits into
conradbzura wants to merge 7 commits into
Conversation
conradbzura
force-pushed
the
100-worker-shutdown-on-idle
branch
2 times, most recently
from
August 12, 2026 16:53
3181237 to
4a2196d
Compare
wool 0.14 adds an idle RPC to every worker and a WorkerConnection.idle() client for it, which is the primitive the idle-based worker shutdown needs. The LocalWorker construction surface and every other wool import cfdb uses are unchanged from 0.13.0. The specifier is compatible-release on 0.14 rather than exact: patch releases are picked up, 0.15 is not, since a minor bump may move the wire protocol the dispatch channel rides on. Because wool admits a worker only when the proxy's version is at most the worker's, any lock refresh that moves the resolved version is a fleet-rejection event rather than a routine dependency update, so refresh the lock deliberately and drain the fleet when it moves.
The static max-lifetime ceiling was the only self-termination path because wool exposed no per-job activity signal: the worker could not tell idle from busy, so drained-to-idle workers billed Fargate for hours and the ceiling could preempt a worker mid-task. The serve loop now dials its own worker over loopback (the same channel-back-to-own-subprocess pattern wool uses for graceful stop, so mTLS credentials and the identity SAN verify unchanged) and polls the idle RPC on a 15 s cadence, exiting once continuous idle crosses CFDB_WORKER_IDLE_TIMEOUT_SECONDS (default 600 s, 0 disables). A busy worker reports zero idle, so the idle exit never fires while a task is running. Both self-termination exits now stop the worker with a drain grace rather than wool's default immediate cancel. An idle reading is a snapshot, so it cannot rule out a dispatch accepted between the final poll and the teardown, and a max-lifetime expiry can land mid-job on a worker running jobs back to back; in either case the task has already been marked running on the API side, where a graceless cancel is finalized as a terminal failure instead of being re-queued. The grace (CFDB_WORKER_MAX_LIFETIME_GRACE_SECONDS, default 6 h) returns instantly when the docket is empty and is sized above the API's 4 h per-job duration cap, so the only work it can cancel is work already past that bound. That in turn lets the ceiling rise to 12 h without reintroducing mid-task preemption, at a worst-case worker uptime of lifetime plus grace. The signal paths keep the immediate cancel, since ECS bounds SIGTERM with SIGKILL anyway. Poll failures are handled without either killing a healthy worker or flooding the logs: IdleUnavailable disables polling for the process lifetime, an isolated failure retries on the next cadence, and after CFDB_WORKER_IDLE_POLL_FAILURE_LIMIT consecutive failures the worker escalates once to ERROR and stops polling. Those messages now name the failing exception type and describe the remaining bound honestly, rather than claiming a max-lifetime backstop that is disabled.
Extends the CLI tests with the new idle and lifetime knobs and adds a suite exercising the serve loop's idle path: exit on crossing the threshold, immunity while busy, idle accumulating below the threshold, the 0-disables sentinel, the IdleUnavailable fallback to max-lifetime, transient and non-transient poll-failure retry, escalation after consecutive failures, the loopback dial with the worker's own credentials, and connection teardown including a failing close. The exit-path assertions name the path taken rather than only the outcome, since returning 0 with the worker stopped is equally true of the max-lifetime backstop — a broken idle comparison would otherwise still pass, just slower. Both self-termination tests also pin the drain grace passed to stop, which is what keeps a task racing the teardown from being cancelled into a terminal job failure. The health server is no longer patched out: these tests already bind port 0, so the real one comes up on an ephemeral port and the helper stops reaching into module privates.
Adds a WorkerIdleTimeoutSeconds parameter (default 600) feeding CFDB_WORKER_IDLE_TIMEOUT_SECONDS in the worker container, and a WorkerMaxLifetimeGraceSeconds parameter (default 21600) feeding CFDB_WORKER_MAX_LIFETIME_GRACE_SECONDS, the drain a self-terminating exit grants in-flight work before cancelling it. The max-lifetime ceiling rises to 43200 to match the code default, which the grace makes safe. The idle-timeout description also records the tuning constraint that is otherwise only discoverable by hitting it: an idle timeout at or below the scheduler's retry cadence plus worker cold-start time lets overflow-spawned workers reap themselves before the next retry tick reaches them, and the jobs ride the queue to the capacity deadline. Landing the parameters requires a one-time privileged cloudformation deploy per environment, as with every workers-stack change.
The template must both wire each reaping knob from its parameter and keep the parameter default equal to the code default; a miss on either silently deploys a fleet whose reaping cadence disagrees with what the code documents. Covers the idle timeout, the max-lifetime ceiling, and the self-termination drain grace.
The leaky-bucket and version-bump-wedge sections and the provisioner's orphan-worker note all described max-lifetime as the only reaper; idle shutdown is now the primary one, shrinking the wedge window from hours to minutes once the fleet runs an idle-aware image. The wedge section also notes that a bump from a pre-idle fleet still needs the manual drain, since old workers cannot self-reap early, and that the eventual refresh of the pre-release wool pin is itself such a bump. The worker-knobs paragraph states what the reaping guarantee actually is — a busy worker reports zero idle, and a task that races the teardown drains rather than dying — along with the resulting worst-case worker uptime and the sizing constraint on the idle timeout relative to the scheduler's retry cadence. The mTLS identity section names the idle poll as a third consumer of the identity contract, since a worker now dials its own subprocess over loopback: under the address-verification opt-out the worker leaf needs a loopback SAN, or every poll fails its handshake and idle shutdown quietly degrades to the backstop.
Both worker self-termination paths stop with a drain grace, and that grace is the only reason they are safe. An idle reading is a snapshot, so it cannot rule out a dispatch accepted between the final poll and the teardown, and a max-lifetime expiry can land mid-job outright. In either case the API has already marked that task running, where a mid-stream cancel is finalized as a terminal job failure rather than re-queued. Every unit test on that path mocks the worker, so the suite asserts only that a grace is passed; nothing verifies wool honors it. A wool change that cancelled regardless would leave the suite green while restoring the defect the grace exists to prevent. The two tests are a controlled pair over one real worker and one real dispatch, identical but for the grace and with opposite outcomes. The graceless case is what makes the drain meaningful: without it, a passing drain could just as well be a task that finished on its own. Both bound the wait, so a task that hangs fails the test rather than passing as something-went-wrong. The routine lives in the shared routines module because cloudpickle must resolve it by reference across the worker boundary, and the pool is bound to a reserved ephemeral port because dispatch needs a session while the stop has to target one known worker.
conradbzura
force-pushed
the
100-worker-shutdown-on-idle
branch
from
August 12, 2026 18:23
4a2196d to
4390f6c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A worker exits once it has been continuously idle past a threshold, rather than once a static wall-clock ceiling expires. wool 0.14 (wool-labs/wool#269) gives every worker an
idleRPC reporting seconds since its in-flight task set last became empty, read throughWorkerConnection.idle(). Theworker_mainserve loop dials its own worker over loopback — the same channel-back-to-own-subprocess pattern wool uses for graceful stop, so mTLS credentials and the identity SAN verify unchanged — and polls that value on a 15 s cadence, exiting once it crossesCFDB_WORKER_IDLE_TIMEOUT_SECONDS(default 600 s; 0 disables). A busy worker reports zero idle, so the idle exit never fires while a task is running.Both self-termination paths stop the worker with a drain grace rather than wool's default immediate cancel. An idle reading is a snapshot, so it cannot rule out a dispatch accepted between the final poll and the teardown, and a max-lifetime expiry can land mid-job on a worker running jobs back to back. In either case the API has already marked that task running, where a graceless cancel is finalized as a terminal failure instead of being re-queued.
CFDB_WORKER_MAX_LIFETIME_GRACE_SECONDS(default 6 h) returns instantly on an empty docket and is sized above the API's 4 h per-job duration cap, so the only work it can cancel is work already past that bound.That drain is pinned against real wool rather than argued from its contract: an integration test dispatches a task to a live worker and stops it mid-flight, asserting the task completes, paired with a graceless stop asserting it is cancelled.
The grace is what lets the ceiling rise to 12 h.
CFDB_WORKER_MAX_LIFETIME_SECONDSis no longer a periodic reaper that eventually preempts a healthy worker; it bounds the two cases idle reporting cannot see — a wedged or unimplementedidleRPC, and a stuck job that holds the in-flight set non-empty, since a hung subprocess reports zero idle exactly like a working one. Worst-case worker uptime is lifetime plus grace, 18 h at the defaults.Closes #100
Proposed changes
Track wool 0.14
Move
pyproject.tomlfromwool~=0.13.0towool~=0.14.0. TheLocalWorkerconstruction surface and every other wool import cfdb uses are unchanged between the two versions.The specifier accepts patch releases but not 0.15, since a minor bump may move the wire protocol the dispatch channel rides on. Because wool admits a worker only when the proxy's version is at most the worker's, any lock refresh that moves the resolved version is a fleet-rejection event rather than a routine dependency update — refresh the lock deliberately and drain the fleet when it moves.
Idle-based shutdown with a draining exit
Add the idle poll to
worker_main.serve, along withDEFAULT_IDLE_TIMEOUT_SECONDS(600 s), a 15 s poll cadence, and a 5 s per-poll gRPC deadline. The loop checks max lifetime, then the signal-drain path, then the idle poll, so a SIGTERM drain always takes precedence over an idle exit.Track the stop grace as loop state rather than a fixed argument: the idle and max-lifetime exits set it to
max_lifetime_grace_seconds, and the signal paths leave it at wool's immediate cancel, which is correct there because ECS bounds SIGTERM with SIGKILL and a long drain could never complete.Raise
DEFAULT_MAX_LIFETIME_SECONDSto 12 h and addDEFAULT_MAX_LIFETIME_GRACE_SECONDSat 6 h.Poll-failure handling
A poll failure must neither kill a healthy worker nor flood the logs for hours.
IdleUnavailabledisables polling for the process lifetime, since a version-skew scenario must not crash-loop the poll. An isolated failure retries on the next cadence. AfterCFDB_WORKER_IDLE_POLL_FAILURE_LIMITconsecutive failures — 20 by default, roughly 5 minutes at the standard cadence — the worker escalates once to ERROR and stops polling, which turns a permanent TLS misconfiguration or a dead subprocess into one actionable CloudWatch record instead of hundreds of identical warnings. Both messages name the failing exception type, and both describe the remaining bound honestly rather than reporting a max-lifetime backstop that is disabled.Worker knobs
Expose
--idle-timeout-seconds,--idle-poll-interval-seconds,--idle-poll-failure-limit, and--max-lifetime-grace-seconds, each mirroring aCFDB_WORKER_*environment variable per the module's existing convention.Wire the reaping knobs through the workers stack
Add
WorkerIdleTimeoutSeconds(default"600") andWorkerMaxLifetimeGraceSeconds(default"21600") tocloudformation/workers.yml, both feeding the container environment, and raiseWorkerMaxLifetimeSecondsto"43200"to match the code default. Template tests pin the env wiring and default-agreement for all three.The
WorkerIdleTimeoutSecondsdescription also records a tuning constraint that is otherwise discoverable only by hitting it: an idle timeout at or belowCFDB_WORKFLOW_RETRY_INTERVAL_Splus worker cold-start time lets overflow-spawned workers reap themselves before the scheduler's next retry tick reaches them, and the jobs then ride the queue to thecapacity:deadline while every dashboard looks healthy.Pin the stop-grace contract against a real worker
Add an integration test that starts a real worker, dispatches a real task, and stops it mid-flight. Every unit test on this path mocks
wool.LocalWorker, so the suite otherwise asserts only that cfdb passes a grace — nothing verifies wool honors it, and a wool change that cancelled regardless would leave the suite green while restoring the defect the grace exists to prevent. The graceless case is tested alongside it, so the two form a controlled pair differing only in the grace.Documentation
Reframe the README's leaky-bucket and version-bump-wedge sections and the provisioner's orphan-worker note around idle-based reaping: rejected or orphaned workers self-reap in minutes rather than hours once the fleet runs an idle-aware image. State the reaping guarantee, the resulting worst-case uptime, and the idle-timeout sizing constraint in the worker-knobs paragraph. Name the idle poll as a third consumer of the mTLS identity contract alongside dispatch and the drain channel, since a worker now dials its own subprocess over loopback — under the address-verification opt-out (
CFDB_WORKER_TLS_IDENTITY="") the worker leaf needs a loopback SAN, or every poll fails its handshake and idle shutdown degrades to the backstop with only poll warnings as the symptom.Deployment notes
This PR is itself a wool version bump, so on deploy the rolled API rejects in-flight 0.13 workers until fleet turnover. Drain the dev worker fleet as part of the deploy, per the README's version-bump runbook: the old workers predate the
idleRPC and cannot self-reap early. The same applies before the first prod promotion carrying this change.Landing the two new parameters requires the usual one-time privileged
workers.ymldeploy per environment. Until it runs, deployed workers use the identical code defaults, so the deploy order is not load-bearing here.Because the specifier is
~=0.14.0, a later lock refresh that picks up a wool patch release is itself a version bump and repeats the same drain procedure — the hazard is not limited to deliberate upgrades.Test cases
TestMainCliworker_main.mainis invokedservereceives the documented defaults for every idle and lifetime knobTestMainCliworker_main.mainis invoked with no flagsservereceives the env-driven valuesTestMainCliworker_main.mainis invokedTestServeIdleShutdownserverunsTestServeIdleShutdownserverunsTestServeIdleShutdownserverunsTestServeIdleShutdownidle_timeout_secondsset to 0serverunsWorkerConnectionTestServeIdleShutdownIdleUnavailableserverunsTestServeIdleShutdownserverunsTestServeIdleShutdownserverunsTestServeIdleShutdownserverunsTestServeIdleShutdownserveruns to idle exitTestServeIdleShutdownservereturnsWorkerConnectionTestServeIdleShutdownWorkerConnectionwhosecloseraisesserveruns to completiontest_cloudformationCFDB_WORKER_IDLE_TIMEOUT_SECONDSreferences the parameter and the default equals the code constanttest_cloudformationCFDB_WORKER_MAX_LIFETIME_SECONDSreferences the parameter and the default equals the code constanttest_cloudformationCFDB_WORKER_MAX_LIFETIME_GRACE_SECONDSreferences the parameter and the default equals the code constantTestWorkerStopGraceTestWorkerStopGrace