Worker¶
The worker consumes messages from RabbitMQ and dispatches them to your consumer functions. It has no Django dependency — it runs standalone with any async Python environment.
Writing consumers¶
Using the @consume decorator¶
from djoutbox import Worker, consume
@consume("user.created")
async def handle_user_created(user: dict):
print(f"User created: {user}")
worker = Worker(
consumers=[handle_user_created],
rmq_url="amqp://guest:guest@localhost/",
)
Using Consumer directly¶
Both approaches are equivalent:
from djoutbox import Worker, Consumer
async def handle_user_created(user: dict):
print(f"User created: {user}")
worker = Worker(
consumers=[
Consumer(
binding_key="user.created",
queue_name="my_service.on_user_created", # optional, auto-generated if not provided
callback=handle_user_created,
)
],
rmq_url="amqp://guest:guest@localhost/",
)
Running the worker¶
Option 1 — Standalone script (no Django)¶
import asyncio
from djoutbox import Worker, consume
@consume("my.routing.key")
async def my_handler(payload: dict):
...
worker = Worker(consumers=[my_handler], rmq_url="amqp://guest:guest@localhost/")
asyncio.run(worker.run())
Option 2 — Env vars¶
import asyncio
import os
from djoutbox import Worker, consume
worker = Worker(
consumers=[my_handler],
rmq_url=os.environ["RMQ_URL"],
exchange_name=os.environ.get("EXCHANGE_NAME", "outbox"),
)
asyncio.run(worker.run())
Option 3 — Import settings.py (for shared config)¶
import os
import sys
sys.path.insert(0, "/path/to/project")
os.environ["DJANGO_SETTINGS_MODULE"] = "myproject.settings"
import asyncio
from django.conf import settings
from djoutbox import Worker, consume
worker = Worker(consumers=[my_handler], **settings.DJOUTBOX)
asyncio.run(worker.run())
Option 4 — Management command (full Django ORM access)¶
If your consumer needs the Django ORM or other Django features (including publishing messages to the outbox table), put Worker.run() in a management command:
# myapp/management/commands/run_worker.py
import asyncio
from django.core.management.base import BaseCommand
from django.conf import settings
from djoutbox import Worker, consume
@consume("my.routing.key")
async def my_handler(payload: dict):
# Full Django ORM access here
User.objects.filter(...)
class Command(BaseCommand):
def handle(self, *args, **options):
worker = Worker(consumers=[my_handler], **settings.DJOUTBOX)
asyncio.run(worker.run())
Run with: ./manage.py run_worker
Worker API¶
class Worker:
def __init__(
self,
*,
rmq_connection: AbstractConnection | None = None, # mutually exclusive with rmq_url
rmq_url: str | None = None, # mutually exclusive with rmq_connection
consumers: Sequence[Consumer] = (),
exchange_name: str = "outbox",
default_retry_delays: Sequence[str] = ("1s", "10s", "1m", "5m"),
prefetch_count: int = 10,
) -> None
| Parameter | Default | Description |
|---|---|---|
rmq_url |
None |
RabbitMQ connection string |
rmq_connection |
None |
Existing aio-pika connection (alternative to rmq_url) |
consumers |
() |
List of Consumer instances |
exchange_name |
"outbox" |
Topic exchange name |
default_retry_delays |
("1s", "10s", "1m", "5m") |
Default retry delays for all consumers |
prefetch_count |
10 |
RabbitMQ prefetch count |
Methods¶
run()— Start consuming. Blocks until SIGINT/SIGTERM.
Consumer API¶
@dataclass
class Consumer:
binding_key: str # required
callback: Callable # required
queue_name: str | None = None
retry_delays: Sequence[str] | None = None
| Field | Default | Description |
|---|---|---|
binding_key |
(required) | Topic binding key (supports wildcards like user.*) |
callback |
(required) | Async or sync function to handle messages |
queue_name |
None |
Queue name (auto-generated from callback module + qualname if not provided) |
retry_delays |
None |
Per-consumer retry delays (overrides Worker's default) |
The Consumer is callable — it delegates to the callback, making it transparent for testing.
consume() decorator¶
consume(
binding_key: str,
*,
queue_name: str | None = None,
retry_delays: Sequence[str] | None = None,
) -> Callable[[Callable], Consumer]
Consumer callback arguments¶
The worker inspects your callback's signature and injects arguments by name:
@consume("user.*")
async def handler(
body: dict, # The deserialized message body (exactly one non-reserved param required)
routing_key: str, # The original routing key (useful with wildcard bindings)
message, # The raw aio-pika message object
tracking_ids: list, # List of UUIDs tracing the message lineage
attempt_count: int, # Current attempt number (1-based)
):
...
You must have exactly one parameter that isn't one of the reserved names (routing_key, message, tracking_ids, attempt_count). That parameter receives the deserialized message body.
Body deserialization¶
The body type hint determines deserialization:
| Type hint | Deserialization |
|---|---|
Pydantic BaseModel subclass |
model_validate_json() |
bytes |
Raw body |
dict / no hint / other |
json.loads() |
Pydantic is optional. Without it installed, plain json.loads() is used for all non-bytes types.
Retries and dead-lettering¶
When a consumer raises an exception, the worker implements delayed retries using RabbitMQ's TTL-based delay queues.
How retries work¶
- Consumer raises an exception
- Worker publishes the message to a delay exchange with TTL
- After TTL expires, RabbitMQ routes it back to the original queue
- Consumer receives it again (with incremented
attempt_count)
Configuring retry delays¶
from djoutbox import Worker, consume, Reject
# Per-consumer override
@consume("user.created", retry_delays=("5s", "30s", "2m"))
async def handle_user_created(user):
...
# Disable retries (straight to DLQ on failure)
@consume("user.deleted", retry_delays=())
async def handle_user_deleted(user):
...
# Global default
worker = Worker(default_retry_delays=("1s", "10s", "1m", "5m"), ...)
Duration format¶
| Input | Milliseconds |
|---|---|
"0", "0ms", "0s", "0m" |
0 (instant requeue) |
"500ms" |
500 |
"1s" |
1000 |
"30s" |
30000 |
"1m" |
60000 |
"1m30s" |
90000 |
"1h" |
3600000 |
"1d" |
86400000 |
Dead-letter queues (DLQ)¶
After exhausting all retry attempts, messages are sent to a dead-letter queue ({queue_name}.dlq). This allows you to inspect failures, fix bugs, and reprocess messages.
from djoutbox import Reject
@consume("order.created")
async def handle_order(order):
if some_permanent_error:
raise Reject() # Skip retries, send directly to DLQ
Graceful shutdown¶
When the worker receives SIGINT or SIGTERM:
- It requests disconnect from all queues
- Messages sent between shutdown and disconnect are rejected with
requeue=True(they'll be consumed by other workers) - In-flight messages continue processing until the callback finishes
- When all pending tasks complete, the worker exits
sequenceDiagram
participant Pub as Publisher
participant Q as RabbitMQ Queue
participant W as Worker
participant OW as Other Worker
W->>Q: Connect, start consuming
Pub->>Q: Publish message 1
Q-->>W: Deliver message 1
W->>W: Process message 1
Note right of W: SIGINT/SIGTERM received
W->>Q: Request disconnect
Pub->>Q: Publish message 2
Q-->>W: Deliver message 2
W->>Q: Reject message 2 (requeue=True)
Q-->>W: Acknowledge disconnect
W->>W: Finish processing message 1
W->>Q: Ack message 1
W->>W: Exit
OW->>Q: Connect
Q-->>OW: Deliver message 2
OW->>OW: Process message 2
OW->>Q: Ack message 2
Blocking I/O and CPU-bound work¶
Blocking I/O¶
If you have synchronous code, use a non-async callback. The worker wraps it in asyncio.to_thread() automatically:
# Async callback (preferred)
@consume("user.created")
async def handle_user_created(user):
await send_email_async(user)
# Sync callback (auto-runs in thread pool)
@consume("order.created")
def handle_order(order):
send_email_blocking(order)
update_crm_blocking(order)
CPU-bound work¶
If your consumer needs to perform CPU-intensive work (image processing, data transformations, heavy computations), offload it to a process pool (not thread pool). This is not built into the library because most outbox use cases are I/O-bound, and users have different parallelism needs.
import asyncio
from concurrent.futures import ProcessPoolExecutor
from djoutbox import consume
process_pool = ProcessPoolExecutor(max_workers=4)
def cpu_intensive_task(image_data: bytes) -> bytes:
"""Runs in a separate process, doesn't block the event loop"""
from PIL import Image
import io
image = Image.open(io.BytesIO(image_data))
image.thumbnail((800, 600))
output = io.BytesIO()
image.save(output, format="JPEG")
return output.getvalue()
@consume("image.uploaded", queue="process_image")
async def process_image(image_data: bytes):
loop = asyncio.get_event_loop()
processed_data = await loop.run_in_executor(
process_pool, cpu_intensive_task, image_data
)
await upload_to_storage(processed_data)
Why process pool instead of thread pool for CPU work?
- Python's GIL prevents true parallelism in threads for CPU-bound tasks
- Process pools bypass the GIL by using separate processes
- For blocking I/O, threads are sufficient (and more efficient) because I/O releases the GIL
Topic exchange and wildcard matching¶
Use RabbitMQ topic exchange wildcards in binding keys:
# Publish
publish("user.created", {"id": 123})
publish("user.updated", {"id": 123})
publish("user.deleted", {"id": 123})
# Consumer catches all user events
@consume("user.*", queue="user_event_handler")
async def handle_user_event(user):
...
# Consumer catches everything
@consume("#", queue="catch_all")
async def catch_all(event):
...
If you use wildcards and need the original routing key, add a routing_key parameter to the callback:
@consume("user.*", queue="user_event_handler")
async def handle_user_event(routing_key: str, user):
logger.info(f"Received {routing_key=}")