Broccoli
Broccoli is a Redis-backed Python task queue for running background work with:
- Priority scheduling (strict priority tiers + FIFO ordering inside each tier)
- Dependency-aware tasks (
depends_on) - Retries + dead-letter handling
- Crash/stall recovery
- Multiple worker runtimes (base, threaded, async, hybrid, gpu)
- CLI tooling for operational inspection and control
It is designed for teams that want Celery-like queue behavior with a smaller, explicit codebase.
Table of contents
- Features
- Architecture and Redis data model
- Requirements
- Installation
- Quick start
- Core concepts
- Task lifecycle
- Worker types
- Task dependencies
- Dead-letter and recovery
- CLI reference
- Environment variables
- Programmatic API reference
- Operational guidance
- Troubleshooting
- Development
- License
Features
Queueing and scheduling
- Push tasks with explicit priorities (
0is highest priority) - FIFO ordering is preserved within each priority band
- Queue operations are persisted in Redis for durability
Dependency-aware execution
- A task can declare
depends_on=[<task_id>, ...]for fan-out/fan-in style waits - Dependent tasks are marked
waitinguntil all listed parents complete - Dependency release is handled automatically on parent completion
Worker execution models
- Single-threaded worker (
BaseWorker) - Thread pool worker (
ThreadedWorker) - Asyncio worker (
AsyncWorker) - Hybrid worker (
HybridWorker: async dispatch + threaded execution) - GPU worker (
GPUWorker: hybrid worker pinned to a GPU queue/device)
Reliability and failure handling
- Built-in retry handling (
max_retries, default3) - Dead-letter capture for permanently failed tasks
- Requeue dead-letter tasks for replay
- Stalled task recovery (manual and startup auto-recovery)
Operations and observability
- Queue depth and processing stats
- Dead-letter inspection CLI commands
- Health checks for Redis reachability
Architecture and Redis data model
Broccoli primarily uses Redis sorted sets and hashes:
<base>:queue— runnable tasks, scored by(priority tier + FIFO sequence)<base>:processing— in-flight tasks, scored by processing timestamp<base>:sequence— monotonic sequence for FIFO ordering<task_prefix>:<task_id>— task metadata hashdependency:<task_id>— set of blocked dependents waiting for parent<task_prefix>:dead_letter— dead-letter task IDs with failure timestampsdl:<task_id>— dead-letter task snapshotresult:<task_id>— result records with TTL
Requirements
- Python 3.11+
- Redis 6+
Installation
pip install broccoli-workers
Local development install:
pip install -e .
Quick start
1) Create a producer
from broccoli.core.task.task import Task
from broccoli.core.task.task_queue import TaskQueue
queue = TaskQueue(
redis_url="redis://localhost:6379",
decode_responses=True, # set False to work with raw Redis bytes
)
task = Task(task_type="send_email", payload={"to": "user@example.com"})
queue.push(task, priority=1)
print(task.task_id)
2) Create a worker and register handlers
from broccoli.workers.threaded_worker import ThreadedWorker
worker = ThreadedWorker(redis_url="redis://localhost:6379", max_workers=4)
@worker.registry.register("send_email")
def send_email(payload):
print(f"Sending email to {payload['to']}")
return {"status": "sent", "to": payload["to"]}
worker.start()
Core concepts
Task model
Task fields include:
task_id: UUID (auto-generated unless provided)task_type: handler key in registrypayload: arbitrary JSON-serializable dictionarystatus:pending | waiting | in_progress | completed | failedretries: current retry countmax_retries: retry limitdepends_on: optional list of parent task IDsresult: handler outputerror: error message for failed attempts
Registry model
TaskRegistry is singleton-backed, so handlers are globally visible in-process.
You can register with a decorator:
@worker.registry.register("my_task")
def my_task(payload):
return {"ok": True}
Or manually:
worker.registry.register_manually("my_task", my_task_handler)
Task lifecycle
- Push: task metadata hash is saved.
- Queue placement:
- no dependency ->
pendingin runnable queue - with unresolved dependency ->
waiting
- no dependency ->
- Pop: worker moves task to
in_progressand processing set. - Execution:
- success ->
completed - failure + retries left ->
pending(requeued) - failure + retries exhausted ->
failed(dead-letter eligible)
- success ->
- Post-processing:
- results persisted
- task hash cleanup for terminal states
- handlers invoked
Worker types
BaseWorker
- Sequential task execution
- Simple and predictable behavior
- Useful for debugging and low-throughput workloads
ThreadedWorker
- Executes tasks concurrently using
ThreadPoolExecutor - Good for mixed and blocking workloads
- Config:
max_workers
AsyncWorker
- Uses
asyncioloop with bounded concurrency - Runs task handlers via executor with timeout enforcement
- Config:
max_concurrent
HybridWorker
- Async dispatch + thread pool execution
- Suitable for high-throughput mixed workloads
- Config:
thread_workers,async_tasks
GPUWorker
- Dedicated worker for GPU workloads (
gpu_tasks:queue) - Pins execution to a selected GPU via
--gpu-id - Uses
HybridWorkerexecution model with GPU cache cleanup
Task dependencies
When pushing dependent tasks:
- If all parents already completed, dependent is enqueued immediately.
- If one or more parents are incomplete, dependent enters
waitingand is linked under each unresolveddependency:<parent_id>set.
On parent completion, waiting tasks decrement their remaining dependency count and enqueue only when all dependencies are satisfied.
Helpful APIs:
queue.get_waiting_for(parent_id)queue.get_waiting_tasks()
Dead-letter and recovery
Dead-letter behavior
On permanent failure, task IDs are stored in <task_prefix>:dead_letter and a snapshot is persisted at dl:<task_id>.
Requeue dead-letter task
queue.requeue_dead(task_id)
CLI:
broccoli dead list
broccoli dead requeue <task_id>
Recover stalled processing tasks
Manual recovery before startup:
broccoli worker start --type threaded --recover-stalled 600
Automatic startup recovery (default enabled):
broccoli worker start --type threaded --recover-on-startup
broccoli worker start --type threaded --no-recover-on-startup
CLI reference
Global
broccoli -v ...
broccoli -vv ...
Worker startup
broccoli worker start --type threaded
broccoli worker start --type async --concurrency 20
broccoli worker start --type hybrid --thread-workers 8 --async-tasks 50
broccoli worker start --type gpu --gpu-id 0
broccoli worker start --type threaded --pool --num-workers 4
Common worker flags:
--redis-url--queue-name--task-prefix--worker-id--recover-stalled--recover-stalled-timeout--recover-on-startup/--no-recover-on-startup--decode-responses/--no-decode-responses--redis-socket-timeout--redis-socket-connect-timeout--redis-health-check-interval--redis-retry-on-timeout/--no-redis-retry-on-timeout--redis-max-connections
Queue inspection
broccoli queue stats --format table
broccoli queue stats --format json
broccoli queue list --status pending --limit 20
broccoli queue get <task_id>
broccoli queue waiting <parent_id>
Dead-letter operations
broccoli dead list
broccoli dead list --format json
broccoli dead requeue <task_id>
Health
broccoli health
Environment variables
Broccoli reads only the Redis URL (which may include a password) from the environment:
BROCCOLI_REDIS_URL, elseREDIS_URL(default:redis://localhost:6379)
It is also read from a .env file in the working directory (or the path in BROCCOLI_ENV_FILE); real environment variables take precedence. Example .env:
REDIS_URL=redis://:yourpassword@localhost:6379/0
All other settings (queue name, timeouts, retries, worker counts, ...) are fields on BroccoliConfig in broccoli/config.py. Change them in code:
from broccoli.config import get_config, set_config
set_config(get_config().with_overrides(queue_name="jobs:queue", task_timeout=900))
Programmatic API reference
TaskQueue
- constructor:
TaskQueue(redis_url=..., queue_name=..., task_prefix=..., decode_responses=True, redis_config={...}) push(task, priority=0) -> task_idpop() -> Task | Nonecomplete(task)fail(task)requeue(task_id, priority=None)requeue_dead(task_id) -> boolrecover_stalled(timeout_seconds=3600) -> intget_task(task_id) -> Task | Nonestats() -> dictprocessing_stats() -> dictget_waiting_for(task_id) -> list[str]get_waiting_tasks() -> list[str]is_fully_drained() -> bool
Worker hooks
All workers support lifecycle hooks:
add_completion_handler(handler)add_failure_handler(handler)add_pre_process_handler(handler)add_post_process_handler(handler)- decorator aliases:
on_complete,on_failure,on_pre_process,on_post_process
Operational guidance
1. Use shared registration modules
Keep task registrations in one importable module and register into each worker instance at startup.
2. Prefer idempotent handlers
Retries can execute handlers multiple times. Side effects should be safe to repeat.
3. Set realistic timeout and recovery values
Choose recover_stalled_timeout high enough to avoid recovering long-running but valid tasks.
4. Monitor dead-letter growth
A growing dead-letter set often indicates handler bugs or dependency/data issues.
5. Separate queues by workload profile
Use distinct queue names/prefixes for high-latency jobs or tenant isolation.
Troubleshooting
Worker starts but does nothing
- Ensure tasks are pushed to the same
queue_namethe worker consumes. - Verify
task_prefixalignment between producer and worker. - Confirm handlers are registered for every
task_type.
No handler registered for task type
- Register the handler in the same process that runs the worker.
- Import your registration module before calling
worker.start().
Tasks remain waiting
- Check parent task status with
broccoli queue get <parent_id>. - Confirm parent was completed (not failed/deleted).
Stuck processing tasks after crash
- Enable startup recovery or run
--recover-stalledmanually.
Redis connectivity errors
- Run
broccoli health. - Verify Redis URL/auth/network.
Development
Install development dependencies:
pip install -e .[dev]
Run tests:
pytest
Format/lint/type-check tools are configured in pyproject.toml (black, isort, flake8, mypy).
License
MIT License. See /home/runner/work/Broccoli/Broccoli/LICENSE.
Metadata
Release files for broccoli-workers 0.1.5
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| broccoli_workers-0.1.5.tar.gz | 58.8 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| broccoli_workers-0.1.5-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 114.2 kB
Release files / broccoli_workers-0.1.5.tar.gz
| Download URL | broccoli_workers-0.1.5.tar.gz |
|---|---|
| Size | 58.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c5b0df42f71eee2e4b4ce428efe637d4fad18b7b9e985e7c8af09f42d6c91b26
|
|
BLAKE2b-256 checksum How to use checksums |
5127928d8cc379560e71e8af44b4c3465c4ebcd0b75d7a66344a9d6aeda6148a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|
Release files / broccoli_workers-0.1.5-py3-none-any.whl
| Download URL | broccoli_workers-0.1.5-py3-none-any.whl |
|---|---|
| Size | 55.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3c8b49b606ef8f150a3237839e277828eaa31e4a5ac4d845e84c77a8424fab58
|
|
BLAKE2b-256 checksum How to use checksums |
40dac468b022d91f05cc23028667d77799452c2bdc0057910f45b99548af453f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|
Security for broccoli-workers 0.1.5
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.