Repository navigation
Conversation
Tests written first (TDD red phase): - merge_async_iterables: merging multiple async generators, error propagation, closing inner generators on consumer close. - MultiBroker: constructor validation, kick routing by queue_name label, merged listen over all inner brokers, startup/shutdown delegation, process flag propagation, task registry aggregation, result backend propagation, end-to-end Receiver test proving one worker consumes multiple queues.
MultiBroker combines several real brokers so a single worker consumes messages from all of them at the same time: - listen() merges listen generators of all inner brokers via the new merge_async_iterables util (errors propagate, inner generators are closed on shutdown); - kick() routes messages by the queue_name label when a routing mapping is configured, otherwise the default broker is used; - startup/shutdown delegate to all inner brokers, a failing shutdown of one broker doesn't prevent others from stopping; - is_worker_process/is_scheduler_process flags propagate to inner brokers so they fire correct lifecycle events; - find_task/get_all_tasks aggregate task registries of inner brokers, so tasks can be registered on any of them; - with_result_backend() propagates the backend to inner brokers to keep client-side polling and worker-side saving in sync. Also refines the inner-broker-error test to assert the contract at the MultiBroker.listen level: Receiver wraps any broker error in an anyio ExceptionGroup, which is existing taskiq behavior.
Previously an error escaping an inner broker's listen() killed the whole merged listening (fail-fast), taking down all remaining healthy brokers with it. For multi-cluster setups (e.g. two RabbitMQ clusters or RabbitMQ + Kafka) that's the wrong trade-off: one dead cluster should not stop processing of the others. Now MultiBroker.listen() wraps every inner broker in a guard generator: - an inner broker failure is logged (with traceback) and the failed source is excluded from listening; remaining brokers keep working; - only when every inner broker is down does the listening end, so the worker process exits and is restarted by the supervisor (default --max-fails -1 restarts indefinitely); - shutdown paths are unchanged: cancellation and aclose still close inner generators deterministically (guard's finally acloses the inner iterator). merge_async_iterables keeps its fail-fast contract; the degradation policy lives in MultiBroker where the logging context is.
Restructures the MultiBroker docs section and spells out the contract: - How it works: merged listening, kick paths, lifecycle delegation, flag propagation, deterministic shutdown cleanup. - Failure handling: connection flaps are handled by inner brokers themselves (e.g. aio-pika RobustConnection); if listening does fail, the broker is excluded (logged with traceback) and the worker keeps consuming the rest; exclusion is permanent until process restart; all brokers down -> worker exits and the supervisor restarts it (default --max-fails -1 restarts indefinitely); in-flight messages complete; kicks are unaffected by worker-side degradation. - Guarantees table: what the composite provides (merge, routing, degradation, graceful shutdown) vs what must come from inner brokers (reconnection, delivery guarantees, redelivery/dead-lettering, per-queue concurrency limits, ordering). Also mirrors the failure summary in the class docstring.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #723 +/- ##
==========================================
+ Coverage 85.49% 86.02% +0.52%
==========================================
Files 68 69 +1
Lines 2662 2790 +128
==========================================
+ Hits 2276 2400 +124
- Misses 386 390 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The pre-commit mypy hook type-checks tests as well, while only 'taskiq' was checked locally: - annotate the merged list in the empty-iterables test and cast the generator, since the TypeVar cannot be inferred from an empty argument list; - parameterize InmemoryResultBackend instantiation; - align DyingBroker.listen signature with the parent broker (AsyncGenerator[AckableMessage, None]) and yield AckableMessage.
| ) | ||
| await super().shutdown() | ||
|
|
||
| def find_task( |
There was a problem hiding this comment.
I don't think we need to override those task search methods.
The reason is that if you use this multibroker, you use it with the task decorator, so all tasks will appear in the multibroker's task registry.
With this implementation, the code after line 242 is never expected to be reached. Also, this implementation depends quite a lot on the order of the brokers for instance, if you define a task with the same name on different brokers, only the first broker will be used.
| for inner_broker in brokers: | ||
| if not isinstance(inner_broker, AsyncBroker): | ||
| raise ValueError( | ||
| "All brokers must be AsyncBroker instances, " | ||
| f"got {type(inner_broker).__name__}.", | ||
| ) |
There was a problem hiding this comment.
| for inner_broker in brokers: | |
| if not isinstance(inner_broker, AsyncBroker): | |
| raise ValueError( | |
| "All brokers must be AsyncBroker instances, " | |
| f"got {type(inner_broker).__name__}.", | |
| ) | |
| for inner_broker in brokers: | |
| if not isinstance(inner_broker, AsyncBroker): | |
| raise ValueError( | |
| "All brokers must be AsyncBroker instances, " | |
| f"got {type(inner_broker).__name__}.", | |
| ) | |
| inner_broker.task = lamba: raise ValueError("Use multibroker instead for registering tasks on indifvidual brokers") |
This guardrail might help with remove find_task and get_all_tasks.
| "All brokers must be AsyncBroker instances, " | ||
| f"got {type(inner_broker).__name__}.", | ||
| ) | ||
| if default_broker is not None and not isinstance( |
There was a problem hiding this comment.
There might be additional validation that this broker is one of brokers. Or, we can remove this parameter in favor or default_routing. This will guarantee that broker is present.
| *brokers: AsyncBroker, | ||
| default_broker: AsyncBroker | None = None, | ||
| routing: "dict[str, AsyncBroker] | None" = None, | ||
| routing_label: str = "queue_name", |
There was a problem hiding this comment.
Since you have routing already, you can remove brokers and default_broker arguments and make signature of something like that:
| *brokers: AsyncBroker, | |
| default_broker: AsyncBroker | None = None, | |
| routing: "dict[str, AsyncBroker] | None" = None, | |
| routing_label: str = "queue_name", | |
| routing: "dict[str, AsyncBroker] | None" = None, | |
| default_routing_label: str | None = None, | |
| routing_label: str = "queue_name", |
| try: | ||
| async for value in iterable: | ||
| await queue.put(value) | ||
| except asyncio.CancelledError: |
There was a problem hiding this comment.
We can simplify this pump function using anyio, since it's in our dependencies.
| await iterable.aclose() | ||
|
|
||
|
|
||
| _MERGE_STOP = object() |
There was a problem hiding this comment.
Such things better define at the top.
| async def merge_async_iterables( | ||
| *iterables: AsyncGenerator[_T, None], | ||
| ) -> AsyncGenerator[_T, None]: | ||
| """ | ||
| Merge multiple async generators into a single one. | ||
|
|
||
| All values from every generator are yielded | ||
| as soon as they arrive, in no particular order. | ||
|
|
||
| If any of the inner generators raises an exception, | ||
| this exception is re-raised for the consumer. | ||
|
|
||
| When the merged generator is closed or cancelled, | ||
| all inner generators are closed as well. | ||
|
|
||
| :param iterables: async generators to merge. | ||
| :return: single generator with values from all generators. | ||
| """ | ||
| if not iterables: | ||
| return | ||
|
|
||
| queue: asyncio.Queue[Any] = asyncio.Queue() | ||
| pumps = [ | ||
| asyncio.create_task(_pump_iterable(iterable, queue)) for iterable in iterables | ||
| ] | ||
| stopped_count = 0 | ||
| try: | ||
| while stopped_count < len(iterables): | ||
| value = await queue.get() | ||
| if value is _MERGE_STOP: | ||
| stopped_count += 1 | ||
| continue | ||
| if isinstance(value, BaseException): | ||
| raise value | ||
| yield value | ||
| finally: | ||
| for pump_task in pumps: | ||
| pump_task.cancel() | ||
| await asyncio.gather(*pumps, return_exceptions=True) | ||
| for iterable in iterables: | ||
| # A generator is already closed if its pump was | ||
| # cancelled in the middle of iteration. For others | ||
| # this triggers their cleanup logic. | ||
| with contextlib.suppress(RuntimeError): | ||
| await iterable.aclose() | ||
|
|
||
|
|
||
| _MERGE_STOP = object() | ||
|
|
||
|
|
||
| async def _pump_iterable( | ||
| iterable: AsyncGenerator[_T, None], | ||
| queue: "asyncio.Queue[Any]", | ||
| ) -> None: | ||
| """Forward all values from a single iterable to the shared queue.""" | ||
| try: | ||
| async for value in iterable: | ||
| await queue.put(value) | ||
| except asyncio.CancelledError: | ||
| raise | ||
| except BaseException as exc: | ||
| await queue.put(exc) | ||
| else: | ||
| await queue.put(_MERGE_STOP) |
There was a problem hiding this comment.
I think that there is a simpler solution:
| async def merge_async_iterables( | |
| *iterables: AsyncGenerator[_T, None], | |
| ) -> AsyncGenerator[_T, None]: | |
| """ | |
| Merge multiple async generators into a single one. | |
| All values from every generator are yielded | |
| as soon as they arrive, in no particular order. | |
| If any of the inner generators raises an exception, | |
| this exception is re-raised for the consumer. | |
| When the merged generator is closed or cancelled, | |
| all inner generators are closed as well. | |
| :param iterables: async generators to merge. | |
| :return: single generator with values from all generators. | |
| """ | |
| if not iterables: | |
| return | |
| queue: asyncio.Queue[Any] = asyncio.Queue() | |
| pumps = [ | |
| asyncio.create_task(_pump_iterable(iterable, queue)) for iterable in iterables | |
| ] | |
| stopped_count = 0 | |
| try: | |
| while stopped_count < len(iterables): | |
| value = await queue.get() | |
| if value is _MERGE_STOP: | |
| stopped_count += 1 | |
| continue | |
| if isinstance(value, BaseException): | |
| raise value | |
| yield value | |
| finally: | |
| for pump_task in pumps: | |
| pump_task.cancel() | |
| await asyncio.gather(*pumps, return_exceptions=True) | |
| for iterable in iterables: | |
| # A generator is already closed if its pump was | |
| # cancelled in the middle of iteration. For others | |
| # this triggers their cleanup logic. | |
| with contextlib.suppress(RuntimeError): | |
| await iterable.aclose() | |
| _MERGE_STOP = object() | |
| async def _pump_iterable( | |
| iterable: AsyncGenerator[_T, None], | |
| queue: "asyncio.Queue[Any]", | |
| ) -> None: | |
| """Forward all values from a single iterable to the shared queue.""" | |
| try: | |
| async for value in iterable: | |
| await queue.put(value) | |
| except asyncio.CancelledError: | |
| raise | |
| except BaseException as exc: | |
| await queue.put(exc) | |
| else: | |
| await queue.put(_MERGE_STOP) | |
| import asyncio | |
| from collections.abc import AsyncGenerator | |
| from typing import TypeVar | |
| import anyio | |
| from anyio.streams.memory import MemoryObjectSendStream | |
| _T = TypeVar("_T") | |
| async def _start_producer( | |
| stream: AsyncGenerator[_T, None], | |
| send_stream: MemoryObjectSendStream[_T], | |
| ) -> None: | |
| async with send_stream: | |
| async for val in stream: | |
| await send_stream.send(val) | |
| async def merge_async_iterables( | |
| *streams: AsyncGenerator[_T, None], | |
| ) -> AsyncGenerator[_T, None]: | |
| send_stream, receive_stream = anyio.create_memory_object_stream[_T]() | |
| async with anyio.create_task_group() as tg, send_stream, receive_stream: | |
| for stream in streams: | |
| tg.start_soon(_start_producer, stream, send_stream.clone()) | |
| # We close the original send stream, | |
| # to only have copies. When all of them are closed, | |
| # the stream will end. | |
| send_stream.close() | |
| async with receive_stream: | |
| async for message in receive_stream: | |
| yield message | |
It uses anyio primitives and should be a bit more robust. Also, we don't have to track manually any sentinel objects and stuff like that.
Problem
Core taskiq binds one worker process to a single broker with a single
listen()generator. Running multiple queues today means either multipleworker deployments or relying on per-broker multi-queue support (which
only some broker packages have). Multi-queue support in core is still an
open discussion (TRFC-2, org discussion #448), with draft PRs #486/#624.
Solution
Additive
MultiBroker: a composite broker wrapping N real brokers(any
AsyncBrokerimplementations — taskiq-aio-pika, taskiq-redis,taskiq-aio-kafka, etc.) so a single
taskiq workerconsumes all ofthem. No changes to the CLI, the Receiver, or external packages.
What it does
listen()merges listen-generators of all inner brokers into a singlestream (new
merge_async_iterablesutil intaskiq.utils);kick()routes by thequeue_namelabel when aroutingmapping isconfigured, otherwise uses the default broker;
startup()/shutdown()delegate to all inner brokers; one failingshutdown doesn't prevent the rest from stopping;
is_worker_process/is_scheduler_processpropagate, so innerbrokers fire their WORKER_* lifecycle events;
find_task()/get_all_tasks()aggregate task registries, so tasksmay be registered on any inner broker;
with_result_backend()propagates the backend to inner brokers to keepclient-side polling and worker-side saving in sync.
Notes
task_queuesof taskiq-aio-pika >= 0.6.0 keeps a single connectionand remains preferable;
MultiBrokertargets the multi-broker /multi-cluster case (documented, including the one-connection-per-broker
trade-off).
Queuedataclass / ABCcontract changes) — it solves the "one worker, many queues/brokers"
use case additively and can serve as a building block for the full
TRFC-2 design.