streaQ Distributed Task Queue

repository·master·Indexed 41 days ago

https://github.com/tastyware/streaq

A fast, async, and fully-typed distributed task queue built on Redis streams. streaQ supports async/sync tasks, cron jobs, priority queues, and task dependency graphs. It is designed for high performance and structured concurrency, providing native support for both asyncio and Trio, as well as integration with Redis Sentinel and Redis Cluster.

Tokens
19.7K
Snippets
81
Records
92
Agent score
48%

What's inside streaQ

  1. What is streaQ?

    master

    streaQ is a fast, asynchronous, and type-safe job queuing library built on top of Redis streams. It is designed for developers who need robust task management with features like structured concurrency and support for Trio. Key capabilities include:

    • Task Management: Supports startup/shutdown hooks, task scheduling (cron jobs), task middleware, and task abortion.
    • Advanced Queuing: Supports priority queues, task dependency graphs, and synchronous tasks.
    • Redis Integration: Supports Redis Sentinel and Redis Cluster.
    • Developer Experience: Fully type-safe with extensive documentation and a Web UI.
    • Concurrency: Built with structured concurrency in mind and provides native Trio support.
  2. Modularize task definitions across multiple Workers

    master

    You can define tasks on separate Worker instances and combine them using worker.include(other_worker). This allows for grouping tasks logically.

    Accessing Context in Modular Tasks When a task is defined on a separate worker instance (e.g., other), it can still access the dependencies of the main worker that includes it. To maintain type hints, define the worker with the expected context type: other = Worker[WorkerContext]().

    Warning: Accessing the worker inside a task Do not use the global worker variable (e.g., other.abort_by_id(...)) inside a task, as other might not be the actual running worker. Instead, use the reference attached to the task object: task_name.worker.abort_by_id(...).

    # other.py
    from streaq import Worker
    from typing import NamedTuple
    from httpx import AsyncClient
    
    class WorkerContext(NamedTuple):
        client: AsyncClient
    
    other = Worker[WorkerContext]()
    
    @other.task
    async def make_request() -> None:
        await other.context.client.get("...")
    
    @other.task
    async def abort_foobar(task_id: str) -> bool:
        # Use the task's worker reference, NOT the global 'other'
        return await make_request.worker.abort_by_id(task_id)
    
    # main.py
    from streaq import Worker
    from other import other, make_request
    
    worker = Worker()
    worker.include(other)
    
    @worker.task
    async def barfoo() -> bool: ...
    
    async def main():
        async with worker:
            await make_request.enqueue()
            await barfoo.enqueue()
  3. How middleware works in streaQ

    master

    Middleware allows you to wrap task execution to implement cross-cutting concerns like observability, logging, or exception handling.

    Middleware is implemented as a higher-order function that accepts a ReturnCoroutine (the task to be executed) and returns a new ReturnCoroutine (the wrapper). Because they are structured as wrapped functions, you can:

    1. Execute code before the task starts.
    2. Execute code after the task completes.
    3. Access and modify the task's arguments (*args, **kwargs).
    4. Access and modify the task's return value.
    5. Access the task context via the RegisteredMiddleware.context attribute (e.g., middleware_name.context.task_id).

    For automated distributed tracing, use the opentelemetry-instrumentation-streaq package instead of manual middleware.

    import time
    from typing import Any
    from streaq.types import ReturnCoroutine
    
    @worker.middleware
    def timer(task: ReturnCoroutine) -> ReturnCoroutine:
        async def wrapper(*args, **kwargs) -> Any:
            start_time = time.perf_counter()
            result = await task(*args, **kwargs)
            print(f"Executed task {timer.context.task_id} in {time.perf_counter() - start_time:.3f}s")
            return result
    
        return wrapper
  4. Understand TaskInfo and TaskResult data structures

    master

    When interacting with tasks, streaQ provides two main data structures:

    TaskInfo: Contains metadata about the task's lifecycle.

    • fn_name: Name of the function.
    • enqueue_time: Timestamp of enqueuing.
    • tries: Number of attempts made.
    • scheduled: The scheduled execution time.
    • dependencies / dependents: Sets representing the task's position in the dependency graph.

    TaskResult: Contains the outcome of the task execution.

    • fn_name: Name of the function.
    • success: Boolean indicating if the task completed without error.
    • start_time / finish_time: Execution timestamps.
    • tries: Total attempts.
    • worker_id: The ID of the worker that processed the task.
    • _result: The actual return value of the function.
    # Example TaskInfo output
    TaskInfo(fn_name='sleeper', enqueue_time=1751508876961, tries=0, scheduled=datetime.datetime(2025, 7, 3, 2, 14, 39, 961000, tzinfo=datetime.timezone.utc), dependencies=set(), dependents=set())
    
    # Example TaskResult output
    TaskResult(fn_name='sleeper', enqueue_time=1751508876961, success=True, start_time=1751508880500, finish_time=1751508881503, tries=1, worker_id='ca5bd9eb', _result=1)
  5. Create task dependency graphs with .start(after=...)

    master

    streaQ allows you to chain tasks together so that dependent tasks only execute after their dependencies have finished successfully. If a dependency fails, the dependent task will also fail.

    You can specify dependencies using the after parameter in the Task.start method. The after parameter accepts a single task ID or a list of task IDs.

    async with worker:
        task1 = await sleeper.enqueue(1)
        # task2 starts only after task1 finishes
        task2 = await sleeper.enqueue(2).start(after=task1.id)
        # task3 starts only after both task1 and task2 finish
        task3 = await sleeper.enqueue(3).start(after=[task1.id, task2.id])
  6. How task execution works in streaQ

    master

    streaQ uses a "pessimistic execution" (at-least-once) model. Tasks are only removed from the queue after they succeed or fail. If a worker shuts down mid-task, the task remains in the queue for another worker to pick up.

    Key implications:

    • Idempotency: Tasks should be designed to be safe if called multiple times. Use database transactions, idempotency keys, or Redis to ensure non-repeatable work isn't performed twice.
    • Opting out: You can disable retries for a specific task by setting max_tries=1 in the @worker.task decorator.
    • Liveness: Worker.idle_timeout determines how quickly stuck tasks are retried.

    Exception handling:

    • StreaqRetry: Triggers a retry (potentially with delay).
    • asyncio.CancelledError or trio.Cancelled: Results in failure if aborted by the user, or a retry if the worker shuts down unexpectedly.
    • Any other exception: Results in task failure.
  7. Customize Web UI formatters

    master

    The streaQ Web UI allows you to customize how results and exceptions are displayed by overriding the get_result_formatter and get_exception_formatter dependencies in FastAPI.

    from typing import Any
    from fastapi import FastAPI
    from streaq.ui import get_result_formatter, get_exception_formatter
    
    def my_result_formatter(result: Any) -> str: 
        # Implement custom formatting logic
        return str(result)
    
    def my_exception_formatter(exc: BaseException) -> str: 
        # Implement custom exception formatting logic
        return str(exc)
    
    app = FastAPI()
    app.dependency_overrides[get_result_formatter] = lambda: my_result_formatter
    app.dependency_overrides[get_exception_formatter] = lambda: my_exception_formatter
  8. Optimize worker performance

    master

    To improve worker performance:

    1. Coredis Optimization: Enable coredis optimized mode to disable some runtime type checking.
    2. Event Loop: Use uvloop (or winloop on Windows) by installing the package and setting anyio_kwargs={"use_uvloop": True} in your Worker configuration.
    worker = Worker(anyio_kwargs={"use_uvloop": True})
  9. Deploy with Redis Cluster

    master

    To use Redis Cluster, provide cluster_nodes. Because streaQ uses pipelines and Lua scripts, you must use a hash tag (e.g., {default}) in the queue_name to ensure keys land on the same node. If using multiple queues, use different prefixes to distribute them across the cluster.

    worker = Worker(cluster_nodes=[("localhost", 7000)], queue_name="{default}")
  10. Run streaQ performance benchmarks

    master

    You can enqueue jobs for benchmarking using the bench_streaq.py script. Use the --time flag to specify the duration.

    To run the benchmark script:

    $ python benchmarks/bench_streaq.py --time 1

    To run the streaQ worker for the benchmark, use the streaq run command. You can adjust the number of workers using the --workers flag and enable burst mode with --burst:

    $ streaq run --burst --workers ? bench_streaq:worker
    $ python benchmarks/bench_streaq.py --time 1
    $ streaq run --burst --workers ? bench_streaq:worker