userver: en/testsuite/databases/kafka/classes.py Source File
Loading...
Searching...
No Matches
classes.py
1import asyncio
2import dataclasses
3import logging
4import typing
5
6import aiokafka
7
8logger = logging.getLogger(__name__)
9
10
11@dataclasses.dataclass(frozen=True)
13 """Kafka service start settings"""
14
15 server_host: str
16 server_port: int
17 controller_port: int
18 custom_start_topics: dict[str, int]
19
20
21"""Kafka bootstrap servers URLs list"""
22BootstrapServers = list[str]
23
24"""Kafka message header"""
25Header = tuple[str, bytes]
26
27"""
28 Kafka headers sequence.
29 The order is taken into account.
30 Duplicate keys are allowed.
31"""
32Headers = typing.Sequence[Header]
33
34
35class KafkaDisabledError(Exception):
36 pass
37
38
39class KafkaProducer:
40 """
41 Kafka producer wrapper.
42 """
43
44 def __init__(self, enabled: bool, bootstrap_servers: str):
45 self._enabled = enabled
46 self._bootstrap_servers = bootstrap_servers
47
48 async def start(self):
49 if self._enabled:
50 self.producer = aiokafka.AIOKafkaProducer(
51 bootstrap_servers=self._bootstrap_servers,
52 linger_ms=0, # turn off message buffering
53 )
54 await self.producer.start()
55
56 async def send(
57 self,
58 topic: str,
59 key: str | bytes,
60 value: str | bytes,
61 partition: int | None = None,
62 headers: Headers | None = None,
63 ):
64 """
65 Sends the message (``value``) to ``topic`` by ``key`` and,
66 optionally, to a given ``partition`` and waits until it is delivered.
67 If the call is successfully awaited,
68 message is guaranteed to be delivered.
69
70 :param topic: topic name.
71 :param key: key. Needed to determine message's partition.
72 :param value: message payload. Must be valid UTF-8.
73 :param partition: Optional message partition.
74 If not passed, determined by internal partitioner
75 depends on key's hash.
76 """
77
78 resp_future = await self.send_async(
79 topic, key, value, partition, headers
80 )
81 await resp_future
82
83 async def send_async(
84 self,
85 topic: str,
86 key: str | bytes,
87 value: str | bytes,
88 partition: int | None = None,
89 headers: Headers | None = None,
90 ):
91 """
92 Sends the message (``value``) to ``topic`` by ``key`` and,
93 optionally, to a given ``partition`` and
94 returns the future for message delivery awaiting.
95
96 :param topic: topic name.
97 :param key: key. Needed to determine message's partition.
98 :param value: message payload. Must be valid UTF-8.
99 :param partition: Optional message partition.
100 If not passed, determined by internal partitioner
101 depends on key's hash.
102 """
103
104 if not self._enabled:
105 raise KafkaDisabledError
106
107 return await self.producer.send(
108 topic=topic,
109 value=value if isinstance(value, bytes) else value.encode(),
110 key=key if isinstance(key, bytes) else key.encode(),
111 partition=partition,
112 headers=headers,
113 )
114
115 async def _flush(self):
116 logger.info('Flusing produced messages')
117 await self.producer.flush()
118
119 async def aclose(self):
120 if self._enabled:
121 await self.producer.stop()
122
123
124class ConsumedMessage:
125 """Wrapper for consumed record."""
126
127 topic: str
128 key_raw: bytes
129 value_raw: bytes
130 partition: int
131 offset: int
132 headers_raw: Headers
133
134 def __init__(self, record: aiokafka.ConsumerRecord):
135 self.topic = record.topic
136 self.key_raw = record.key
137 self.value_raw = record.value
138 self.partition = record.partition
139 self.offset = record.offset
140 self.headers_raw = record.headers
141
142 @property
143 def key(self) -> str:
144 return self.key_raw.decode()
145
146 @property
147 def value(self) -> str:
148 return self.value_raw.decode()
149
150 @property
151 def headers(self) -> list[Header]:
152 return list(self.headers_raw)
153
154
155class KafkaConsumer:
156 """
157 Kafka balanced consumer wrapper.
158 All consumers are created with the same group.id,
159 after each test consumer commits offsets for all consumed messages.
160 This is needed to make tests independent.
161 """
162
163 def __init__(self, enabled: bool, bootstrap_servers):
164 self._enabled = enabled
165 self._bootstrap_servers = bootstrap_servers
166 self._subscribed_topics: list[str] = []
167
168 async def start(self):
169 if self._enabled:
170 self.consumer = aiokafka.AIOKafkaConsumer(
171 group_id='Test-group',
172 bootstrap_servers=self._bootstrap_servers,
173 auto_offset_reset='earliest',
174 enable_auto_commit=False,
175 )
176 await self.consumer.start()
177
178 def _subscribe(self, topics: list[str]):
179 if not self._enabled:
180 raise KafkaDisabledError
181
182 to_subscribe: list[str] = []
183 for topic in topics:
184 if topic not in self._subscribed_topics:
185 to_subscribe.append(topic)
186
187 if to_subscribe:
188 logger.info('Subscribing to [%s]', ','.join(to_subscribe))
189 self.consumer.subscribe(to_subscribe)
190 self._subscribed_topics.extend(to_subscribe)
191
192 async def _commit(self):
193 if not self._enabled:
194 raise KafkaDisabledError
195
196 if self._subscribed_topics:
197 await self.consumer.commit()
198
199 async def _unsubscribe(self):
200 await self._commit()
201
202 if self._subscribed_topics:
203 logger.info('Unsubscribing from all topics')
204 self.consumer.unsubscribe()
205 self._subscribed_topics = []
206
207 async def receive_one(
208 self, topics: list[str], timeout: float = 20.0
209 ) -> ConsumedMessage:
210 """
211 Waits until one message are consumed.
212
213 :param topics: list of topics to read messages from.
214 :param timeout: timeout to stop waiting. Default is 20 seconds.
215
216 :returns: :py:class:`ConsumedMessage`
217 """
218 if not self._enabled:
219 raise KafkaDisabledError
220
221 self._subscribe(topics)
222
223 async def _do_receive():
224 record: aiokafka.ConsumerRecord = await self.consumer.getone()
225 return ConsumedMessage(record)
226
227 return await asyncio.wait_for(_do_receive(), timeout=timeout)
228
229 async def receive_batch(
230 self,
231 topics: list[str],
232 max_batch_size: int | None,
233 timeout: float = 3.0,
234 ) -> list[ConsumedMessage]:
235 """
236 Waits until either ``max_batch_size`` messages are consumed or
237 ``timeout`` expired.
238
239 :param topics: list of topics to read messages from.
240 :max_batch_size: maximum number of consumed messages.
241 :param timeout: timeout to stop waiting. Default is 3 seconds.
242
243 :returns: :py:class:`List[ConsumedMessage]`
244 """
245 if not self._enabled:
246 raise KafkaDisabledError
247
248 self._subscribe(topics)
249
250 records: dict[
251 aiokafka.TopicPartition, list[aiokafka.ConsumerRecord]
252 ] = await self.consumer.getmany(
253 timeout_ms=int(timeout * 1000), max_records=max_batch_size
254 )
255
256 return list(map(ConsumedMessage, sum(records.values(), [])))
257
258 async def aclose(self):
259 if self._enabled:
260 await self._unsubscribe()
261 await self.consumer.stop()