feat(worker): until-idle drain mode (#53) - #61
Open
rcbevans wants to merge 11 commits into
Open
Conversation
rcbevans
force-pushed
the
spec/worker-drain
branch
from
July 30, 2026 04:36
f15ce27 to
9befa88
Compare
…tations Add count_active_jobs(queues: list[str]) -> int to count non-terminal jobs (pending, scheduled, running) across the specified queues. Used by the drain monitor to detect when queues are empty. - Protocol: add method to Backend (BACKEND_PROTOCOL_VERSION stays at 3) - PostgresBackend: SQL template with ::text[] parameter binding for queue names, schema validated against _IDENT_RE - InMemoryBackend: uses ACTIVE_STATUSES from statemachine (not hardcoded) - FakeBackend: stub returning 0 - TestMethodCount: 36→37, add count_active_jobs to expected names - 8 unit tests + 1 integration test covering empty queues, no jobs, pending, running, scheduled, terminal excluded, multi-queue, subset
Add idle_settle_window (2.0s default, ge=0.0), idle_poll_interval (1.0s default, ge=0.1), and idle_max_runtime (None default, gt=0) fields for the until-idle drain monitor. Includes 12 unit tests covering defaults, env overrides, validation constraints (F5), and type checks.
Add drain_failures: int = 0 field to WorkerDeps dataclass. Incremented by di_consumer_loop when dispatch_one_job returns 'failed'. Read by the drain monitor to determine exit code (0 vs 2). Per review finding F1: 'cancelled' is NOT in the failure set because CancelledError propagates as BaseException (not caught by the consumer's except Exception), so it never reaches the increment as a value.
…drain_failures
- dispatch_one_job now returns AttemptOutcome instead of None
- di_consumer_loop captures outcome, increments drain_failures on 'failed'
- Consolidate duplicate AttemptOutcome: _consumer.py imports from _handlers.py (F8)
- Tighten local outcome type to AttemptOutcome (F6)
- Failure set is ONLY {'failed'} — 'cancelled' propagates as CancelledError
(BaseException, not caught by except Exception) so never reaches the
increment (F1)
- 6 new unit tests for return type and drain_failures increment paths
New module src/taskq/worker/drain.py with: - drain_monitor_loop: polls count_active_jobs + active_jobs.count(), triggers orchestrate_shutdown when idle for settle window - _trigger_drain_shutdown: creates orchestration wrapper task with drain exit code, H2 double-orchestration guard - _sleep_or_shutdown: cancellable sleep helper - Exit codes: 0 (clean), 2 (failures), 3 (timeout) F3: catches only (TimeoutError, PostgresConnectionError, OSError) — non-recoverable errors propagate and tear down the TaskGroup F4: test for error-recovery path (transient error → continue polling) F2: documented create_task/append race window as effectively atomic 13 unit tests covering all drain monitor behaviors.
…-idle - _main and worker_main accept until_idle, idle_settle_window, idle_poll_interval, max_runtime keyword-only params - Drain monitor spawned as sibling with may_return=True when until_idle - Cron+until_idle startup warning emitted (L6) - H2 double-orchestration guard on signal handler's first-signal arm - CLI: --until-idle, --idle-settle-window, --idle-poll-interval, --max-runtime flags passed through to worker_main - 8 new tests: 4 wiring tests + 4 CLI tests
4 e2e tests covering real worker containers with TASKQ_UNTIL_IDLE: - Clean drain: 3 jobs → all succeed → exit 0 - Drain with failures: permanent error → exit 2 - Max runtime timeout: long job + short cap → exit 3 - Scheduled jobs: future-scheduled job → worker waits → processes → exits 0 Also modifies worker_entry.py to accept TASKQ_UNTIL_IDLE env var and pass until_idle to worker_main. F9: timeout test acknowledges both pending and running states prevent idle — the exact internal state doesn't matter, only that the timeout fires correctly.
- workers.md: new 'Until-idle mode' section with usage, exit codes, scheduled jobs, cron warning, multi-worker behavior, settle=0 boundary (F16), and drain_failures known limitations including cancelled-during-drain semantics (F15) - cli.md: --until-idle, --idle-settle-window, --idle-poll-interval, --max-runtime in worker options table; exit codes 2/3 in exit codes table - architecture.md: DrainMonitor in component diagram; count_active_jobs in Backend protocol declaration - Settings reference: three new TASKQ_IDLE_* fields
Code quality fixes: - Extract H2 double-orchestration guard into shared _orchestration_in_progress helper in shutdown.py (DRY — was duplicated in drain.py and shutdown.py) - Replace _sleep_or_shutdown in drain.py with existing _sleep_interruptible from _leader_sweeps.py (DRY — identical pattern already existed) - Add asyncpg.InterfaceError to _COUNT_RECOVERABLE_EXCEPTIONS (F4 — pool closing during credential reload would crash drain monitor) - Fix type: ignore[possibly-undefined] in _bootstrap.py by defining settle/poll/runtime with defaults before the if-until_idle block - Import AttemptOutcome directly from _handlers.py in dispatch.py (was transitively imported through _consumer.py) - Run ruff format on cli.py and _bootstrap.py - Remove unused contextlib import from drain.py - Document signal handler behavior when drain triggers first (F3) Documentation fixes: - workers.md: 'instant' → 'near-instant (one poll interval)' for idle_settle_window=0 (F12) Test coverage gaps fixed: - Non-recoverable exception from count_active_jobs propagates (GAP 2) - idle_settle_window=0.0 boundary test (GAP 1) - --idle-poll-interval CLI flag passthrough test (GAP 8) - 'cancelled' outcome does NOT increment drain_failures (GAP 6) - idle_poll_interval=0.1 minimum acceptance test (GAP 18) - count_active_jobs with non-existent queue returns 0 (GAP 12) - idle_settle_window=0.0 accepted in settings (GAP 1 settings) 4038 tests pass, 0 failures, ruff + pyright clean.
rcbevans
force-pushed
the
spec/worker-drain
branch
from
July 30, 2026 04:57
9befa88 to
c0ae2c3
Compare
…alidation - Renumber drain exit codes: 0=clean, 3=failures, 4=timeout (avoid collision with existing exit codes 1/2) - Rename CLI flag --max-runtime → --idle-max-runtime for namespacing - Handlers return AttemptOutcome based on actual DB transition, not hardcoded value from exception type - Add input validation for idle_* params in _bootstrap - Drain monitor uses shared TRANSIENT_PG_ERRORS + asyncio.wait_for timeout - Add liveness ticks in drain monitor loop - Remove ASYNC109 from e2e per-file ignores + clean up unused noqa - 344 new lines of handler return-value contract tests - Ruff format fixes post-rebase
rcbevans
force-pushed
the
spec/worker-drain
branch
from
July 30, 2026 05:01
c0ae2c3 to
1348597
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
Adds
--until-idlemode totaskq workerandworker_main(until_idle=True)for batch-processing use cases. The worker drains all jobs in its subscribed queues through the existing four-phase graceful shutdown path (DRAINING → CANCELLING → FORCING → ABANDONING) and exits with a status code:0if every job succeeded,2if any failed,3if a max-runtime cap was hit. No new shutdown plumbing — the drain monitor triggers the sameorchestrate_shutdownthat SIGTERM uses.Issue addressed
Closes #53 — requested a worker option to exit when queues are drained, with non-zero exit codes for failures and timeout.
Implementation
Drain monitor loop (
src/taskq/worker/drain.py— new):drain_monitor_loop()is spawned as a sibling coroutine (withmay_return=True) only whenuntil_idle=True. It pollsbackend.count_active_jobs(queues)anddeps.active_jobs.count()everyidle_poll_interval. When both stay zero foridle_settle_window, it triggersorchestrate_shutdownvia_trigger_drain_shutdown(), which creates the orchestration task and appends it toorchestrator_holder— exactly as the SIGTERM handler does. It does not setshutdown_eventdirectly;orchestrate_shutdown'sfinallyblock does that at the correct point. Ifmax_runtimeis exceeded before drain completes, it triggers shutdown with exit code 3.Double-orchestration guard (H2):
_trigger_drain_shutdownand_on_shutdown_signalinshutdown.pyboth checkorchestrator_holderanddeps.shutdown_phasebefore creating a second orchestration task. First trigger wins — if SIGTERM fires while the drain monitor is mid-orchestration, the signal handler returns early, and vice versa.drain_failurescounter (src/taskq/worker/deps.py): Newintfield onWorkerDeps, defaulting to 0. Incremented bydi_consumer_loopinrun.pywhendispatch_one_jobreturns"failed", or whendispatch_one_jobraises an exception. The drain monitor reads this counter to pick exit code 0 vs 2."scheduled"(snooze/retry) does not increment — a retried job is not a drain failure.dispatch_one_jobreturn type (src/taskq/worker/dispatch.py): Changed from-> Noneto-> AttemptOutcome. Theoutcomevariable was already computed for metrics;return outcomewas added after the try/except/finally block (not infinally, to preserveCancelledErrorpropagation).di_consumer_loopcaptures the return value.count_active_jobsbackend method: Added to theBackendprotocol,PostgresBackend,InMemoryBackend, andFakeBackend. The SQL query counts jobs with status in('pending', 'scheduled', 'running')filtered by queue. TheInMemoryBackendimplementation reusesACTIVE_STATUSESfromstatemachine.py. Protocol version stays at 3 (additive, loud-failure on missing method).WorkerSettingsfields (src/taskq/settings.py): Three new fields —idle_settle_window(default 2.0s,ge=0),idle_poll_interval(default 1.0s,ge=0.1),idle_max_runtime(defaultNone,gt=0). All overridable via env vars (TASKQ_IDLE_SETTLE_WINDOW,TASKQ_IDLE_POLL_INTERVAL,TASKQ_IDLE_MAX_RUNTIME) and CLI flags.worker_main/_mainwiring (src/taskq/worker/_bootstrap.py): Both functions acceptuntil_idle,idle_settle_window,idle_poll_interval,max_runtimeparams. Whenuntil_idle=True, settings overrides are resolved, a cron-incompatibility warning is logged if cron schedules are present, anddrain_monitor_loopis spawned inside the TaskGroup.CLI (
src/taskq/cli.py): Added--until-idle,--idle-settle-window,--idle-poll-interval,--max-runtimeoptions totaskq worker, passed through toworker_main().Test coverage
tests/test_count_active_jobs.pytests/test_worker_settings_drain.pytests/test_worker_drain.pyWorkerDeps.drain_failuresdefault;dispatch_one_jobreturn type;di_consumer_loopdrain_failures increment on failure/exception/no-increment on success/scheduled; drain monitor: triggers on idle, exit code 2 on failures, exit code 3 on timeout, settle reset on new jobs, no trigger when active jobs, skips when orchestration already active, continues after transient count errortests/test_worker_main.pyuntil_idle, exit code 2 on failures, exit code 3 on timeout, no drain monitor withoutuntil_idle, watchdog disarm orderingtests/test_cli_worker.py--until-idleflag passed toworker_main, default false,--idle-settle-windowpassedtests/test_backend_protocol.pyTestMethodCount: 36 → 37 members, addedcount_active_jobsto expected namestests/e2e/test_until_idle.pyDocumentation
docs/guides/workers.md— new "Until-idle mode" section with CLI and Python usage, exit codes, and cron incompatibility notedocs/guides/cli.md—--until-idleand related flags in the worker options table; exit codes 2 and 3 documenteddocs/architecture.md— DrainMonitor box added to architecture diagram;count_active_jobsadded to Backend protocol listingdocs/specs/2026-07-29-worker-drain.md— full design specCloses #53