Skip to content

Add MultiBroker: single worker consuming multiple queues and brokers - #723

Draft
nkhitrov wants to merge 6 commits into
taskiq-python:masterfrom
nkhitrov:feature/multi-broker
Draft

nkhitrov wants to merge 6 commits into
taskiq-python:masterfrom
nkhitrov:feature/multi-broker

Conversation

@nkhitrov

Copy link
Copy Markdown
Contributor

Problem

Core taskiq binds one worker process to a single broker with a single
listen() generator. Running multiple queues today means either multiple
worker 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 AsyncBroker implementations — taskiq-aio-pika, taskiq-redis,
taskiq-aio-kafka, etc.) so a single taskiq worker consumes all of
them
. No changes to the CLI, the Receiver, or external packages.

fast = AioPikaBroker(url, task_queues=[Queue(name="fast")])
slow = AioPikaBroker(url, task_queues=[Queue(name="slow")])
broker = MultiBroker(fast, slow)
# taskiq worker proj.broker:broker proj.tasks  ->  one worker, two queues

What it does

  • listen() merges listen-generators of all inner brokers into a single
    stream (new merge_async_iterables util in taskiq.utils);
  • kick() routes by the queue_name label when a routing mapping is
    configured, otherwise uses the default broker;
  • startup()/shutdown() delegate to all inner brokers; one failing
    shutdown doesn't prevent the rest from stopping;
  • is_worker_process / is_scheduler_process propagate, so inner
    brokers fire their WORKER_* lifecycle events;
  • find_task() / get_all_tasks() aggregate task registries, so tasks
    may be registered on any inner broker;
  • with_result_backend() propagates the backend to inner brokers to keep
    client-side polling and worker-side saving in sync.

Notes

  • For queues all living in a single RabbitMQ broker, native
    task_queues of taskiq-aio-pika >= 0.6.0 keeps a single connection
    and remains preferable; MultiBroker targets the multi-broker /
    multi-cluster case (documented, including the one-connection-per-broker
    trade-off).
  • This is intentionally narrower than TRFC-2 (no Queue dataclass / ABC
    contract 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.

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

codecov Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.44961% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.02%. Comparing base (9214379) to head (69d56b1).

Files with missing lines Patch % Lines
taskiq/brokers/multi_broker.py 97.87% 2 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +76 to +81
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__}.",
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +69 to +72
*brokers: AsyncBroker,
default_broker: AsyncBroker | None = None,
routing: "dict[str, AsyncBroker] | None" = None,
routing_label: str = "queue_name",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you have routing already, you can remove brokers and default_broker arguments and make signature of something like that:

Suggested change
*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",

Comment thread taskiq/utils.py
try:
async for value in iterable:
await queue.put(value)
except asyncio.CancelledError:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can simplify this pump function using anyio, since it's in our dependencies.

Comment thread taskiq/utils.py
await iterable.aclose()


_MERGE_STOP = object()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Such things better define at the top.

Comment thread taskiq/utils.py
Comment on lines +36 to +99
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that there is a simpler solution:

Suggested change
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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants