Repository navigation
Add --workers to run several worker processes from one command - #58
Merged
Merged
Conversation
run_database_tasks is one process running one task at a time, and running more of them has been left to whatever starts the process. --workers N puts a small supervisor in the command, in the shape of gunicorn's arbiter: it starts N copies of the command with --workers left off, so each worker is the single-process worker it has always been, replaces a worker that exits when --continuous is set (with a growing delay for one that keeps failing right after starting), forwards SIGTERM/SIGINT (and Ctrl-Break on Windows) to the workers and waits for them within --shutdown-timeout, kills them on a second signal, adds and removes a worker on SIGTTIN/SIGTTOU, and decides the exit code from what the workers reported. The children are subprocess children rather than forks, so nothing the parent opened is shared and the same code runs on Windows; they are placed in a process group of their own so that a Ctrl-C at the terminal reaches the supervisor alone. Several workers are refused on SQLite: a task is claimed with a conditional UPDATE, so nothing runs twice, but without row locks two workers claiming at once collide on the database lock and the loser counts a failed task. The supervisor is a module of its own with no dependency on the package beyond shutdown.py, so that django-tasks-redis can ship the same one. The default of 1 is the single process the command has always been. Refs #25
tokibito
added a commit
that referenced
this pull request
Oct 2, 2026
This was referenced Oct 2, 2026
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.
First step of #25:
--workers Nonrun_database_tasks.--threads Mand the port to django-tasks-redis (tokibito/django-tasks-redis#44) are left for later, as the issue orders them.What it does
run_database_tasks --workers Nturns the command into a small supervisor in the shape of gunicorn's arbiter. It starts N copies of itself with--workersleft off and every other option as given, so each worker is exactly the single-process worker: its own worker id, its own database connection, its ownWorker started/Worker finishedrecords. The supervisor runs no tasks and opens no database connection.--continuousa worker that exits is replaced. One that exits with a nonzero code within ten seconds of starting is restarted after a growing delay (1 s doubling up to 30 s), so a misconfiguration does not spin. Without--continuouseach worker drains the queue once and the command exits when all of them have.SIGTERMandSIGINT(Ctrl-Break too on Windows) are handled by the supervisor and forwarded to every worker, which is waited for within--shutdown-timeout; what is still running past it is killed and the command exits 1. A second signal kills the workers at once. With--no-graceful-shutdownthey are terminated on the first signal.SIGTTINadds a worker andSIGTTOUremoves the newest one; the last worker is kept.--failed-exit-codeif any worker exited with it,--empty-exit-codeif every worker did, a worker's own code if one crashed (128 + signalfor one killed by a signal), 0 otherwise.--max-tasksreaches each worker, so with--continuousit is gunicorn'smax_requests.requeue_stale_database_tasks.The children are
subprocesschildren, not forks, so nothing the parent opened is shared and the same code runs on Windows. They are put in a process group of their own (process_group=0on POSIX,CREATE_NEW_PROCESS_GROUPon Windows): in the parent's group a Ctrl-C at the terminal would reach a child directly and again as the forwarded signal, and the child would treat the second as "force exit". The command line to re-run is taken fromdjango.utils.autoreload.get_child_arguments(), which already handlesmanage.py,python -m django, console scripts and the interpreter's-W/-Xflags.The supervisor is
django_database_task/supervisor.py, depending on nothing in the package beyondshutdown.py, so django-tasks-redis can ship the same module the way it shipsshutdown.py.Two calls the issue left open
--workersabove 1 rather than warning. A smoke run with two workers on a SQLite file showed the loser of a claim gettingdatabase is lockedout of_claim_taskand counting it as a failed task: nothing runs twice (the conditional UPDATE from fetch_task() releases the row lock before run_task() writes RUNNING, so two workers can run the same task #24 holds), but the result is misleading, and the README already says SQLite is for single-worker deployments.--workers, from the issue titles. The Redis issue still lists--workersversus--concurrencyas open; if it goes the other way, the other name can be added here as an alias rather than renaming.Compatibility
The default is 1, which is the single process the command has always been, with the same signals and exit codes. No option, default or log field changes. Existing projects need no changes.
Tests
tests/test_supervisor.pydrives the supervisor over small stub processes: start, exit codes, restart and back-off, signal forwarding and waiting, kill on timeout and on a second signal,--no-graceful-shutdown, add/remove, and (POSIX) the realSIGTERM/SIGTTIN/SIGTTOUhandlers with their restoration.tests/test_commands.pycovers what the command checks before starting the supervisor (--workersvalue, the other options checked once in the parent, the SQLite refusal) and what it passes on (exit codes, restart under--continuous), with the worker command line stubbed.tests/postgres/test_workers.pyruns the real command with--workers 2against the PostgreSQL test database: two slow tasks areRUNNINGat the same time beforeSIGTERM, both finishSUCCESSFUL, the run-once shape drains four tasks across two workers, and an idle run reports--empty-exit-code. It skips on SQLite like the other PostgreSQL modules.Run on Python 3.13 / Django 6.0 (SQLite), Python 3.14 / Django 6.1 with
-W error::DeprecationWarning, and against PostgreSQL 16. The Windows path (Ctrl-Break forwarding,STATUS_CONTROL_C_EXITas a stopped worker) is written for the Windows CI job but was not run locally.Refs #25