From 5822b36f56819e5b4b37284721971ee4b915d5e3 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 8 Oct 2026 00:15:00 +0200 Subject: [PATCH 1/2] Benchmark huey beside the other queues - benchmarks/huey_app.py hands echo tasks to the huey Redis storage, which keeps results in the huey.results hash the cleanup already names. - The consumer runs as one process with one thread, like the celery and dramatiq workers, and is stopped after a sentinel task proves the drain. - The footnote lists huey with the queues that read one message at a time. - The generated chart is left untouched; regenerate it with benchmarks/chart.py from a run on an idle machine. --- benchmarks/chart.py | 4 ++-- benchmarks/huey_app.py | 34 ++++++++++++++++++++++++++++ benchmarks/test_backends.py | 45 +++++++++++++++++++++++++++++++++++-- pyproject.toml | 1 + 4 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 benchmarks/huey_app.py diff --git a/benchmarks/chart.py b/benchmarks/chart.py index f89ec8f..f5ad3c2 100644 --- a/benchmarks/chart.py +++ b/benchmarks/chart.py @@ -221,10 +221,10 @@ def build_chart(results: list[QueueResult], theme: Theme) -> str: text( 28, footnote_y, - # joe: width checked by hand (right edge 856.1 of 900 at 11.5px); add a + # joe: width checked by hand (right edge 863 of 900 at 11.5px); add a # width guard if the canvas width or the font stack changes. "One process and one thread each. Threadmill, celery and dramatiq read " - "128 ahead; django-tasks-db and -rq read one message at a time.", + "128 ahead; django-tasks-db, -rq and huey read one task at a time.", theme=theme, size=11.5, fill=theme.faint, diff --git a/benchmarks/huey_app.py b/benchmarks/huey_app.py new file mode 100644 index 0000000..719b89e --- /dev/null +++ b/benchmarks/huey_app.py @@ -0,0 +1,34 @@ +"""The huey app the comparison benchmarks hand tasks to. + +The huey consumer CLI imports this module without setting up Django, so it must +not import Django or any Django application. The Redis storage keeps task +results in the ``huey.results.`` hash, the way the Celery app's result +backend keeps them in Redis. The benchmark cleanup's ``huey.*`` pattern deletes +the queue, the results, the schedule and the counters. +""" + +import os + +import redis +from huey import RedisHuey + +REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") + +PROCESSED_KEY = "benchmark:processed" +"""Key the sentinel task increments once every earlier task was processed.""" + +client = redis.Redis.from_url(REDIS_URL) + +huey_app = RedisHuey("threadmill_benchmark", results=True, url=REDIS_URL) + + +@huey_app.task() +def huey_echo(value): + """Return the given value.""" + return value + + +@huey_app.task() +def huey_mark_processed(): + """Record that every earlier task in the queue has been processed.""" + client.incr(PROCESSED_KEY) diff --git a/benchmarks/test_backends.py b/benchmarks/test_backends.py index 3fbbb57..b8cf81c 100644 --- a/benchmarks/test_backends.py +++ b/benchmarks/test_backends.py @@ -31,7 +31,8 @@ django-tasks-db reads one task at a time, because its shipped worker does not expose a read-ahead setting. django-tasks-rq forks a work horse for each job, so -its drain includes that fork and it reads one task at a time too. +its drain includes that fork and it reads one task at a time too. huey blocks on +an empty queue, but pops one message at a time either way. Threadmill is measured twice, with 128 messages ahead and with one message at a time. The read-ahead cost can therefore be subtracted from both worker benchmarks. @@ -74,6 +75,7 @@ celery_mark_processed, ) from benchmarks.dramatiq_app import dramatiq_echo, dramatiq_mark_processed +from benchmarks.huey_app import huey_echo, huey_mark_processed from tests.testapp.tasks import echo ENQUEUE_ITERATIONS = 500 @@ -96,7 +98,8 @@ about 0.06 ms for each task. django-tasks-db reads one task at a time. django-tasks-rq forks a work horse for -each job. Neither queue can be told to read ahead. +each job. Neither queue can be told to read ahead. huey pops one task at a time +and has no read-ahead setting either. """ CELERY_WORKER = ( @@ -142,6 +145,26 @@ ``dramatiq_queue_prefetch``. The benchmark sets this variable to ``READ_AHEAD``. """ +HUEY_WORKER = ( + sys.executable, + "-m", + "huey.bin.huey_consumer", + "benchmarks.huey_app.huey_app", + "--workers=1", + "--worker-type=thread", + "--no-periodic", + "--quiet", + "--graceful-signal=TERM", +) +"""huey consumer running as one process with one thread, one message at a time. + +The Redis storage of huey blocks on an empty queue and pops a single message. +It has no read-ahead setting. ``--quiet`` matches the log level of the other +workers and ``--no-periodic`` skips the periodic-task scan, because the +benchmark app registers none. ``--graceful-signal=TERM`` stops the consumer on +SIGTERM the way the other worker benchmarks stop theirs. +""" + WORKER_STOP_TIMEOUT_SECONDS = 20 """Seconds to wait for a worker process to stop after SIGTERM.""" @@ -251,6 +274,12 @@ def drain_with_dramatiq_worker() -> None: ) +def drain_with_huey_worker() -> None: + """Process every queued task with a single-thread huey consumer.""" + huey_mark_processed() + drain_with_subprocess_worker(HUEY_WORKER) + + def drain_with_subprocess_worker( argv: collections.abc.Sequence[str], env: collections.abc.Mapping[str, str] | None = None, @@ -352,6 +381,12 @@ def enqueue_dramatiq_tasks(count: int) -> None: dramatiq_echo.send(index) +def enqueue_huey_tasks(count: int) -> None: + """Accept `count` echo tasks on the huey queue.""" + for index in range(count): + huey_echo(index) + + def django_task_backend( name: str, alias: str, @@ -405,6 +440,11 @@ def django_task_backend( enqueue=enqueue_dramatiq_tasks, drain=drain_with_dramatiq_worker, ), + QueueUnderTest( + name="huey", + enqueue=enqueue_huey_tasks, + drain=drain_with_huey_worker, + ), ) """Queues that ship a worker to process queued tasks.""" @@ -457,6 +497,7 @@ def delete_queued_tasks() -> None: "django_tasks:*", "celery*", "dramatiq:*", # broker keys and the dramatiq:results:* results + "huey.*", # queue, results, schedule and counter keys "rq:*", "_kombu*", ): diff --git a/pyproject.toml b/pyproject.toml index 32c7483..dbc1402 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ test = [ "django-tasks-db", "django-tasks-rq", "dramatiq[redis]>=2.2.1", + "huey", "pytest", "pytest-asyncio", "pytest-benchmark", From 68876e99612d1d5b88c8c3522ee56ea7c149750c Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 8 Oct 2026 00:26:14 +0200 Subject: [PATCH 2/2] Regenerate the queue throughput chart with huey Measured on an idle machine: threadmill 9,960 tasks/s, dramatiq 7,177, huey 6,038, celery 2,280, django-tasks-db 2,065, django-tasks-rq 82. Four of the five earlier rows land within 6% of the previous chart; threadmill measured 9,960 against 11,977, which is the queue that coverage tracing and start-cost quantization hit hardest. --- README.md | 2 +- docs/images/backend-comparison-dark.svg | 33 +++++++++++++----------- docs/images/backend-comparison-light.svg | 33 +++++++++++++----------- 3 files changed, 37 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 8ef46a7..a689c88 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ - Tasks per second with one worker: threadmill 11,977, dramatiq 7,168, celery 2,183, django-tasks-db 2,154, django-tasks-rq 87. + Tasks per second with one worker: threadmill 9,960, dramatiq 7,177, huey 6,038, celery 2,280, django-tasks-db 2,065, django-tasks-rq 82.

diff --git a/docs/images/backend-comparison-dark.svg b/docs/images/backend-comparison-dark.svg index c0ce3a5..41f1146 100644 --- a/docs/images/backend-comparison-dark.svg +++ b/docs/images/backend-comparison-dark.svg @@ -1,22 +1,25 @@ - + - + Queue throughput 5,000–60,000 trivial tasks per queue · one worker process, one thread · higher is better threadmill -11,977/s +9,960/s dramatiq - -7,168/s -celery - -2,183/s -django-tasks-db - -2,154/s -django-tasks-rq - -87/s -One process and one thread each. Threadmill, celery and dramatiq read 128 ahead; django-tasks-db and -rq read one message at a time. + +7,177/s +huey + +6,038/s +celery + +2,280/s +django-tasks-db + +2,065/s +django-tasks-rq + +82/s +One process and one thread each. Threadmill, celery and dramatiq read 128 ahead; django-tasks-db, -rq and huey read one task at a time. diff --git a/docs/images/backend-comparison-light.svg b/docs/images/backend-comparison-light.svg index a69b065..1dac111 100644 --- a/docs/images/backend-comparison-light.svg +++ b/docs/images/backend-comparison-light.svg @@ -1,22 +1,25 @@ - + - + Queue throughput 5,000–60,000 trivial tasks per queue · one worker process, one thread · higher is better threadmill -11,977/s +9,960/s dramatiq - -7,168/s -celery - -2,183/s -django-tasks-db - -2,154/s -django-tasks-rq - -87/s -One process and one thread each. Threadmill, celery and dramatiq read 128 ahead; django-tasks-db and -rq read one message at a time. + +7,177/s +huey + +6,038/s +celery + +2,280/s +django-tasks-db + +2,065/s +django-tasks-rq + +82/s +One process and one thread each. Threadmill, celery and dramatiq read 128 ahead; django-tasks-db, -rq and huey read one task at a time.