Skip to content

Commit 92d5bb4

Browse files
authored
Merge pull request #10 from taskiq-python/MultyTopicBrocker
add: multi Topic support
2 parents c926e20 + 278c09a commit 92d5bb4

11 files changed

Lines changed: 591 additions & 35 deletions

File tree

README.md

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,65 @@ broker.configure_producer(request_timeout_ms=100000)
3535
broker.configure_consumer(group_id="the best group ever.")
3636
```
3737

38+
## Multiple topics
39+
40+
By default `AioKafkaBroker` sends all tasks to `kafka_topic`.
41+
You can also configure the broker to listen to multiple topics and bind
42+
different tasks to different default topics.
43+
44+
```python
45+
from taskiq_aio_kafka import AioKafkaBroker
46+
from taskiq_aio_kafka.topic import Topic
47+
48+
broker = AioKafkaBroker(
49+
bootstrap_servers="localhost",
50+
kafka_topic="default-topic",
51+
kafka_topics=[
52+
Topic("emails"),
53+
Topic("reports"),
54+
],
55+
)
56+
57+
58+
@broker.task_with_topic("emails")
59+
async def send_email(user_id: int) -> None:
60+
print(f"Send email to {user_id}")
61+
62+
63+
@broker.task_with_topic("reports")
64+
async def build_report(report_id: int) -> None:
65+
print(f"Build report {report_id}")
66+
```
67+
68+
In this example the worker listens to `default-topic`, `emails`, and `reports`.
69+
When you call `send_email.kiq(...)`, the task is sent to `emails` by default.
70+
When you call `build_report.kiq(...)`, the task is sent to `reports` by default.
71+
72+
You can override a task topic for a single kick with `kicker().with_topic(...)`:
73+
74+
```python
75+
await send_email.kicker().with_topic("reports").kiq(user_id=1)
76+
```
77+
78+
Tasks without a custom topic keep the old behavior and are sent to `kafka_topic`.
79+
The regular `@broker.task` decorator keeps the standard taskiq labels behavior.
80+
81+
```python
82+
@broker.task
83+
async def regular_task() -> None:
84+
print("This task goes to default-topic.")
85+
86+
87+
await regular_task.kiq()
88+
```
89+
3890
## Configuration
3991

4092
AioKafkaBroker parameters:
4193
* `bootstrap_servers` - url to kafka nodes. Can be either string or list of strings.
42-
* `kafka_topic` - custom topic in kafka.
94+
* `kafka_topic` - default topic in kafka.
95+
* `kafka_topics` - additional topics that worker should listen to.
4396
* `result_backend` - custom result backend.
4497
* `task_id_generator` - custom task_id genertaor.
4598
* `kafka_admin_client` - custom `kafka` admin client.
46-
* `delete_topic_on_shutdown` - flag to delete topic on broker shutdown.
99+
* `delete_topic_on_shutdown` - flag to delete topics on broker shutdown.

taskiq_aio_kafka/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Taskiq integration with aiokafka."""
22

3-
from taskiq_aio_kafka.broker import AioKafkaBroker
3+
__all__ = ("AioKafkaBroker",)
44

5-
__all__ = ["AioKafkaBroker"]
5+
from taskiq_aio_kafka.broker import AioKafkaBroker

taskiq_aio_kafka/broker.py

Lines changed: 162 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
1+
__all__ = ("AioKafkaBroker",)
2+
13
import asyncio
2-
from collections.abc import AsyncGenerator, Callable
4+
from collections.abc import AsyncGenerator, Callable, Iterable
35
from logging import getLogger
4-
from typing import Any, TypeVar
6+
from typing import Any, TypeVar, overload
57

68
from aiokafka import AIOKafkaConsumer, AIOKafkaProducer
79
from kafka.admin import KafkaAdminClient, NewTopic
810
from kafka.coordinator.assignors.roundrobin import RoundRobinPartitionAssignor
911
from kafka.partitioner.default import DefaultPartitioner
1012
from taskiq import AsyncResultBackend, BrokerMessage
1113
from taskiq.abc.broker import AsyncBroker
14+
from typing_extensions import ParamSpec
1215

13-
from taskiq_aio_kafka.exceptions import WrongAioKafkaBrokerParametersError
14-
from taskiq_aio_kafka.models import KafkaConsumerParameters, KafkaProducerParameters
16+
from .constants import TASK_TOPIC_LABEL
17+
from .decorated_task import AioKafkaDecoratedTask
18+
from .exceptions import WrongAioKafkaBrokerParametersError
19+
from .models import KafkaConsumerParameters, KafkaProducerParameters
20+
from .topic import Topic
21+
from .types import TopicType
22+
from .utils import get_topic_name
1523

1624
_T = TypeVar("_T")
25+
_FuncParams = ParamSpec("_FuncParams")
26+
_ReturnType = TypeVar("_ReturnType")
1727

1828

1929
logger = getLogger("taskiq.kafka_broker")
@@ -22,10 +32,13 @@
2232
class AioKafkaBroker(AsyncBroker):
2333
"""Broker that works with Kafka."""
2434

35+
task_topic_label = TASK_TOPIC_LABEL
36+
2537
def __init__( # noqa: PLR0913
2638
self,
2739
bootstrap_servers: str | list[str] | None,
28-
kafka_topic: NewTopic | None = None,
40+
kafka_topic: TopicType | None = None,
41+
kafka_topics: Iterable[TopicType] | None = None,
2942
result_backend: AsyncResultBackend[_T] | None = None,
3043
task_id_generator: Callable[[], str] | None = None,
3144
kafka_admin_client: KafkaAdminClient | None = None,
@@ -35,7 +48,8 @@ def __init__( # noqa: PLR0913
3548
"""Construct a new broker.
3649
3750
:param bootstrap_servers: string with url to kafka or list with urls.
38-
:param kafka_topic: kafka topic.
51+
:param kafka_topic: default kafka topic.
52+
:param kafka_topics: all kafka topics to listen.
3953
:param result_backend: custom result backend.
4054
:param task_id_generator: custom task_id generator.
4155
:param kafka_admin_client: configured KafkaAdminClient.
@@ -46,6 +60,7 @@ def __init__( # noqa: PLR0913
4660
aiokafka_consumer were specified but bootstrap_servers wasn't specified.
4761
"""
4862
super().__init__(result_backend, task_id_generator)
63+
self.decorator_class = AioKafkaDecoratedTask
4964

5065
if kafka_admin_client and not bootstrap_servers:
5166
raise WrongAioKafkaBrokerParametersError
@@ -54,11 +69,16 @@ def __init__( # noqa: PLR0913
5469

5570
self._loop: asyncio.AbstractEventLoop | None = loop
5671

57-
self._kafka_topic: NewTopic = kafka_topic or NewTopic(
58-
name="taskiq_topic",
59-
num_partitions=1,
60-
replication_factor=1,
61-
)
72+
self._kafka_topic: NewTopic = self._normalize_default_topic(kafka_topic)
73+
self._kafka_topics: dict[str, TopicType] = {
74+
self._kafka_topic.name: self._kafka_topic,
75+
}
76+
if kafka_topics is not None:
77+
for topic in kafka_topics:
78+
self._kafka_topics.setdefault(
79+
get_topic_name(topic),
80+
topic,
81+
)
6282

6383
self._aiokafka_producer_params: KafkaProducerParameters = (
6484
KafkaProducerParameters()
@@ -83,6 +103,44 @@ def __init__( # noqa: PLR0913
83103
self._is_producer_started = False
84104
self._is_consumer_started = False
85105

106+
@classmethod
107+
def _normalize_default_topic(
108+
cls,
109+
kafka_topic: TopicType | None,
110+
) -> NewTopic:
111+
if kafka_topic is None:
112+
return NewTopic(
113+
name="taskiq_topic",
114+
num_partitions=1,
115+
replication_factor=1,
116+
)
117+
if isinstance(kafka_topic, str):
118+
return NewTopic(
119+
name=kafka_topic,
120+
num_partitions=1,
121+
replication_factor=1,
122+
)
123+
if isinstance(kafka_topic, Topic):
124+
if kafka_topic.topic_config.declare:
125+
return kafka_topic.new_topic()
126+
return NewTopic(
127+
name=kafka_topic.name,
128+
num_partitions=1,
129+
replication_factor=1,
130+
)
131+
return kafka_topic
132+
133+
@classmethod
134+
def _get_declaration_topic(
135+
cls,
136+
topic: TopicType,
137+
) -> NewTopic | None:
138+
if isinstance(topic, NewTopic):
139+
return topic
140+
if isinstance(topic, Topic) and topic.topic_config.declare:
141+
return topic.new_topic()
142+
return None
143+
86144
def configure_producer(self, **producer_parameters: Any) -> None:
87145
"""Configure kafka producer.
88146
@@ -107,21 +165,93 @@ def configure_consumer(self, **consumer_parameters: Any) -> None:
107165
**consumer_parameters,
108166
)
109167

168+
@overload
169+
def task(
170+
self,
171+
task_name: Callable[_FuncParams, _ReturnType],
172+
**labels: Any,
173+
) -> AioKafkaDecoratedTask[_FuncParams, _ReturnType]: ...
174+
175+
@overload
176+
def task(
177+
self,
178+
task_name: str | None = None,
179+
**labels: Any,
180+
) -> Callable[
181+
[Callable[_FuncParams, _ReturnType]],
182+
AioKafkaDecoratedTask[_FuncParams, _ReturnType],
183+
]: ...
184+
185+
def task(
186+
self,
187+
task_name: str | Callable[..., Any] | None = None,
188+
**labels: Any,
189+
) -> Any:
190+
"""Decorate function."""
191+
if callable(task_name):
192+
return super().task(task_name, **labels)
193+
194+
return super().task(
195+
task_name=task_name,
196+
**labels,
197+
)
198+
199+
@overload
200+
def task_with_topic(
201+
self,
202+
topic: TopicType,
203+
task_name: Callable[_FuncParams, _ReturnType],
204+
**labels: Any,
205+
) -> AioKafkaDecoratedTask[_FuncParams, _ReturnType]: ...
206+
207+
@overload
208+
def task_with_topic(
209+
self,
210+
topic: TopicType,
211+
task_name: str | None = None,
212+
**labels: Any,
213+
) -> Callable[
214+
[Callable[_FuncParams, _ReturnType]],
215+
AioKafkaDecoratedTask[_FuncParams, _ReturnType],
216+
]: ...
217+
218+
def task_with_topic(
219+
self,
220+
topic: TopicType,
221+
task_name: str | Callable[..., Any] | None = None,
222+
**labels: Any,
223+
) -> Any:
224+
"""Decorate function and bind it to a kafka topic by default."""
225+
topic_name = get_topic_name(topic)
226+
self._kafka_topics.setdefault(topic_name, topic)
227+
labels[self.task_topic_label] = topic_name
228+
229+
if callable(task_name):
230+
return super().task(task_name, **labels)
231+
232+
return super().task(
233+
task_name=task_name,
234+
**labels,
235+
)
236+
110237
async def startup(self) -> None:
111238
"""Setup AIOKafkaProducer, AIOKafkaConsumer and kafka topics.
112239
113-
We will have 2 topics for default and high priority.
114-
115240
Also we need to create AIOKafkaProducer and AIOKafkaConsumer
116241
if there are no producer and consumer passed.
117242
"""
118243
await super().startup()
119-
available_condition: bool = (
120-
self._kafka_topic.name not in self._kafka_admin_client.list_topics()
121-
)
122-
if available_condition:
244+
existed_topic_names = set(self._kafka_admin_client.list_topics())
245+
246+
new_topics = []
247+
for topic in self._kafka_topics.values():
248+
new_topic = self._get_declaration_topic(topic)
249+
if new_topic is not None and new_topic.name not in existed_topic_names:
250+
new_topics.append(new_topic)
251+
252+
if new_topics:
123253
self._kafka_admin_client.create_topics(
124-
new_topics=[self._kafka_topic],
254+
new_topics=new_topics,
125255
validate_only=False,
126256
)
127257

@@ -145,7 +275,7 @@ async def startup(self) -> None:
145275
partition_assignment_strategy
146276
)
147277
self._aiokafka_consumer = AIOKafkaConsumer(
148-
self._kafka_topic.name,
278+
*self._kafka_topics,
149279
bootstrap_servers=self._bootstrap_servers,
150280
loop=self._loop,
151281
**consumer_kwargs,
@@ -166,18 +296,16 @@ async def shutdown(self) -> None:
166296
if self._is_consumer_started:
167297
await self._aiokafka_consumer.stop()
168298

169-
topic_delete_condition: bool = all(
170-
(
171-
self._delete_topic_on_shutdown,
172-
self._kafka_topic.name in self._kafka_admin_client.list_topics(),
173-
),
174-
)
175-
176299
if self._kafka_admin_client:
177-
if topic_delete_condition:
178-
self._kafka_admin_client.delete_topics(
179-
[self._kafka_topic.name],
180-
)
300+
if self._delete_topic_on_shutdown:
301+
existed_topic_names = set(self._kafka_admin_client.list_topics())
302+
topic_names = [
303+
topic_name
304+
for topic_name in self._kafka_topics
305+
if topic_name in existed_topic_names
306+
]
307+
if topic_names:
308+
self._kafka_admin_client.delete_topics(topic_names)
181309
self._kafka_admin_client.close()
182310

183311
async def kick(self, message: BrokerMessage) -> None:
@@ -194,7 +322,10 @@ async def kick(self, message: BrokerMessage) -> None:
194322
if not self._is_producer_started:
195323
raise ValueError("Please run startup before kicking.")
196324

197-
topic_name: str = self._kafka_topic.name
325+
topic_name: str = message.labels.get(
326+
self.task_topic_label,
327+
self._kafka_topic.name,
328+
)
198329

199330
await self._aiokafka_producer.send_and_wait(
200331
topic=topic_name,

taskiq_aio_kafka/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__all__ = ("TASK_TOPIC_LABEL",)
2+
3+
TASK_TOPIC_LABEL = "taskiq_aio_kafka_topic"

taskiq_aio_kafka/decorated_task.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
__all__ = ("AioKafkaDecoratedTask",)
2+
3+
from typing import TypeVar
4+
5+
from taskiq.decor import AsyncTaskiqDecoratedTask
6+
from typing_extensions import ParamSpec
7+
8+
from .kicker import AioKafkaKicker
9+
10+
_FuncParams = ParamSpec("_FuncParams")
11+
_ReturnType = TypeVar("_ReturnType")
12+
13+
14+
class AioKafkaDecoratedTask(AsyncTaskiqDecoratedTask[_FuncParams, _ReturnType]):
15+
"""Taskiq decorated task with kafka-specific kicker."""
16+
17+
def kicker(self) -> AioKafkaKicker[_FuncParams, _ReturnType]:
18+
"""Return kafka-aware kicker."""
19+
return AioKafkaKicker(
20+
task_name=self.task_name,
21+
broker=self.broker,
22+
labels=self.labels,
23+
return_type=self.return_type,
24+
)

0 commit comments

Comments
 (0)