Skip to content

feat(worker): until-idle drain mode (#53) - #61

Open
rcbevans wants to merge 11 commits into
mainfrom
spec/worker-drain
Open

feat(worker): until-idle drain mode (#53)#61
rcbevans wants to merge 11 commits into
mainfrom
spec/worker-drain

Conversation

@rcbevans

@rcbevans rcbevans commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds --until-idle mode to taskq worker and worker_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: 0 if every job succeeded, 2 if any failed, 3 if a max-runtime cap was hit. No new shutdown plumbing — the drain monitor triggers the same orchestrate_shutdown that 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 (with may_return=True) only when until_idle=True. It polls backend.count_active_jobs(queues) and deps.active_jobs.count() every idle_poll_interval. When both stay zero for idle_settle_window, it triggers orchestrate_shutdown via _trigger_drain_shutdown(), which creates the orchestration task and appends it to orchestrator_holder — exactly as the SIGTERM handler does. It does not set shutdown_event directly; orchestrate_shutdown's finally block does that at the correct point. If max_runtime is exceeded before drain completes, it triggers shutdown with exit code 3.

Double-orchestration guard (H2): _trigger_drain_shutdown and _on_shutdown_signal in shutdown.py both check orchestrator_holder and deps.shutdown_phase before 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_failures counter (src/taskq/worker/deps.py): New int field on WorkerDeps, defaulting to 0. Incremented by di_consumer_loop in run.py when dispatch_one_job returns "failed", or when dispatch_one_job raises 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_job return type (src/taskq/worker/dispatch.py): Changed from -> None to -> AttemptOutcome. The outcome variable was already computed for metrics; return outcome was added after the try/except/finally block (not in finally, to preserve CancelledError propagation). di_consumer_loop captures the return value.

count_active_jobs backend method: Added to the Backend protocol, PostgresBackend, InMemoryBackend, and FakeBackend. The SQL query counts jobs with status in ('pending', 'scheduled', 'running') filtered by queue. The InMemoryBackend implementation reuses ACTIVE_STATUSES from statemachine.py. Protocol version stays at 3 (additive, loud-failure on missing method).

WorkerSettings fields (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 (default None, gt=0). All overridable via env vars (TASKQ_IDLE_SETTLE_WINDOW, TASKQ_IDLE_POLL_INTERVAL, TASKQ_IDLE_MAX_RUNTIME) and CLI flags.

worker_main / _main wiring (src/taskq/worker/_bootstrap.py): Both functions accept until_idle, idle_settle_window, idle_poll_interval, max_runtime params. When until_idle=True, settings overrides are resolved, a cron-incompatibility warning is logged if cron schedules are present, and drain_monitor_loop is spawned inside the TaskGroup.

CLI (src/taskq/cli.py): Added --until-idle, --idle-settle-window, --idle-poll-interval, --max-runtime options to taskq worker, passed through to worker_main().

Test coverage

File Type Coverage
tests/test_count_active_jobs.py Unit + integration 8 unit tests (InMemoryBackend: empty queues, no jobs, pending, running, scheduled, terminal excluded, multi-queue, queue subset) + 1 PostgresBackend integration test
tests/test_worker_settings_drain.py Unit 12 tests: defaults, env overrides, validation (negative settle window, poll below 0.1, zero max_runtime), float type checks
tests/test_worker_drain.py Unit WorkerDeps.drain_failures default; dispatch_one_job return type; di_consumer_loop drain_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 error
tests/test_worker_main.py Unit Drain monitor spawned with until_idle, exit code 2 on failures, exit code 3 on timeout, no drain monitor without until_idle, watchdog disarm ordering
tests/test_cli_worker.py Unit --until-idle flag passed to worker_main, default false, --idle-settle-window passed
tests/test_backend_protocol.py Unit Updated TestMethodCount: 36 → 37 members, added count_active_jobs to expected names
tests/e2e/test_until_idle.py E2E 4 tests with real Postgres: drain and exit 0, exit 2 on failures, exit 3 on timeout, scheduled jobs drain before exit

Documentation

  • docs/guides/workers.md — new "Until-idle mode" section with CLI and Python usage, exit codes, and cron incompatibility note
  • docs/guides/cli.md--until-idle and related flags in the worker options table; exit codes 2 and 3 documented
  • docs/architecture.md — DrainMonitor box added to architecture diagram; count_active_jobs added to Backend protocol listing
  • docs/specs/2026-07-29-worker-drain.md — full design spec

Closes #53

@rcbevans
rcbevans requested review from XBeg9, clinzy and kjw-azx July 30, 2026 01:35
@rcbevans rcbevans self-assigned this Jul 30, 2026
Base automatically changed from feat/e2e-test-suite to main July 30, 2026 04:32
@rcbevans
rcbevans force-pushed the spec/worker-drain branch from f15ce27 to 9befa88 Compare July 30, 2026 04:36
rcbevans added 10 commits July 29, 2026 21:54
…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
rcbevans force-pushed the spec/worker-drain branch from 9befa88 to c0ae2c3 Compare July 30, 2026 04:57
…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
rcbevans force-pushed the spec/worker-drain branch from c0ae2c3 to 1348597 Compare July 30, 2026 05:01
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