Skip to content

Add --workers to run several worker processes from one command - #58

Merged
tokibito merged 1 commit into
mainfrom
workers-option
Oct 2, 2026
Merged

tokibito merged 1 commit into
mainfrom
workers-option

Conversation

@tokibito

@tokibito tokibito commented Oct 2, 2026

Copy link
Copy Markdown
Owner

First step of #25: --workers N on run_database_tasks. --threads M and 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 N turns the command into a small supervisor in the shape of gunicorn's arbiter. It starts N copies of itself with --workers left 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 own Worker started / Worker finished records. The supervisor runs no tasks and opens no database connection.

  • With --continuous a 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 --continuous each worker drains the queue once and the command exits when all of them have.
  • SIGTERM and SIGINT (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-shutdown they are terminated on the first signal.
  • On POSIX, SIGTTIN adds a worker and SIGTTOU removes the newest one; the last worker is kept.
  • The exit code is decided by the supervisor from what the workers reported: --failed-exit-code if any worker exited with it, --empty-exit-code if every worker did, a worker's own code if one crashed (128 + signal for one killed by a signal), 0 otherwise.
  • --max-tasks reaches each worker, so with --continuous it is gunicorn's max_requests.
  • No liveness check: a worker in a long task and a stuck one look the same from outside, so that stays with requeue_stale_database_tasks.

The children are subprocess children, 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=0 on POSIX, CREATE_NEW_PROCESS_GROUP on 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 from django.utils.autoreload.get_child_arguments(), which already handles manage.py, python -m django, console scripts and the interpreter's -W/-X flags.

The supervisor is django_database_task/supervisor.py, depending on nothing in the package beyond shutdown.py, so django-tasks-redis can ship the same module the way it ships shutdown.py.

Two calls the issue left open

  • SQLite refuses --workers above 1 rather than warning. A smoke run with two workers on a SQLite file showed the loser of a claim getting database is locked out of _claim_task and 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.
  • The option is --workers, from the issue titles. The Redis issue still lists --workers versus --concurrency as 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.py drives 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 real SIGTERM/SIGTTIN/SIGTTOU handlers with their restoration.
  • tests/test_commands.py covers what the command checks before starting the supervisor (--workers value, 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.py runs the real command with --workers 2 against the PostgreSQL test database: two slow tasks are RUNNING at the same time before SIGTERM, both finish SUCCESSFUL, 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_EXIT as a stopped worker) is written for the Windows CI job but was not run locally.

Refs #25

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
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