Skip to content

Commit 71868f9

Browse files
committed
add: stream subscriber mapping
1 parent 50c8e08 commit 71868f9

6 files changed

Lines changed: 246 additions & 4 deletions

File tree

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,39 @@ async def regular_task() -> None:
8787
await regular_task.kiq()
8888
```
8989

90+
## Stream topics
91+
92+
You can subscribe an existing task to a Kafka topic with raw messages.
93+
Messages from subscribed stream topics are wrapped into regular taskiq
94+
messages before execution, so result backends and worker middlewares keep
95+
working as usual.
96+
97+
```python
98+
import json
99+
100+
from taskiq_aio_kafka import AioKafkaBroker
101+
102+
broker = AioKafkaBroker(
103+
bootstrap_servers="localhost",
104+
kafka_topic="taskiq-topic",
105+
)
106+
107+
108+
@broker.task
109+
async def process_user_created(event: dict[str, object]) -> None:
110+
print(event)
111+
112+
113+
broker.subscribe(
114+
"users.created",
115+
process_user_created,
116+
decoder=json.loads,
117+
)
118+
```
119+
120+
The decoded value is passed as the first task argument. Stream messages also
121+
receive the `taskiq-stream` label with the source topic name.
122+
90123
## Configuration
91124

92125
AioKafkaBroker parameters:

taskiq_aio_kafka/broker.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,21 @@
66
from typing import Any, TypeVar, overload
77

88
from aiokafka import AIOKafkaConsumer, AIOKafkaProducer
9+
from aiokafka.structs import ConsumerRecord
910
from kafka.admin import KafkaAdminClient, NewTopic
1011
from kafka.coordinator.assignors.roundrobin import RoundRobinPartitionAssignor
1112
from kafka.partitioner.default import DefaultPartitioner
1213
from taskiq import AsyncResultBackend, BrokerMessage
1314
from taskiq.abc.broker import AsyncBroker
15+
from taskiq.decor import AsyncTaskiqDecoratedTask
16+
from taskiq.message import TaskiqMessage
1417
from typing_extensions import ParamSpec
1518

16-
from .constants import TASK_TOPIC_LABEL
19+
from .constants import TASK_STREAM_LABEL, TASK_TOPIC_LABEL
1720
from .decorated_task import AioKafkaDecoratedTask
1821
from .exceptions import WrongAioKafkaBrokerParametersError
1922
from .models import KafkaConsumerParameters, KafkaProducerParameters
23+
from .subscriber import StreamDecoder, StreamSubscriber
2024
from .topic import Topic
2125
from .types import TopicType
2226
from .utils import get_topic_name
@@ -79,6 +83,7 @@ def __init__( # noqa: PLR0913
7983
get_topic_name(topic),
8084
topic,
8185
)
86+
self._stream_subscribers: dict[str, StreamSubscriber] = {}
8287

8388
self._aiokafka_producer_params: KafkaProducerParameters = (
8489
KafkaProducerParameters()
@@ -165,6 +170,30 @@ def configure_consumer(self, **consumer_parameters: Any) -> None:
165170
**consumer_parameters,
166171
)
167172

173+
@staticmethod
174+
def _default_stream_decoder(message: bytes) -> bytes:
175+
return message
176+
177+
def subscribe(
178+
self,
179+
topic: TopicType,
180+
task: AsyncTaskiqDecoratedTask[Any, Any],
181+
decoder: StreamDecoder | None = None,
182+
**labels: Any,
183+
) -> None:
184+
"""Subscribe task to raw Kafka topic messages."""
185+
topic_name = get_topic_name(topic)
186+
if topic_name in self._stream_subscribers:
187+
error_message = f"Topic {topic_name!r} is already subscribed."
188+
raise ValueError(error_message)
189+
190+
self._kafka_topics.setdefault(topic_name, topic)
191+
self._stream_subscribers[topic_name] = StreamSubscriber(
192+
task_name=task.task_name,
193+
decoder=decoder or self._default_stream_decoder,
194+
labels=labels,
195+
)
196+
168197
@overload
169198
def task(
170199
self,
@@ -347,4 +376,30 @@ async def listen(
347376
raise ValueError("Please run startup before listening.")
348377

349378
async for raw_kafka_message in self._aiokafka_consumer:
350-
yield raw_kafka_message.value
379+
subscriber = self._stream_subscribers.get(raw_kafka_message.topic)
380+
if subscriber is None:
381+
yield raw_kafka_message.value
382+
continue
383+
384+
yield self._build_stream_message(raw_kafka_message, subscriber)
385+
386+
def _build_stream_message(
387+
self,
388+
raw_kafka_message: ConsumerRecord[Any, bytes],
389+
subscriber: StreamSubscriber,
390+
) -> bytes:
391+
raw_value = raw_kafka_message.value
392+
decoded_value = subscriber.decoder(raw_value)
393+
labels = {
394+
TASK_STREAM_LABEL: raw_kafka_message.topic,
395+
**subscriber.labels,
396+
}
397+
message = TaskiqMessage(
398+
task_id=self.id_generator(),
399+
task_name=subscriber.task_name,
400+
labels=labels,
401+
labels_types={},
402+
args=[decoded_value],
403+
kwargs={},
404+
)
405+
return self.formatter.dumps(message).message

taskiq_aio_kafka/constants.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
__all__ = ("TASK_TOPIC_LABEL",)
1+
__all__ = ("TASK_STREAM_LABEL", "TASK_TOPIC_LABEL")
22

3+
TASK_STREAM_LABEL = "taskiq-stream"
34
TASK_TOPIC_LABEL = "taskiq_aio_kafka_topic"

taskiq_aio_kafka/subscriber.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
__all__ = ("StreamDecoder", "StreamSubscriber")
2+
3+
from collections.abc import Callable
4+
from dataclasses import dataclass, field
5+
from typing import Any
6+
7+
StreamDecoder = Callable[[bytes], Any]
8+
9+
10+
@dataclass(frozen=True)
11+
class StreamSubscriber:
12+
"""Kafka stream subscriber bound to a taskiq task."""
13+
14+
task_name: str
15+
decoder: StreamDecoder
16+
labels: dict[str, Any] = field(default_factory=dict)

tests/test_broker_multi_topic.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
from unittest.mock import Mock
33

44
import pytest
5+
from aiokafka.structs import ConsumerRecord
56
from kafka.admin import KafkaAdminClient, NewTopic
67
from taskiq import BrokerMessage
78

89
from taskiq_aio_kafka.broker import AioKafkaBroker
10+
from taskiq_aio_kafka.constants import TASK_STREAM_LABEL
911
from taskiq_aio_kafka.topic import Topic
1012

1113

@@ -40,13 +42,53 @@ async def stop(self) -> None:
4042
"""Stop consumer."""
4143

4244

45+
class _ConsumerMock:
46+
"""Kafka consumer mock."""
47+
48+
def __init__(self, messages: list[ConsumerRecord[None, bytes]]) -> None:
49+
self._messages = iter(messages)
50+
51+
def __aiter__(self) -> "_ConsumerMock":
52+
return self
53+
54+
async def __anext__(self) -> ConsumerRecord[None, bytes]:
55+
"""Return next message."""
56+
try:
57+
return next(self._messages)
58+
except StopIteration as exc:
59+
raise StopAsyncIteration from exc
60+
61+
4362
def get_admin_client_mock() -> KafkaAdminClient:
4463
"""Get kafka admin client mock."""
4564
admin_client = Mock(spec=KafkaAdminClient)
4665
admin_client.list_topics.return_value = []
4766
return admin_client
4867

4968

69+
def build_consumer_record(topic: str, value: bytes) -> ConsumerRecord[None, bytes]:
70+
"""Build Kafka consumer record."""
71+
return ConsumerRecord(
72+
topic=topic,
73+
partition=0,
74+
offset=0,
75+
timestamp=0,
76+
timestamp_type=0,
77+
key=None,
78+
value=value,
79+
checksum=None,
80+
serialized_key_size=0,
81+
serialized_value_size=len(value),
82+
headers=[],
83+
)
84+
85+
86+
async def get_first_task(broker: AioKafkaBroker) -> bytes: # type: ignore[return]
87+
"""Get first message from the topic."""
88+
async for message in broker.listen():
89+
return message
90+
91+
5092
async def test_task_topic_is_used_for_kick() -> None:
5193
"""Test that task is sent to its declared topic."""
5294
broker = AioKafkaBroker(
@@ -252,3 +294,99 @@ def create_consumer(
252294
await broker.shutdown()
253295

254296
assert consumer_topics == ("default-topic", "extra-topic")
297+
298+
299+
async def test_subscribe_wraps_raw_topic_message() -> None:
300+
"""Test that raw Kafka messages are wrapped as taskiq messages."""
301+
broker = AioKafkaBroker(
302+
bootstrap_servers="localhost",
303+
kafka_topic="default-topic",
304+
kafka_admin_client=get_admin_client_mock(),
305+
)
306+
307+
@broker.task
308+
async def test_task(message: str) -> None:
309+
assert message
310+
311+
broker.subscribe(
312+
"stream-topic",
313+
test_task,
314+
decoder=lambda message: message.decode(),
315+
)
316+
broker._aiokafka_consumer = _ConsumerMock(
317+
[build_consumer_record("stream-topic", b"raw-message")],
318+
)
319+
broker._is_consumer_started = True
320+
321+
received_message = await get_first_task(broker)
322+
taskiq_message = broker.formatter.loads(received_message)
323+
324+
assert taskiq_message.task_name == test_task.task_name
325+
assert taskiq_message.args == ["raw-message"]
326+
assert taskiq_message.kwargs == {}
327+
assert taskiq_message.labels[TASK_STREAM_LABEL] == "stream-topic"
328+
329+
330+
async def test_subscribe_accepts_topic_object_and_custom_labels() -> None:
331+
"""Test that stream subscriptions can use Topic objects."""
332+
broker = AioKafkaBroker(
333+
bootstrap_servers="localhost",
334+
kafka_topic="default-topic",
335+
kafka_admin_client=get_admin_client_mock(),
336+
)
337+
stream_topic = Topic("stream-topic")
338+
339+
@broker.task
340+
async def test_task(message: bytes) -> None:
341+
assert message
342+
343+
broker.subscribe(stream_topic, test_task, source="external")
344+
broker._aiokafka_consumer = _ConsumerMock(
345+
[build_consumer_record(stream_topic.name, b"raw-message")],
346+
)
347+
broker._is_consumer_started = True
348+
349+
received_message = await get_first_task(broker)
350+
taskiq_message = broker.formatter.loads(received_message)
351+
352+
assert taskiq_message.task_name == test_task.task_name
353+
assert taskiq_message.args == ["raw-message"]
354+
assert taskiq_message.labels == {
355+
TASK_STREAM_LABEL: stream_topic.name,
356+
"source": "external",
357+
}
358+
359+
360+
def test_subscribe_adds_topic_to_broker_topics() -> None:
361+
"""Test that subscribed topic is included in listened topics."""
362+
broker = AioKafkaBroker(
363+
bootstrap_servers="localhost",
364+
kafka_topic="default-topic",
365+
kafka_admin_client=get_admin_client_mock(),
366+
)
367+
368+
@broker.task
369+
async def test_task(message: bytes) -> None:
370+
assert message
371+
372+
broker.subscribe("stream-topic", test_task)
373+
374+
assert set(broker._kafka_topics) == {"default-topic", "stream-topic"}
375+
376+
377+
def test_subscribe_rejects_duplicate_topics() -> None:
378+
"""Test that stream topic cannot be subscribed twice."""
379+
broker = AioKafkaBroker(
380+
bootstrap_servers="localhost",
381+
kafka_topic="default-topic",
382+
kafka_admin_client=get_admin_client_mock(),
383+
)
384+
385+
@broker.task
386+
async def test_task(message: bytes) -> None:
387+
assert message
388+
389+
broker.subscribe("stream-topic", test_task)
390+
391+
with pytest.raises(ValueError, match="already subscribed"):
392+
broker.subscribe("stream-topic", test_task)

uv.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)