Skip to content

Add --threads to run several tasks at once inside a worker - #61

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

tokibito merged 1 commit into
mainfrom
threads-option

Conversation

@tokibito

@tokibito tokibito commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Second step of #25, on top of the claim_task() / run_claimed_task() split from #60. --threads M runs M tasks at once inside one worker process.

What it does

The worker stays one process with one polling thread. That thread still does what it did: it fetches from the database, receives from the broker and handles the signals. What changes is that the task it found goes to one of M executor threads instead of being run inline, so M tasks are in progress while the polling load stays at one fetch per `--interval`.

  • For a task fetched from the database the polling thread claims it (`claim_task()` from Split run_task() into claim_task() and run_claimed_task() #60) with the executor's worker id before handing it over, so its next `fetch_task()` never returns the same READY row. The executor runs `run_claimed_task()`.
  • A broker receive asks for no more messages than there are idle executors, up to `--max-messages`; each message goes to an executor, which runs and acknowledges it as before.
  • Each executor has a worker id of its own, `-t1` to `-tM`, recorded on the tasks it runs and in the log fields, and a database connection of its own (Django keeps them per thread). It calls `close_old_connections()` after every task, the way a request does, so `CONN_MAX_AGE` applies.
  • `--max-tasks` counts the tasks handed out, so no more than that many are ever started. Failures are counted across the threads under a lock. On `SIGTERM` no more tasks are handed out and the ones in the threads finish; `--shutdown-timeout` works as before through the `GracefulShutdown` timer.
  • The pool is `django_database_task/threads.py`: idle executors are taken one at a time by the polling thread, which is the only one that fetches, claims and collects; executors report back through a queue. A job that raises, including `SystemExit` from task code, ends the job and not the thread, so a slot is never lost.
  • `--workers N --threads M` combine: N processes with M threads each.

Two things to look at

  • SQLite refuses several threads, with the same check and message shape as `--workers`. The claims for database tasks are serialised in the polling thread, but the broker path claims inside `run_task_by_id()` in the executor, and the finishing writes come from several threads; on SQLite's shared-cache in-memory database the first run of the tests hit `database table is locked` on exactly that. Since SQLite is documented as single-worker, refusing is simpler than carving out the paths that happen to work.
  • `--threads 1` is the old loop. The loop gained the dispatch branch and the counting helpers, but with no executors every branch is the inline one it was; the existing command tests pass unmodified. I did keep the `--max-messages` cap from being narrowed to one in the inline path, which the existing broker tests caught.

Documentation

The README's Running several workers gains a third row in the tiers table, a `--threads M` section (thread safety is on the task code, I/O-bound only, memory but not connections, worker ids, shutdown and the cap, combining with `--workers`, SQLite), and a Threads instead bullet in Trade-offs. The help string is translated in the four catalogues. The CHANGELOG entry is under Unreleased; the default is 1, so existing projects need no changes.

Tests

  • `tests/test_threads.py`: the pool on its own, without a database.
  • `tests/test_commands.py`: the option's validation, that `--threads 1` is the worker itself (no `-t` suffix, no banner line), and the SQLite refusal.
  • `tests/postgres/test_threads.py`: the threads at work against PostgreSQL: three tasks that can only finish together (a barrier), one worker id per thread, `--max-tasks` leaving the rest READY, failures counted across threads, output order, broker messages spread over the threads and the receive capped by the idle threads, and a signal letting the running tasks finish while starting no more.

Run on Python 3.13 / Django 6.0 (SQLite), Python 3.14 / Django 6.1 with `-W error::DeprecationWarning`, and the whole suite against PostgreSQL 16.

Refs #25

Base automatically changed from split-run-task to main October 2, 2026 05:16
The worker stays one process with one polling thread, which still fetches,
receives from the broker and handles the signals; the task it found is
handed to one of M executor threads instead of being run inline. The claim
is made by the polling thread with the executor's worker id, so the next
fetch never hands out the same row, and a broker receive asks for no more
messages than there are idle threads. Each thread records its own id on
the tasks it runs, has a database connection of its own and closes old
connections after every task. --max-tasks counts the tasks handed out,
failures are counted across the threads under a lock, and on SIGTERM the
tasks in the threads finish before the worker exits.

Several threads are refused on SQLite, as several workers are: claims from
the broker path and finishing writes from several threads collide on the
database lock. The default of 1 leaves the loop as it was.

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