Worker¶
Worker process internals: dispatcher, consumer, heartbeat, leader election, shutdown.
taskq.worker itself is a lazy __getattr__ shim (re-exports selected names from its
submodules on first access) and renders no useful members on its own. The directives below
target the concrete submodules instead. See also the Workers guide
for a task-oriented walkthrough.
Process entry point, registration, producer/consumer loops¶
run ¶
Worker-runtime wiring helpers and process entry point.
This module forms the production seam between WorkerDeps assembly
(open_worker_deps, deps.py) and the heartbeat task spawn. It also hosts
the worker process entry point worker_main and the _main bootstrap
coroutine that wires the full TaskGroup of long-lived siblings.
Deviations from the original sketch
- Signal handlers go through
install_signal_handlers, not inline lambdas. orchestrate_shutdowntakes notgparameter.ProcessScope/ThreadScope/LoopScopeare bootstrapped insideopen_worker_depsrather than before it (M3 single-process deployment , process exit and deps exit are coterminal; see_maincomment block).- The
_local_queue_seedkeyword-only parameter is a test seam, not public API. local_queuemaxsize usesmax_concurrency(notbatch_size, nobatch_sizefield exists onWorkerSettings).
M1 stub consumers accept a stub_work_timeout keyword-only parameter
(default 60.0 s) that controls the sentinel sleep duration. Integration
tests may pass a shorter override (e.g. stub_work_timeout=2.0) when
seeding jobs that must complete naturally during a short test run. The
bootstrap accepts the default; M2 replaces the stubs with the
real producer/consumer.
__all__
module-attribute
¶
__all__ = [
"_emit_resolved_capacity_startup_lines",
"_main",
"consumer_loop_stub",
"deregister_worker",
"di_consumer_loop",
"producer_loop",
"producer_loop_stub",
"register_worker",
"worker_main",
"worker_main_async",
]
worker_main ¶
worker_main(
settings: WorkerSettings,
*,
actor_registry: Mapping[str, ActorRef[Any, Any]]
| None = None,
di_registry: ProviderRegistry | None = None,
cron_registry: list[CronScheduleSpec] | None = None,
connections: WorkerConnections | None = None,
pg_credential_provider: PgCredentialProvider
| None = None,
rate_limit_registry: RateLimitRegistry | None = None,
until_idle: bool = False,
idle_settle_window: float | None = None,
idle_poll_interval: float | None = None,
idle_max_runtime: float | None = None,
) -> int
Worker process entry point (synchronous).
Thin wrapper: runs :func:worker_main_async under an asyncio.Runner
and returns its int result. Uses Runner (not asyncio.run) for
finer control over teardown.
Because this function owns the loop, an application that must build
loop-bound dependencies (asyncpg pools) before constructing its actors
cannot use it - await :func:worker_main_async from its own coroutine
instead. Both take exactly these parameters.
actor_registry is a mapping from short name to :class:ActorRef
containing every @actor-decorated handler this worker intends to
run. Forwarded to :func:_main for the bootstrap config sync.
pg_credential_provider is the resolved Postgres credential
provider for the worker-internal per-slot transaction pool (see
:func:_main). Pass the same provider used to build
connections, the slot pool does not read WorkerConnections.
None authenticates it from the direct DSN.
di_registry is an optional pre-configured :class:ProviderRegistry
containing application-specific provider registrations (database pools,
HTTP clients, etc.). When supplied, the worker uses it instead of
creating a fresh registry, callers must NOT call validate() before
passing it here; the worker calls validate() as part of its bootstrap
sequence. WorkerSettings and Clock are registered automatically
if not already present.
rate_limit_registry is an optional owned :class:RateLimitRegistry
for this worker (e.g. one instance per process in a multi-process
deployment). When None, resolution falls back to a
RateLimitRegistry value provider at Scope.LOOP in
di_registry, then to the module singleton, import-time
.register() on the singleton keeps working exactly as before.
Co-present with a RateLimitRegistry provider in di_registry
this raises TypeError (ambiguous, bootstrap and dispatch would
diverge); pass one or the other.
Forwarded to :func:_main.
cron_registry is an optional list of :class:CronScheduleSpec
objects to auto-register at startup. When None (the default),
get_registered_crons() is used instead, schedules declared via
the @cron decorator are auto-discovered. When an explicit list
is passed (even empty []), only those schedules are registered;
decorator-registered schedules are skipped. For each spec, a direct
INSERT INTO … cron_schedules is executed inside
try/except asyncpg.UniqueViolationError: pass, the DB (actor, name)
UNIQUE constraint prevents duplicates, so concurrent worker replicas
can safely race. Startup auto-discovery is create-only,
skip-on-conflict: existing cron_schedules rows are never
modified by the registration pass. If a @cron decorator's
parameters change after the schedule was first registered, the
operator must manually update or delete and recreate the schedule.
until_idle enables drain mode (see :func:_main for details).
idle_settle_window, idle_poll_interval, and
idle_max_runtime override the corresponding settings when
until_idle is True; they are ignored otherwise.
Returns the exit code from :func:_main, 0 on clean shutdown,
3 if any jobs failed in drain mode, 4 if idle_max_runtime was
exceeded.
Source code in src/taskq/worker/_bootstrap.py
2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 3033 3034 3035 3036 3037 3038 3039 3040 3041 3042 3043 3044 3045 3046 3047 3048 3049 3050 3051 3052 3053 3054 3055 3056 3057 3058 3059 3060 3061 3062 3063 3064 3065 3066 | |
worker_main_async
async
¶
worker_main_async(
settings: WorkerSettings,
*,
actor_registry: Mapping[str, ActorRef[Any, Any]]
| None = None,
di_registry: ProviderRegistry | None = None,
cron_registry: list[CronScheduleSpec] | None = None,
connections: WorkerConnections | None = None,
pg_credential_provider: PgCredentialProvider
| None = None,
rate_limit_registry: RateLimitRegistry | None = None,
until_idle: bool = False,
idle_settle_window: float | None = None,
idle_poll_interval: float | None = None,
idle_max_runtime: float | None = None,
) -> int
Run a worker to completion on the caller's event loop.
Same parameters, same semantics and same exit codes as
:func:worker_main - which is now the thin synchronous wrapper that
drives this coroutine under its own asyncio.Runner. See
:func:worker_main for what each parameter means.
Why this exists as public API: worker_main owns the loop, and
asyncpg pools are loop-bound. An application whose actors are
closures over its own dependencies has to build those dependencies
(pools, HTTP clients) on the loop the worker will run on, before the
actor registry is constructed - and there is no hook in worker_main
to do that. Pre-creating a pool in a throwaway loop and handing it to
worker_main's Runner is not an option: the pool would belong to a
dead loop. Awaiting this from inside the caller's own coroutine puts
dependency construction, actor construction, and the worker on one
loop::
async def main() -> int:
pool = await asyncpg.create_pool(dsn) # this loop
registry = build_actors(pool) # closures over it
try:
return await worker_main_async(settings, actor_registry=registry)
finally:
await pool.close()
raise SystemExit(asyncio.run(main()))
Signal handling, logging setup and cron auto-discovery are identical to
:func:worker_main; nothing about running here changes the worker's
behaviour. The caller owns whatever it built - TaskQ closes only the
resources it created itself (see :class:~taskq.connections.WorkerConnections
for the ownership rule that governs anything passed via connections).
Source code in src/taskq/worker/_bootstrap.py
3069 3070 3071 3072 3073 3074 3075 3076 3077 3078 3079 3080 3081 3082 3083 3084 3085 3086 3087 3088 3089 3090 3091 3092 3093 3094 3095 3096 3097 3098 3099 3100 3101 3102 3103 3104 3105 3106 3107 3108 3109 3110 3111 3112 3113 3114 3115 3116 3117 3118 3119 3120 3121 3122 3123 3124 3125 3126 3127 3128 3129 3130 3131 3132 3133 3134 3135 3136 3137 | |
__getattr__ ¶
Source code in src/taskq/worker/run.py
make_heartbeat_kwargs ¶
make_heartbeat_kwargs(
deps: WorkerDeps,
worker_id: UUID,
backend: Backend,
cancel_wake_event: Event | None = None,
) -> dict[str, object]
Return keyword arguments that wire a cancel controller and optional cancel-wake event into heartbeat_loop for a given worker.
Usage (production, owned by the orchestration layer)::
kwargs = make_heartbeat_kwargs(deps, worker_id, backend, cancel_wake_event)
await heartbeat_loop(deps, worker_id, shutdown, **kwargs)
Returns:
| Type | Description |
|---|---|
dict[str, object]
|
|
Source code in src/taskq/worker/run.py
producer_loop
async
¶
producer_loop(
deps: WorkerDeps,
local_queue: Queue[JobRow],
shutdown_event: Event,
producer_stop_event: Event,
*,
backend: Backend,
worker_id: UUID,
slot_freed_event: Event | None = None,
rng: Random | None = None,
) -> None
Dispatch pending jobs from the database and feed them into local_queue.
On each iteration the producer:
- Waits for a wake signal (NOTIFY-driven
asyncio.Event) or thepoll_intervalfallback timer, whichever fires first. - Calls
backend.dispatch_batch()to atomically claim up to the worker's genuinely free slots,local_queue.maxsize - local_queue.qsize() - active_jobs.count() - active_jobs.intent_count()pending jobs (pending → running) usingFOR UPDATE SKIP LOCKED. - Puts each returned :class:
JobRowontolocal_queuefor the consumer tasks.
A round that returns fewer rows than it asked for arms a short
jittered cooldown (:data:_CLAIM_COOLDOWN_SECONDS) before the next
round, so a burst of wakes or freed slots costs one round rather than
one per trigger; a full round re-claims immediately.
Slot accounting: a worker with max_concurrency slots may
hold at most that many rows locked at once, the claim sizes by the
slots actually free (queue emptiness minus jobs actively running),
not by queue emptiness alone, which allowed up to 2x
max_concurrency rows locked (a full local queue while every
consumer was busy still looked like max_concurrency free slots:
double reclaim exposure on a crash, and head-of-line latency behind
long jobs while peer workers idle). The get()-to-register window is
counted through the claim intent the consumer loop records at the
take, so a consumer paused in DI resolution or a denied-admission
local retry is still a held slot to this arithmetic; the
completion-side slot frees at the consumer's deregister, and
the wake the consumer sets there re-arms this producer the moment
accounting settles, no claim waits for the next poll tick.
Exits cleanly when either shutdown_event or producer_stop_event
is set.
slot_freed_event is set by the consumer loops at the two
slot-release points, when a local_queue.get() drains a queue
slot, and when a job's active_jobs registration ends; the
bootstrap wires one shared event into this loop and every consumer.
rng supplies the
fallback-poll jitter (a test seam; production uses the module RNG
seeded per process). Both default to standalone behaviour: a private
event nobody sets degrades the slot-refill wait to the fallback
cadence, and the module RNG jitters as in production.
Source code in src/taskq/worker/run.py
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 | |
producer_loop_stub
async
¶
producer_loop_stub(
deps: WorkerDeps,
local_queue: Queue[JobRow],
shutdown_event: Event,
producer_stop_event: Event,
*,
backend: Backend,
worker_id: UUID,
) -> None
Observe producer_stop_event and shutdown_event; exit cleanly.
Why the unused parameters: this is a drop-in stand-in for
:func:producer_loop (used when a worker runs consume-only), so it
takes that function's signature verbatim and the caller can swap one
for the other without a shim. deps, local_queue, backend
and worker_id are exactly the arguments it does not need because
it never dispatches, a divergent signature here would move that
branch into every call site.
Outer loop: while not (producer_stop_event.is_set() or shutdown_event.is_set()).
Body races producer_stop_event.wait() against shutdown_event.wait() via
asyncio.wait(..., return_when=FIRST_COMPLETED); the loser is cancelled
to avoid a pending-task leak.
Source code in src/taskq/worker/run.py
consumer_loop_stub
async
¶
consumer_loop_stub(
deps: WorkerDeps,
local_queue: Queue[JobRow],
shutdown_event: Event,
*,
backend: Backend,
worker_id: UUID,
stub_work_timeout: float = 60.0,
slot_freed_event: Event | None = None,
) -> None
Pull one job per iteration, register, sleep sentinel, write terminal status.
Outer loop: while not shutdown_event.is_set().
Races local_queue.get() against shutdown_event.wait(); on shutdown
win the queue waiter is cancelled and the stub returns cleanly.
On job get the stub registers in deps.active_jobs, awaits a cancellable
sentinel, writes terminal state via backend (shielded), and deregisters
in finally.
Source code in src/taskq/worker/run.py
640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 | |
di_consumer_loop
async
¶
di_consumer_loop(
deps: WorkerDeps,
local_queue: Queue[JobRow],
shutdown_event: Event,
*,
backend: Backend,
worker_id: UUID,
registry: ProviderRegistry,
process_scope: ProcessScope,
thread_scope: ThreadScope,
loop_scope: LoopScope,
actor_registry: Mapping[str, ActorRef[Any, Any]],
enqueuer: SubJobEnqueuer,
slot_freed_event: Event | None = None,
) -> None
Pull one job per iteration and dispatch via dispatch_one_job.
Outer loop: while not shutdown_event.is_set().
Races local_queue.get() against shutdown_event.wait(); on shutdown
win the queue waiter is cancelled and the loop returns cleanly.
Each job is dispatched through dispatch_one_job which composes build_actor_scope + consume_one_job, providing DI-aware actor invocation with per-invocation TRANSIENT scope teardown.
Source code in src/taskq/worker/run.py
804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 | |
register_worker
async
¶
Register the current worker in taskq.workers and return its UUID.
Generates a UUIDv7, inserts a row into {schema}.workers, and returns
the new UUID. Acquires from pool with a 2.0 s timeout; on timeout or
connection error the failure is logged and re-raised (registering is
fatal).
If settings.worker_label or settings.workgroup_instance are set,
they are stored directly for cross-process correlation and health checking.
The row's metadata records the worker's runtime facts: whether NOTIFY
dispatch is enabled, and max_concurrency, the capacity the worker
runs at, which sizes local_queue and bounds every dispatch.
Source code in src/taskq/worker/run.py
deregister_worker
async
¶
Remove the worker row from {schema}.workers (best-effort).
Acquires from pool with a 2.0 s timeout. On timeout or connection
error, logs a structured warning deregister_worker_failed and
reports the failure WITHOUT raising: shutdown MUST NOT block on this
cleanup, and the caller's backstop (the deps-teardown retry, then the
recovery sweep) decides what a still-unknown row fate means.
Returns the DELETE's outcome: True when the row is confirmed gone
(the DELETE executed and reported rowcount > 0), False when the
delete is NOT confirmed, either because a transient failure was
swallowed here (the dominant class: pool-acquire timeout, conn loss;
the row's fate is unknown) or because the DELETE matched no row (the
sweep or a peer already removed it, so there was nothing to delete).
A non-transient failure still raises: it would reproduce identically
in any retry, so reporting it as transient-shaped False would
livelock the backstop while logging like a blip.
Source code in src/taskq/worker/run.py
Maintenance leader (election, sweeps, cron, prune/archive)¶
leader ¶
Maintenance leader: election, watchdog, and recovery sweeps.
A single elected leader per cluster runs cooperative loops inside one
asyncio.TaskGroup: election, watchdog, scheduled-wake (sweep 3), cron,
sweep (sweeps 1/2/4), prune (sweep 5), archive expiry (sweep 6), stale
worker cleanup, queue depth, reservation slots, and backlog detection.
Non-leader pods retry election periodically and skip the gated work ,
the backlog detector is the deliberate exception (every worker samples;
see _backlog_detection_loop).
Leadership is a row lease in maintenance_leader: the holder writes an
expires_at it chose from leader_lease and renews it every heartbeat
interval, and any pod may take the row over once that instant has passed.
The row alone decides the election in every state, held, lapsed, or
absent, so nothing about the role depends on the liveness of a
connection: a holder that dies without a FIN is replaced on a horizon
TaskQ controls rather than the server's connection-reaping schedule, and
the replacement needs no privilege beyond UPDATE on that row. The
schema-qualified advisory lock is still taken after winning, as a courtesy
to pods from releases that only understand the lock; it is never required,
never waited on, and its absence never gates or costs the role.
Failover SLA
Graceful stop sub-second when the wake lands: the resign is the FIRST act of the shutdown (the shutdown-ordering contract below: the handover happens at shutdown START, not after the drain, so draining adds no latency to the handover), it deletes the row and then broadcasts on the leadership wake channel, a HINT (the row and the fence stay the authority) that makes a follower re-run the fenced elect after at most leader_wake_jitter of damping. The wake is never the guarantee: a lost, duplicated, malformed, or disabled broadcast, and a killed leader (that can notify no one), degrades to the plain bound ≤ heartbeat_interval + one round trip (the resign deletes the row; the next election wins it - the bound the hint degrades to, and the crash bound)
Worker killed ≤ leader_lease + heartbeat_interval + one round trip
Silent leader ≤ leader_lease + heartbeat_interval + one round trip
Won, unassumable ≤ leader_lease + heartbeat_interval + one failing
cycle's connection attempts (each bounded by
reload_factory_timeout; the trust-spent hand-back
deletes the row: the lapse backstop cannot fire
while sub-lease re-wins keep refreshing it; see
_hand_back_unassumable_lease)
Partition detect ≤ watchdog_interval + heartbeat_interval + 2 s
PG failover ≤ heartbeat_interval
Watchdog detect ≤ watchdog_interval + heartbeat_interval
Shutdown ordering contract (the no-leadership-while-stopping invariant): a worker that is shutting down NEVER wins, holds, or renews the leader lease, and a leader that begins shutting down hands the lease over at shutdown START, so a rolling deploy never has a leaderless window and never sees a dying pod win or hold leadership.
orchestrate_shutdownsetsdeps.shutdown_start_eventBEFORE the DRAINING phase touches any row. That event is the stop signal this runtime obeys, earlier and narrower than the worker-wideshutdown_event, which does not fire until every phase has run.- The election loop parks the moment the stop signal is observed: no
attempts and no renewals for the rest of the shutdown, however long
the drain runs. It drops its liveness registration when it parks
(detector 2 must not trip on a loop that stopped by design) and
ignores the resign-broadcast wake (:meth:
wake_electionno-ops). - A pod that is LEADING when the stop lands hands the lease over at
shutdown START: demote first (the flag drops before any await), then
the fenced resign over
leader_conn, which is still open - the orchestrator closes it only after the phases. The successor's election tick (or the broadcast wake) elects while THIS pod is still draining its jobs. The teardown resign inrun()'s finally remains as the backstop for exits that never run the orchestrator (a sibling crash, a bare cancel of_main). - An elect statement that was in flight when the stop landed and WON
anyway is handed straight back: fenced resign over the conn the
elect just used, and the assume path never runs - the monitor/cron
conns do not open,
lead()never sets the flag, so no leader-gated loop, sweep or cron tick included, can begin. - The drain does not depend on this pod remaining leader: every phase
of
orchestrate_shutdownwrites through the dispatcher pool and the backend with no leadership premise, and the successor covers the maintenance sweeps from its first tick.
__all__
module-attribute
¶
__all__ = [
"ArchiveExpiryResult",
"MaintenanceLeader",
"PruneResult",
"_build_retention_per_status",
"_load_actor_retention_overrides",
"_schedule_utc_to_cron",
"archive_expiry_sweep",
"cleanup_stale_workers",
"complete_stale_batches",
"prune_terminal_jobs",
]
ArchiveExpiryResult
dataclass
¶
PruneResult
dataclass
¶
PruneResult(
total_deleted: int,
archived: int,
by_actor: dict[str, int],
by_status: dict[str, int],
cutoffs: dict[str, datetime],
duration_ms: int,
)
MaintenanceLeader ¶
MaintenanceLeader(
deps: WorkerDeps,
worker_id: UUID,
backend: Backend,
*,
clock: Clock,
rate_limit_registry: RateLimitRegistry | None = None,
actor_policies: Mapping[str, ActorFirePolicy]
| None = None,
cron_specs: Sequence[CronScheduleSpec] | None = None,
)
Elected leader that runs watchdog, sweeps, cron, and prune loops.
Source code in src/taskq/worker/leader.py
332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 | |
wake_election ¶
Request an immediate election attempt (the resign-broadcast seam).
A hint, never a command: the loop still runs every gate it runs
on its ordinary cadence. A worker whose shutdown has begun
IGNORES the wake - no attempt, no win, no lease - the shutdown
ordering contract in this module's doc header. Safe to call from
any task on this loop; the wake is consumed by the election
loop's park wait (_follower_park), which damps it by
leader_wake_jitter - and refuses it outright while stopping,
the same refusal this arm-time gate makes.
Source code in src/taskq/worker/leader.py
run
async
¶
Source code in src/taskq/worker/leader.py
resign
async
¶
Hand the lease back so a peer elects on its next cycle.
Fenced on the term, so a resign issued after a takeover cannot delete the successor's row. Best-effort by design: the lease lapses on its own, so a resign that cannot reach the database costs a replacement pod at most one lease rather than correctness.
Runs at run() teardown, AFTER the shutdown orchestrator has
already closed and nulled a TaskQ-owned leader_conn, so the
write rides the leader-owned monitor conn when the primary is gone.
Both conns are idle by then (the loops that used them have exited),
and the monitor conn is never the orchestrator's to close.
The fence prefers the live term and falls back to the last election
this pod won: a mid-run demotion clears deps.leader_term long
before teardown, and without the fallback this pod's own row, never
taken over, or the DELETE would fence it out, would sit until the
lease lapses while a replacement pod waits on it.
The other caller is _hand_back_unassumable_lease, mid-run and
on a pod that never led (leader_term is None, so the fallback
fence is the one that applies): it rides the leader conn the
winning elect just used, before that conn is dropped.
A resign that DELETED the row then broadcasts the vacancy on the
leadership wake channel (_emit_leader_wake), so a successor
assumes sub-second instead of on its next tick. The broadcast is
best-effort on top of a best-effort resign: it is a HINT, the
tick is the guarantee, and a broadcast that never lands costs
exactly what the pre-wake design cost.
Returns whether the resign actually DELETED the row. False is
the ordinary fenced no-op (a successor's row, or none left) and
every best-effort shape (no term to resign, no live conn to ride,
the write raising): the no-op and no-term/no-conn shapes are
silent by design (at teardown they are normal), while a raised
write carries its own leader-resign-failed WARN. A caller
whose policy REPORTS a hand-back must not do so on a False
here, which is why the trust-spent hand-back WARNs only on
a True.
Source code in src/taskq/worker/leader.py
938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 | |
archive_expiry_sweep
async
¶
archive_expiry_sweep(
conn: ConnLike,
*,
batch_size: int = DEFAULT_PRUNE_BATCH_SIZE,
schema: str = "taskq",
drain_gate: Callable[[], bool] | None = None,
statement_timeout_ms: int = DEFAULT_PRUNE_STATEMENT_TIMEOUT_MS,
sizer: SweepBatchSizer | None = None,
) -> ArchiveExpiryResult
Hard-delete expired jobs_archive rows, one bounded,
self-committing batch at a time.
Each batch is one _EXPIRY_CTE_SQL statement, committed before the
next runs, under the same server-side statement_timeout and
breaker semantics as :func:prune_terminal_jobs, see that
function's docstring and
:data:~taskq.constants.DEFAULT_PRUNE_STATEMENT_TIMEOUT_MS for the
timeout/breaker contract. drain_gate is called before every batch;
a False return stops the drain with the batches already
committed.
The POLICY FLOOR (hypertable deployments only): when the connected
server has a policy_retention job registered against
jobs_archive (:func:taskq.backend._retention_floor.retention_policy_floor,
probed ONCE per run — before the batch loop, never per batch — and
failing open to None on any probe error), rows older than the
policy's own drop_after horizon gain the AND finished_at >=
$floor conjunct and are left to the policy, which drops them
whole-chunk silently. THE BOUNDARY, stated honestly: OLDER than the
floor, the POLICY owns deletion (silent, chunk-granular,
watermark-blind — see the pinned boundary in
tests/test_timescale_retention_interplay.py); NEWER than it
(inside the window), this sweep owns deletion (row-exact on
expire_at, the stamp honored exactly inside the window — the
young chunk whose rows have passed expire_at while their chunk
survives is still this sweep's work, and remains so). The floor's
clock is the POLICY's clock
(finished_at, the chunk alignment column) and the floor's value
is the policy's OWN horizon (parsed from the registered config, not
re-derived), so a policy registered with a different interval than
the current setting still governs the aged end exactly. A row older
than the floor inside a young chunk is dropped when the chunk itself
ages past the boundary, at most one chunk interval late — chunk
granularity is the trade. On vanilla Postgres the probe fails open
to None and this sweep runs full-range, byte-identical to the
no-floor behavior.
Source code in src/taskq/worker/_leader_shared.py
954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 | |
cleanup_stale_workers
async
¶
cleanup_stale_workers(
conn: ConnLike,
*,
worker_id: UUID,
staleness: timedelta,
schema: str = "taskq",
batch_size: int = DEFAULT_EVENT_WRITER_BATCH_SIZE,
) -> int
Delete worker rows whose last_seen_at exceeds staleness, one
bounded batch per call.
One call deletes at most batch_size workers in one short
transaction; repeated calls drain the stale set a batch at a time.
The caller's worker_id is never deleted. Returns the number of
worker rows removed by this call. Worker-level cascade
(maintenance_leader, job_attempts) is handled by the DDL
ON DELETE clauses, no extra sweeping needed, and the window
bounds workers per call, which bounds that per-worker fan-out per
transaction: a whole-fleet crash drains in committed batches instead
of one transaction rewriting every stale worker's attempt history.
Source code in src/taskq/worker/_leader_shared.py
complete_stale_batches
async
¶
complete_stale_batches(
conn: ConnLike,
*,
schema: str = "taskq",
batch_size: int = DEFAULT_EVENT_WRITER_BATCH_SIZE,
) -> int
Safety net: mark active batches with zero non-terminal jobs as complete.
Covers batches whose completion hook was lost (consumer crash) and intentionally-empty batches (expected_size=0, no jobs at all). One call is one bounded batch: at most batch_size completions, committed, so the sweep loop drains the remainder one call per tick.
Source code in src/taskq/worker/_leader_shared.py
prune_terminal_jobs
async
¶
prune_terminal_jobs(
conn: ConnLike,
*,
retention_per_status: dict[str, timedelta],
archive_retention: timedelta,
batch_size: int = DEFAULT_PRUNE_BATCH_SIZE,
schema: str = "taskq",
actor_overrides: dict[str, timedelta] | None = None,
drain_gate: Callable[[], bool] | None = None,
statement_timeout_ms: int = DEFAULT_PRUNE_STATEMENT_TIMEOUT_MS,
sizer: SweepBatchSizer | None = None,
) -> PruneResult
Archive-and-delete terminal jobs past their retention, one bounded, self-committing batch at a time.
Each batch is one transaction of two statements (the bounded
candidate window, then the lock-bearing _ARCHIVE_CTE_SQL write:
jobs → jobs_archive, job_attempts → job_attempts_archive, jobs
DELETE), committed before the next batch runs. The batch runs under
a server-side statement_timeout (the
:data:~taskq.constants.DEFAULT_PRUNE_STATEMENT_TIMEOUT_MS
derivation), and when sizer is given its latched tier, not
batch_size, sizes every window, so a database that keeps aborting
batches is retried at a reduced tier instead of the same one.
drain_gate is called before every batch; a False return stops
the drain (committed batches stay committed; the remainder waits for
the next attempt). The prune loop passes a gate that returns False on
shutdown and ticks detector-2 liveness between batches.
The archive predicate's clock is the database's own
(statement_timestamp() in the candidate statement, re-verified
at lock time by the write statement's), and the reported cutoffs are
anchored to a database-side clock_timestamp() read, see the
"Anchored to the database clock" comment in the body below.
Source code in src/taskq/worker/_leader_shared.py
803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 | |
build_leader_lease_sql ¶
Render the elect / renew / resign statements for schema.
Validates schema against the canonical identifier regex before formatting; asyncpg cannot bind identifiers as parameters.
Source code in src/taskq/worker/leader.py
Dispatch¶
dispatch ¶
Dispatch per-job DI dispatch.
:func:dispatch_one_job composes :func:build_actor_scope with
:func:consume_one_job so the worker's per-job dispatch path is DI-aware:
actors with Annotated[T, Scope.X] parameters receive resolved instances
at dispatch time, scoped to their effective scope, with TRANSIENT teardown
running per invocation.
The dispatch SQL constants and the dispatch_batch asyncpg helper live
in :mod:taskq.backend._dispatch_sql (backend layer). This module
imports them from there since worker → backend is the correct layer
direction.
SlotPoolAcquireError ¶
Bases: Exception
A consumer slot could not obtain a usable transaction connection.
Infrastructure, not a job outcome: the job is already claimed and
recovers by lock-lease expiry, so the consumer loop must not count
this as a job failure. The bounded acquire (or slot-connection init
hook) that raised is recorded and logged at the raise site; this type
exists so the loop can route it around the failure accounting. The
underlying cause stays chained (__cause__).
Source code in src/taskq/worker/dispatch.py
dispatch_one_job
async
¶
dispatch_one_job(
*,
backend: Backend,
deps: WorkerDeps,
job: JobRow,
worker_id: UUID,
registry: ProviderRegistry,
process_scope: ProcessScope,
thread_scope: ThreadScope,
loop_scope: LoopScope,
actor_ref: ActorRef[BaseModel, BaseModel | None],
actor_config: ActorConfigLike,
clock: Clock,
active_jobs: ActiveJobRegistry | None = None,
max_retry_backoff: timedelta = DEFAULT_MAX_RETRY_BACKOFF,
logger_arg: BoundLogger | None = None,
enqueuer: SubJobEnqueuer,
) -> AttemptOutcome
Dispatch one job through the DI-resolved actor scope.
- Acquire the job's transaction connection, from the worker's
dedicated slot pool when one is open (the per-slot path), else
the LOOP-scope connection, else none (autonomous consume). An
acquire failure raises :class:
SlotPoolAcquireErrorbefore any span or metric exists: infrastructure, not a job outcome. On the per-slot path the acquired connection also shadows the LOOP-registeredasyncpg.Connectionfor the actor invocation (loop_slot_values), so the actor's own writes join this job's transaction and concurrent slots never share a connection. - Create the CONSUMER span with link to the PRODUCER span.
- Resolve the registered :class:
~taskq.obs.ErrorReporter(if any) and validate the payload against actor_ref's payload schema. - Build the interim JobContext with the CONSUMER span.
- Open build_actor_scope to resolve DI kwargs.
- Hand the resolved kwargs to consume_one_job via a run_actor closure that injects them.
- TRANSIENT scope is closed on exit (regardless of outcome).
- Record consumer-path metrics outside the span body.
On the per-slot path the job's enqueuer is a per-job
:class:~taskq.client._enqueuer.SubJobEnqueuer bound to the
acquired connection, so transactional sub-enqueues and their
flush/discard lifecycle are this job's alone.
Source code in src/taskq/worker/dispatch.py
437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 | |
Heartbeat and cancellation¶
heartbeat ¶
Heartbeat loop and cancel-poll seam.
Each tick acquires one connection from heartbeat_pool, opens a single
transaction, and atomically extends workers.last_seen_at, jobs lock /
heartbeat columns, and reservation_slots leases for jobs locked by this
worker, except the jobs the worker has disowned (WorkerDeps.disowned_jobs:
finished with, outcome unrecordable), whose leases must lapse so the
reclaim sweep can hand them back. The tick's whole command sequence runs
under ONE command-timeout budget (see the tick block and
:func:_lease_renewal_threshold for the lease-model arithmetic that
depends on it), with a bounded rollback-or-close teardown. The jobs-lock
renewal is threshold-gated (:func:_lease_renewal_threshold): a row
whose lease is still comfortably fresh is left alone so a healthy beat
stops paying a non-HOT update per running row per tick, while
rows carrying a per-job heartbeat_timeout (the reclaim sweep's
heartbeat arm needs their beats fresh) and rows at/under the threshold
renew on every beat. After max_heartbeat_failures consecutive failed
ticks, transient connection failures and unexpected errors alike, with
the count reset only by a fully successful tick, isolate_self
proactively transitions running jobs and signals shutdown.
heartbeat_loop
async
¶
heartbeat_loop(
deps: WorkerDeps,
worker_id: UUID,
shutdown: Event,
*,
cancel_controller: CancelController | None = None,
cancel_wake_event: Event | None = None,
) -> None
Source code in src/taskq/worker/heartbeat.py
503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 | |
isolate_self
async
¶
Source code in src/taskq/worker/heartbeat.py
1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 | |
cancel ¶
Active-job tracking, cancel-poll hook factory.
This module implements ActiveJobRegistry, the loop-scoped in-process map
of running jobs, the _ActiveJob dataclass, and the CancelController
class that drives the five-phase cancel-poll loop.
CancelController exposes two methods that heartbeat_loop calls on every
tick:
-
run_in_tx(conn), runs inside the heartbeat transaction. Phases 1, 2, and the phase-3 eligibility check happen here. Phase-3 jobs are queued into_pending_abandonsrather than callingmark_abandoneddirectly, becausemark_abandoneduses a separate pool connection that would deadlock on the row lock that the heartbeat transaction still holds. -
run_post_tx(), called byheartbeat_loopAFTER the transaction block exits (and therefore after the transaction has committed and released its row locks). Drains_pending_abandons, callingmark_abandoned+ deregister for each entry.
Key correctness invariants ():
- _by_id mutations (register/deregister) are protected by asyncio.Lock.
- all() is synchronous: in asyncio's single-threaded model no other
coroutine can mutate _by_id between the list-copy and return unless
there is an intervening await. all() has no await, so the
copy is atomic from the event-loop perspective. The lock is NOT acquired
in all(), acquiring an asyncio.Lock requires await and would force
a coroutine boundary that breaks the atomicity guarantee.
- cancel_observed_at uses asyncio.get_running_loop().time() (monotonic
event-loop clock), never time.time() or datetime.now().
- Phase-2 PG write (conn.execute) happens BEFORE task.cancel() in
code order with NO intervening await (PG-first invariant).
- No try/except inside the phase-2 block, PG-write failures propagate
to heartbeat_loop's outer handler.
- run_post_tx is always called after run_in_tx on the same tick, even
when run_in_tx raises; heartbeat_loop must call it in a finally
block (or equivalent) to drain any entries queued before the error.
__all__
module-attribute
¶
CancelController ¶
Bases: Protocol
Structural interface for cancel-poll controllers.
heartbeat_loop calls run_in_tx inside the open heartbeat
transaction and run_post_tx immediately after the transaction commits.
Implementations must satisfy this two-phase contract.
The concrete production implementation is _CancelController, constructed
via make_cancel_controller. Test stubs need only implement these two
methods to satisfy the type.
run_in_tx
async
¶
ClaimIntent
dataclass
¶
The token a claim take hands back to the taker.
Pure identity: one instance per mark_claimed call, held by the
loop iteration that took the row and returned to resolve_claim at
exit, so the resolver drops only ITS OWN claim (the issue-461 class:
the same key can be re-marked by a later generation's take before the
stale generation unwinds). Deliberately empty, the value is the
object identity alone.
ActiveJobRegistry ¶
Loop-scoped in-process map of running jobs on this worker.
One instance per worker process, constructed in _main() / WorkerDeps
before the TaskGroup is entered. Multiple workers in the same process (test
scenarios) each carry their own independent registry.
Thread-safety: not applicable, asyncio workers are single-threaded. The
asyncio.Lock on _by_id prevents interleaving between coroutines that
await register / await deregister while the heartbeat or consumer is
also running.
Public surface per
register(job_id, task, ctx) -> _ActiveJob(async)deregister(job_id, entry) -> None(async, identity-scoped)get(job_id) -> _ActiveJob | None(sync)all() -> list[_ActiveJob](sync snapshot copy)count() -> int(sync)
Source code in src/taskq/worker/cancel.py
mark_enqueued ¶
Record a claim the producer handed to the local queue.
Called by the producer before the queue put (the put is the first await after the claim's return, and the mark must precede any await a hand-back pass could interleave at).
Source code in src/taskq/worker/cancel.py
queued_ids ¶
mark_claimed ¶
Record a queue take before any await can let a drain observe the gap.
Must be called with no intervening await after the take: the single-threaded loop makes the record atomic with the take, which is the whole guarantee. The take also moves the row's coverage from the queued map to the intent map, so the claim-to-register chain never has an unfenced window.
Returns the claim token the caller must hand back to
resolve_claim at exit: the same key can be re-marked by a
later generation's take (a same-worker re-claim) before this
caller unwinds, and the token is the only identity that scopes
the resolve to this caller's own claim.
Source code in src/taskq/worker/cancel.py
resolve_claim ¶
Drop the claim intent once register covers it or the row is released.
Identity-scoped: the entry is removed only when the map's current
token IS the caller's. A stale generation's resolve (its take
superseded by a same-worker re-claim's new mark_claimed)
removes nothing, and the live claim's intent survives to fence
the hand-back passes for the window it exists to cover.
Source code in src/taskq/worker/cancel.py
held_ids ¶
Snapshot of every row this process may still execute: registered and intent.
Deliberately NOT the queued ids (queued_ids()): a row parked
in local_queue at a DRAINING exit must be re-pended by the exit
hand-back, which excludes this snapshot only — the hand-back
passes (drain, isolate) and the heartbeat's lost-claim probe
bind different exclusions on purpose, each the narrower of the
two its correctness needs.
Source code in src/taskq/worker/cancel.py
register
async
¶
Register a job as in-flight.
The lock ensures no concurrent deregister sees an inconsistent state.
The claim intent (if any) is absorbed: the registry now owns the row.
Returns the entry it installed. The caller must hand that entry back
to deregister at exit: the same key can be re-registered by a
later attempt of the same job (a lapsed lease re-claimed on this
worker), and the return value is the only identity that tells the
caller's own registration from the live attempt's.
Source code in src/taskq/worker/cancel.py
deregister
async
¶
Remove a job from the registry (idempotent, ignores missing keys).
Identity-scoped: the pop happens only when the map's current entry
IS the entry this caller registered (or holds from get()).
A same-worker re-claim re-registers the same key with a NEW entry
(the later attempt's), so a stale attempt's exit removes only its
own registration and the live attempt's survives. The bare-id pop
this replaces evicted the live attempt's registration behind every
defence that reads held_ids() (issue 461). This is the same
fence taskq.progress._flush._drop_fenced_out_buffer applies to
the progress buffers, the one other same-keyed map whose entries
are per-attempt.
Source code in src/taskq/worker/cancel.py
get ¶
Return the registry entry for job_id, or None if absent.
Synchronous and lock-free: safe to call from the heartbeat hook or any non-mutating code path between awaits.
Source code in src/taskq/worker/cancel.py
all ¶
Return a snapshot copy of all in-flight entries.
Synchronous: in asyncio's cooperative multitasking, no other coroutine
can mutate _by_id while this method runs (there is no await
between list(...) and return). The copy ensures the caller's
for loop cannot raise RuntimeError: dictionary changed size during
iteration even if register/deregister are called in later
coroutine steps.
Callers that need a fresh count after iterating must call count()
separately.
Source code in src/taskq/worker/cancel.py
count ¶
intent_count ¶
Return the number of takes not yet absorbed by a registration.
The claim-to-register window (the consumer's DI resolution, payload
validation, and admission acquire all run before register) is
invisible to count(), and that window is where a denied
admission's bounded local retry now waits, so a producer sizing a
claim by registered jobs alone can double-claim every slot its
consumers are already holding. The intents are disjoint from both
the queued ids (mark_claimed moves the row out of _queued)
and the registered entries (register absorbs the intent), so
count() + intent_count() is the exact in-process hold.
Source code in src/taskq/worker/cancel.py
make_cancel_controller ¶
Construct a CancelController for the given worker.
Validates schema_name eagerly (before any ticks run) so misconfiguration
is surfaced at startup rather than on the first heartbeat.
Source code in src/taskq/worker/cancel.py
Shutdown¶
shutdown ¶
Two-phase shutdown orchestration.
Consumers (orchestrate_shutdown, health endpoints) MUST observe
deps.shutdown_phase set at the START of each phase, BEFORE any
per-phase work. Value NONE (0) means the worker is running normally.
Phase ordering invariant: NONE (0) → DRAINING (1) → CANCELLING (2) → FORCING (3) → RELEASING (4).
The early handover: shutdown_start_event fires before phase 1
touches any row, and the leader runtime's election loop obeys it (park,
no attempts; a leader resigns at shutdown START, before the drain -
leader.py's shutdown-ordering contract). The worker-wide
shutdown_event does not fire until the phases complete; nothing in
the phases below reads leadership state.
SIGQUIT is not registered; produces a core dump on Linux. Use tini or
ulimit -c 0 for containerised deployments.
The second-SIGTERM contract: if the second SIGTERM arrives during
FORCING or RELEASING, setting escalate_event is a no-op, the
orchestrator is already past CANCELLING.
What the phases owe the work: a deploy is an infrastructure event, so it
never terminalises a job. Rows claimed but
never started are handed back at DRAINING (attempt refunded; the producer
repeats the hand-back on exit so a claim round in flight at the signal is
caught too). Rows mid-execution get the cooperative cancel at CANCELLING
and the forced cancel at FORCING; an actor that unwinds is interrupted ,
released back to the fleet, the spent attempt standing; and one still
alive past
both graces is interrupted with a hold at RELEASING (released only once
the process is provably gone). abandoned is never a shutdown verdict:
an operator-cancelled row the process dies with is terminalised with the
operator's OWN verdict (mark_cancelled, forced) at RELEASING - the
same write its unwinding consumer races - never relabelled abandoned.
__all__
module-attribute
¶
__all__ = [
"ShutdownPhase",
"drain_local_queue_to_pending",
"install_signal_handlers",
"orchestrate_shutdown",
]
ShutdownPhase ¶
Bases: IntEnum
Worker shutdown phase.
NONE , running normally.
DRAINING , stop accepting new dispatch, hand back claimed-but-unstarted
jobs (attempt refunded).
CANCELLING, cooperative cancel of remaining jobs; stamps the shutdown
origin on each signalled job.
FORCING , force-cancel grace, terminal writes shielded; the cancel
escalation write doubles as the origin probe (it lands only
on rows already carrying an operator's cancel request).
RELEASING , release jobs whose actors never unwound back to the fleet
(mark_interrupted: the spent attempt stands, held
until this process is provably gone); jobs under an
operator cancel are terminalised with the operator's own
verdict (mark_cancelled, forced) here.
The value 4 was ABANDONING before the release phase stopped
abandoning, the integer is unchanged, so /health JSON and the CLI
table keep their numbers; only the label moved to what the phase does.
drain_local_queue_to_pending
async
¶
Re-pend every job this worker claimed but never started.
Issues a single bounded-timeout UPDATE that clears the lock on rows
where locked_by_worker = $worker_id AND status = 'running',
excluding the jobs with live consumers (deps.active_jobs):
CANCELLING owns those, and re-pending one would unlock a row
another worker can claim while its consumer still executes it. On
pool exhaustion or connection error the helper logs a warning and
returns 0 so the recovery sweep acts as the backstop rather than a
deadlocked shutdown.
Two callers, one pass each, both on this worker's way out: the
orchestrator's DRAINING phase, and the producer loop's own exit (a
claim round in flight when the stop event landed commits after the
DRAINING pass; the producer's exit pass is the one write that cannot
be overtaken by this worker's next claim, because there is none).
Both refund the claim's attempt increment through the shared
ATTEMPT_REFUND_SQL fragment, a claim that never reached an
actor bought nothing, so it spends nothing (the same idiom the
snooze arms carry; the refund is floored at 0
and a second pass matches no rows, so the two passes together are
exactly-once). mark_interrupted is the deliberate exception: its
attempt DID start executing, so it keeps the increment.
Why the started_at IS NOT NULL conjunct: the dispatch claim CTE
stamps started_at = clock_timestamp() AT CLAIM
(backend/_dispatch_sql.py), so every row the drain exists for is
stamped and the conjunct never fences a claimed-but-unstarted row.
What it fences is the OTHER refund writer's output: the heartbeat's
claim-loss reconcile (worker/heartbeat.py) refunds through the same
fragment and UN-STAMPS started_at while leaving the row
running and locked (Sweep 1 owns that reclaim). On a
running-and-locked row a NULL started_at means exactly "this
claim was already refunded", so without the conjunct the drain
re-pends the reconciled row and refunds the SAME claim a second
time: the counter drops below the epoch a genuine execution's
job_attempts row holds, and the row is yanked out of the
Sweep-1 reclaim the reconcile preserved. The conjunct is a
row-version check, not a memory snapshot, so it holds on the
concurrent interleave too: the drain's UPDATE blocking on the
reconcile's open transaction re-evaluates the row when the
reconcile commits (EvalPlanQual) and the committed NULL stamp
drops it from the write set. The refund is exactly-once per claim
across both writers.
The disowned exclusion: a row this worker DISOWNED (a terminal write that failed on infrastructure, a slot-pool acquire failure) is not this helper's population. The disown arms cover attempts that may have STARTED executing, and refunding a started attempt re-creates the exact epoch the first execution ran under - the successor's re-claim would re-run the body at the SAME attempt number. The row's recovery stays where the disown arms put it: the lease lapses unrenewed and Sweep 1 reclaims at the charged attempt (the reconcile's documented owner).
The DB row carries no
"a consumer took it" mark, so the only honest discriminator for
"never started" is this process's own active-jobs registry; the
claim-to-register window (a job taken off local_queue but not yet
in active_jobs) is invisible to every shutdown arm, CANCELLING
iterates the same registry, and stays outside this predicate's
guarantee.
Returns: Number of rows updated, or 0 on timeout / connection error.
Source code in src/taskq/worker/shutdown.py
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | |
orchestrate_shutdown
async
¶
orchestrate_shutdown(
deps: WorkerDeps,
settings: WorkerSettings,
worker_id: UUID,
shutdown_event: Event,
escalate_event: Event | None = None,
*,
backend: Backend,
) -> int
Run the four-phase shutdown orchestration.
Phases are DRAINING → CANCELLING → FORCING → RELEASING, followed by
TaskQ-owned leader_conn close and shutdown_event.set(). Each
phase is assigned to deps.shutdown_phase BEFORE any per-phase work.
Returns 0 on clean exit.
Source code in src/taskq/worker/shutdown.py
383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 | |
install_signal_handlers ¶
install_signal_handlers(
loop: AbstractEventLoop,
deps: WorkerDeps,
worker_id: UUID,
shutdown_event: Event,
escalate_event: Event,
backend: Backend,
orchestrator_holder: list[Task[int]],
) -> None
Register SIGTERM/SIGINT/SIGHUP handlers.
SIGTERM/SIGINT: three-signal escalation counter. First signal
schedules orchestrate_shutdown via loop.create_task and
appends the created task to orchestrator_holder so that _main
can later await it for the exit code. Second signal sets
escalate_event to fast-advance CANCELLING → FORCING. Third signal
calls sys.exit(1) (Kubernetes SIGKILL is the hard backstop).
SIGHUP: sets deps.reload_event to signal the hot-reload
coordinator (:func:~taskq.worker.deps.reload_credentials) that a
credential refresh has been requested. The coordinator runs as a
sibling task in the worker's TaskGroup and performs the actual
pool/connection swap. SIGHUP can be sent multiple times, each one
sets the event. The coordinator clears the event before each
reload and never after, so a SIGHUP arriving mid-reload (success OR
failure) is honored with exactly one follow-up reload: N signals
during one reload coalesce into one follow-up, not N. Reload
requests arriving while shutdown orchestration is in progress
(deps.shutdown_phase is not NONE) are skipped.
The signal counter is closure-scoped, each call to this function
creates a fresh, independent counter. The handler callable contains
zero await or I/O.
Source code in src/taskq/worker/shutdown.py
777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 | |
Drain (until-idle monitor and exit-code contract)¶
drain ¶
Drain monitor for until-idle mode.
When spawned as a sibling in the worker's TaskGroup, the drain monitor polls the backend for active jobs in the worker's subscribed queues. When the count stays zero for the settle window (and no jobs are active on this worker), the monitor triggers the normal graceful shutdown via orchestrate_shutdown, the same path SIGTERM takes.
Exit codes
0, all jobs succeeded (drain_failures == 0) 3, some jobs failed (drain_failures > 0) 4, max_runtime exceeded before drain completed
__all__
module-attribute
¶
__all__ = [
"EXIT_DRAIN_CLEAN",
"EXIT_DRAIN_TIMEOUT",
"EXIT_DRAIN_WITH_FAILURES",
"drain_monitor_loop",
]
drain_monitor_loop
async
¶
drain_monitor_loop(
deps: WorkerDeps,
settings: WorkerSettings,
worker_id: UUID,
shutdown_event: Event,
escalate_event: Event,
orchestrator_holder: list[Task[int]],
backend: Backend,
*,
idle_settle_window: float,
idle_poll_interval: float,
max_runtime: float | None,
) -> None
Monitor for queue drain and trigger graceful shutdown when idle.
Polls backend.count_active_jobs(queues) and deps.active_jobs.count() every idle_poll_interval. When both are zero, starts the settle timer. If still zero after idle_settle_window, triggers shutdown.
If max_runtime is set and exceeded, triggers shutdown with exit code 4.
Returns after creating the orchestrate_shutdown task. Spawns with may_return=True in the sibling spawner. Does NOT set shutdown_event , orchestrate_shutdown's finally block sets it at the correct point (after all phase work completes), exactly as the SIGTERM signal handler does.
Forgets the liveness registration on every exit path (same contract as the producer loop, run.py): the monitor stops ticking the moment it returns, while the watchdog keeps sweeping until the orchestration's finally sets shutdown_event.
Double-orchestration guard (H2): if orchestrator_holder is already non-empty or deps.shutdown_phase is not ShutdownPhase.NONE, the monitor skips triggering, a SIGTERM-driven orchestration is already in progress.
Source code in src/taskq/worker/drain.py
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | |
Workgroup supervisor¶
workgroup ¶
Workgroup supervisor, spawns and manages multiple TaskQ worker processes.
A workgroup is a single-process orchestrator that manages N child taskq worker
subprocesses, each with potentially different queues, poll intervals, concurrency
caps, etc. The supervisor restarts children that crash, optionally health-checks
them via the database, and cleanly propagates shutdown signals.
Config format (TOML)::
actors = "myapp.actors:registry"
[defaults]
poll_interval = 1.0
max_concurrency = 4
[supervisor]
shutdown_grace = 100.0
backoff_initial = 0.5
backoff_max = 30.0
backoff_factor = 2.0
burst_limit = 10
burst_window = 60.0
[[workers]]
name = "api"
queues = ["default"]
max_concurrency = 8
poll_interval = 0.5
pg_credential_provider = "infra.identity:pg_credentials"
[workers.health]
enabled = true
check_interval = 15
stale_after = 60
startup_grace = 15.0
consecutive_failure_limit = 3
A pg_credential_provider set at the [defaults] level applies to
every worker, and TOML has no per-worker null, a worker cannot opt
out of a defaults-level provider, so a workgroup mixing provider-backed
and provider-less workers must set the field per worker instead of in
[defaults].
__all__
module-attribute
¶
__all__ = [
"DEFAULT_SHUTDOWN_GRACE_SECS",
"WorkerSpec",
"WorkgroupConfig",
"load_workgroup_config",
"run_forever",
"worst_case_shutdown_seconds",
]
DEFAULT_SHUTDOWN_GRACE_SECS
module-attribute
¶
Default for [supervisor].shutdown_grace, the supervisor's
SIGTERM-to-SIGKILL window for its children.
Why 100.0: the window must cover a child's modelled worst-case clean
exit, WorkerSettings.worst_case_shutdown_seconds = cancellation
grace (30s) + cleanup grace (10s) + the bounded-close teardown tail
(8 sequential closes x 5s + 2s publish drain = 42s) = 82s, plus ~22%
margin for loop jitter and the shutdown watchdog's non-instantaneous
deadline trip (the dump interval and bounded metrics flush land inside
the tail, but the tail is a model, not a bound the supervisor enforces).
The old 30.0 default sat below even the children's 40s release floor
(cancellation + cleanup graces): the supervisor SIGKILLed children
before a held release could land, and every interrupted job rode the
lease-expiry crash path instead. Operators who want the tighter,
release-floor-only window can still set it explicitly; the startup
warning names the numbers rather than refusing.
SupervisorConfig
dataclass
¶
SupervisorConfig(
shutdown_grace: float = DEFAULT_SHUTDOWN_GRACE_SECS,
backoff_initial: float = 0.5,
backoff_max: float = 30.0,
backoff_factor: float = 2.0,
burst_limit: int = 10,
burst_window: float = 60.0,
health_pg_dsn: str | None = None,
health_pg_schema: str | None = None,
)
Global supervisor behaviour tunables.
shutdown_grace
class-attribute
instance-attribute
¶
WorkerHealthConfig
dataclass
¶
WorkerHealthConfig(
enabled: bool = False,
check_interval: float = 15.0,
stale_after: float = 60.0,
startup_grace: float = 15.0,
consecutive_failure_limit: int = 3,
)
Per-worker health-check configuration.
The freshness verdict is server-side and exclusive at the boundary:
a worker whose server-measured age equals stale_after exactly is
already stale (the predicate is last_seen_at > now() - stale_after,
strict >).
WorkerSpec
dataclass
¶
WorkerSpec(
name: str,
queues: list[str],
poll_interval: float = 1.0,
max_concurrency: int = 8,
worker_group: str = "default",
force_update_actor_config: bool = False,
pg_credential_provider: str | None = None,
stream_limit: int = _STREAM_LIMIT,
health: WorkerHealthConfig = WorkerHealthConfig(),
)
Configuration for a single worker process managed by the workgroup.
force_update_actor_config
class-attribute
instance-attribute
¶
pg_credential_provider
class-attribute
instance-attribute
¶
health
class-attribute
instance-attribute
¶
cli_args ¶
Build the CLI argument list for this worker.
Source code in src/taskq/worker/workgroup.py
WorkgroupConfig
dataclass
¶
WorkgroupConfig(
actors: str,
supervisor: SupervisorConfig = SupervisorConfig(),
workers: list[WorkerSpec] = list[WorkerSpec](),
defaults: dict[str, Any] = dict[str, Any](),
)
Top-level workgroup configuration loaded from TOML.
supervisor
class-attribute
instance-attribute
¶
workers
class-attribute
instance-attribute
¶
defaults
class-attribute
instance-attribute
¶
from_toml
classmethod
¶
Load and validate the workgroup TOML configuration.
Source code in src/taskq/worker/workgroup.py
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 | |
load_workgroup_config ¶
Load a workgroup configuration from a TOML file.
Source code in src/taskq/worker/workgroup.py
worst_case_shutdown_seconds ¶
The children's modelled worst-case wall clock from SIGTERM to exit.
Delegates to WorkerSettings.worst_case_shutdown_seconds (cancellation
grace + cleanup grace + the bounded-close teardown tail) so the
supervisor surface and the worker's own model stay one number, no
drift. Exposed here so taskq workgroup validate and operators'
sizing scripts can compare a config's shutdown_grace against the
floor it must cover, without importing worker bootstrap internals.
Source code in src/taskq/worker/workgroup.py
run_forever
async
¶
Load config, spawn children, manage lifecycle until a signal arrives.
Blocks until SIGTERM or SIGINT, then shuts down all children gracefully.
SIGHUP is NOT a shutdown signal: it is forwarded to every living child
so each child's credential hot-reload runs (see
:func:_forward_sighup_to_children); without a handler the default
disposition would terminate the supervisor and orphan every child.
Returns normally after shutdown, the CLI caller handles the exit code.
Source code in src/taskq/worker/workgroup.py
1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 | |