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