ddkafka 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ddkafka-0.0.1/LICENSE +21 -0
- ddkafka-0.0.1/PKG-INFO +169 -0
- ddkafka-0.0.1/README.md +147 -0
- ddkafka-0.0.1/ddkafka/__init__.py +3 -0
- ddkafka-0.0.1/ddkafka/config.py +149 -0
- ddkafka-0.0.1/ddkafka/consumer.py +232 -0
- ddkafka-0.0.1/ddkafka/consumer_maker.py +177 -0
- ddkafka-0.0.1/ddkafka/producer.py +137 -0
- ddkafka-0.0.1/ddkafka/py.typed +0 -0
- ddkafka-0.0.1/pyproject.toml +59 -0
- ddkafka-0.0.1/pyproject.toml.orig +51 -0
ddkafka-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artem Davydov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ddkafka-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ddkafka
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Kafka helpers: batch consumer, lock-guarded consumer maker and msgpack producer on top of aiokafka
|
|
5
|
+
Keywords: python,kafka,aiokafka,consumer,producer,ddkafka
|
|
6
|
+
Author: davyddd
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: aiokafka>=0.12,<0.15
|
|
16
|
+
Requires-Dist: msgpack>=1.0,<2
|
|
17
|
+
Requires-Dist: ddutils>=0.2.3,<0.3.0
|
|
18
|
+
Requires-Dist: pydantic>=2.0,<3
|
|
19
|
+
Requires-Python: >=3.11, <3.15
|
|
20
|
+
Project-URL: Repository, https://github.com/davyddd/ddkafka
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# ddkafka
|
|
24
|
+
|
|
25
|
+
[](https://pypi.python.org/pypi/ddkafka)
|
|
26
|
+
[](https://pepy.tech/project/ddkafka)
|
|
27
|
+
[](https://github.com/davyddd/ddkafka)
|
|
28
|
+
[](https://app.codecov.io/github/davyddd/ddkafka)
|
|
29
|
+
[](https://github.com/davyddd/ddkafka/blob/main/LICENSE)
|
|
30
|
+
|
|
31
|
+
Kafka helpers: batch consumer, lock-guarded consumer maker and msgpack producer on top of aiokafka
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Install the library using pip:
|
|
36
|
+
```bash
|
|
37
|
+
pip install ddkafka
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
fab build # build the dev image
|
|
46
|
+
fab tests # run pytest
|
|
47
|
+
fab linters # run ruff (with --fix), ty and complexipy
|
|
48
|
+
fab shell # IPython inside the container
|
|
49
|
+
fab bash # bash inside the container
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
|
|
53
|
+
run `copier update` to pull in template updates.
|
|
54
|
+
|
|
55
|
+
## Producer
|
|
56
|
+
|
|
57
|
+
`BaseKafkaProducerRepository` sends events serialized with msgpack from `model_dump(mode='json')`, keyed by
|
|
58
|
+
`idempotent_key`. One `AIOKafkaProducer` is created lazily per subclass and shared by all its instances.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from pydantic import BaseModel
|
|
62
|
+
|
|
63
|
+
from ddkafka.config import ProducerConfig
|
|
64
|
+
from ddkafka.producer import BaseKafkaProducerRepository
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ProfileEvent(BaseModel):
|
|
68
|
+
profile_id: int
|
|
69
|
+
action: str
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def idempotent_key(self) -> str | None:
|
|
73
|
+
return f'{self.profile_id}:{self.action}'
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class EventProducerRepository(BaseKafkaProducerRepository):
|
|
77
|
+
bootstrap_servers = ['kafka:9092']
|
|
78
|
+
config = ProducerConfig(linger_ms=100)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
event_producer_repository_impl = EventProducerRepository()
|
|
82
|
+
|
|
83
|
+
await event_producer_repository_impl.create('profile_events', ProfileEvent(profile_id=1, action='open_app'))
|
|
84
|
+
await event_producer_repository_impl.bulk_create('profile_events', events)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Any object with `idempotent_key` and `model_dump` satisfies the `Producible` protocol; pydantic models only need the
|
|
88
|
+
property.
|
|
89
|
+
|
|
90
|
+
`create` and `bulk_create` return once the record is in the producer's buffer; aiokafka sends buffered records in
|
|
91
|
+
batches (`linger_ms`, `max_batch_size`), and a record the broker rejects is reported with an ERROR log entry. The
|
|
92
|
+
buffer only reaches Kafka while the process runs, so call `stop()` on shutdown (FastAPI lifespan, dramatiq
|
|
93
|
+
`after_worker_shutdown`), otherwise up to `linger_ms` worth of records is lost on SIGTERM:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
await EventProducerRepository.stop()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Consumer
|
|
100
|
+
|
|
101
|
+
`BaseKafkaConsumerRepository[Domain]` reads one partition in batches. Payloads are msgpack dictionaries turned into
|
|
102
|
+
`Domain` with `model_validate`. `get_batches()` polls until the partition runs dry or a poll comes back thinner than
|
|
103
|
+
`batch_size * min_batch_fill_ratio`; `commit()` acknowledges everything yielded so far.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from ddkafka.consumer import BaseKafkaConsumerRepository
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ProfileEventConsumerRepository(BaseKafkaConsumerRepository[ProfileEvent]):
|
|
110
|
+
bootstrap_servers = ['kafka:9092']
|
|
111
|
+
topic = 'profile_events'
|
|
112
|
+
group_id = 'analytics_context'
|
|
113
|
+
batch_size = 100
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
repo = ProfileEventConsumerRepository(partition=0)
|
|
117
|
+
await repo.start()
|
|
118
|
+
try:
|
|
119
|
+
async for batch in repo.get_batches():
|
|
120
|
+
await process(batch)
|
|
121
|
+
await repo.commit()
|
|
122
|
+
finally:
|
|
123
|
+
await repo.stop()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`get_batches(timestamp_threshold_minutes=5)` yields only messages older than five minutes, for consumers that
|
|
127
|
+
deliberately lag behind the producer.
|
|
128
|
+
|
|
129
|
+
Tunables are class attributes: `batch_size`, `poll_timeout_ms`, `min_batch_fill_ratio` and `config`
|
|
130
|
+
(`ConsumerConfig`, passed to `AIOKafkaConsumer` as keyword arguments). Missing or mistyped attributes fail at
|
|
131
|
+
class definition.
|
|
132
|
+
|
|
133
|
+
A record that cannot be deserialized (a schema the consumer does not know, a payload written by something else)
|
|
134
|
+
raises `MessageDeserializationError` with the topic, partition and offset, and the partition stays at that offset
|
|
135
|
+
until the group's offset is moved past it. A consumer whose stream can afford to lose such a record sets
|
|
136
|
+
`skip_undeserializable = True`: the record is logged with its offset and dropped, the rest of the batch goes on.
|
|
137
|
+
|
|
138
|
+
## Consumer maker
|
|
139
|
+
|
|
140
|
+
`KafkaConsumerRepositoryMaker` wraps a consumer in a distributed lock, so one partition is processed by one
|
|
141
|
+
worker at a time across the whole deployment. The lock is anything satisfying `Lockable` (`acquire`, `release`,
|
|
142
|
+
`extend`, `timeout`), for example `RedisLock` from [ddredis](https://github.com/davyddd/ddredis). Entering acquires
|
|
143
|
+
the lock and starts the consumer; every yielded batch is followed by a checkpoint (lock heartbeat + offset commit);
|
|
144
|
+
leaving commits, stops and releases. A lock that is already held raises from `acquire`, a consumer that fails to
|
|
145
|
+
start raises `ConsumerStartError` after releasing the lock.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from ddredis.lock import RedisLock
|
|
149
|
+
|
|
150
|
+
from ddkafka.consumer_maker import KafkaConsumerRepositoryMaker
|
|
151
|
+
|
|
152
|
+
async with KafkaConsumerRepositoryMaker(
|
|
153
|
+
consumer_class=ProfileEventConsumerRepository,
|
|
154
|
+
partition=0,
|
|
155
|
+
lock_class=RedisLock,
|
|
156
|
+
lock_kwargs={'redis_client': redis_client, 'timeout': 300},
|
|
157
|
+
) as maker:
|
|
158
|
+
async for batch in maker.get_batches():
|
|
159
|
+
await process(batch)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Every batch is committed as a whole after the caller is done with it; a batch the caller fails on is not committed
|
|
163
|
+
and is read again next time, so processing must tolerate repeated records (`idempotent_key`). `batch_size` bounds
|
|
164
|
+
how much is reprocessed. `maker.checkpoint()` commits the current batch, so call it only once the batch is processed.
|
|
165
|
+
|
|
166
|
+
With `stream=True` the consumer does not return when the topic runs dry: it keeps the lock alive and polls
|
|
167
|
+
again after `stream_round_pause`, so a cron that starts it acts as a supervisor and messages are picked up as
|
|
168
|
+
they land. The pace must fit the lock: `stream_round_pause` plus the consumer's poll timeout may not exceed half
|
|
169
|
+
of the lock timeout, otherwise the maker refuses at construction.
|
ddkafka-0.0.1/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# ddkafka
|
|
2
|
+
|
|
3
|
+
[](https://pypi.python.org/pypi/ddkafka)
|
|
4
|
+
[](https://pepy.tech/project/ddkafka)
|
|
5
|
+
[](https://github.com/davyddd/ddkafka)
|
|
6
|
+
[](https://app.codecov.io/github/davyddd/ddkafka)
|
|
7
|
+
[](https://github.com/davyddd/ddkafka/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
Kafka helpers: batch consumer, lock-guarded consumer maker and msgpack producer on top of aiokafka
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install the library using pip:
|
|
14
|
+
```bash
|
|
15
|
+
pip install ddkafka
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Development
|
|
19
|
+
|
|
20
|
+
The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
fab build # build the dev image
|
|
24
|
+
fab tests # run pytest
|
|
25
|
+
fab linters # run ruff (with --fix), ty and complexipy
|
|
26
|
+
fab shell # IPython inside the container
|
|
27
|
+
fab bash # bash inside the container
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
|
|
31
|
+
run `copier update` to pull in template updates.
|
|
32
|
+
|
|
33
|
+
## Producer
|
|
34
|
+
|
|
35
|
+
`BaseKafkaProducerRepository` sends events serialized with msgpack from `model_dump(mode='json')`, keyed by
|
|
36
|
+
`idempotent_key`. One `AIOKafkaProducer` is created lazily per subclass and shared by all its instances.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from pydantic import BaseModel
|
|
40
|
+
|
|
41
|
+
from ddkafka.config import ProducerConfig
|
|
42
|
+
from ddkafka.producer import BaseKafkaProducerRepository
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ProfileEvent(BaseModel):
|
|
46
|
+
profile_id: int
|
|
47
|
+
action: str
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def idempotent_key(self) -> str | None:
|
|
51
|
+
return f'{self.profile_id}:{self.action}'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EventProducerRepository(BaseKafkaProducerRepository):
|
|
55
|
+
bootstrap_servers = ['kafka:9092']
|
|
56
|
+
config = ProducerConfig(linger_ms=100)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
event_producer_repository_impl = EventProducerRepository()
|
|
60
|
+
|
|
61
|
+
await event_producer_repository_impl.create('profile_events', ProfileEvent(profile_id=1, action='open_app'))
|
|
62
|
+
await event_producer_repository_impl.bulk_create('profile_events', events)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Any object with `idempotent_key` and `model_dump` satisfies the `Producible` protocol; pydantic models only need the
|
|
66
|
+
property.
|
|
67
|
+
|
|
68
|
+
`create` and `bulk_create` return once the record is in the producer's buffer; aiokafka sends buffered records in
|
|
69
|
+
batches (`linger_ms`, `max_batch_size`), and a record the broker rejects is reported with an ERROR log entry. The
|
|
70
|
+
buffer only reaches Kafka while the process runs, so call `stop()` on shutdown (FastAPI lifespan, dramatiq
|
|
71
|
+
`after_worker_shutdown`), otherwise up to `linger_ms` worth of records is lost on SIGTERM:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
await EventProducerRepository.stop()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Consumer
|
|
78
|
+
|
|
79
|
+
`BaseKafkaConsumerRepository[Domain]` reads one partition in batches. Payloads are msgpack dictionaries turned into
|
|
80
|
+
`Domain` with `model_validate`. `get_batches()` polls until the partition runs dry or a poll comes back thinner than
|
|
81
|
+
`batch_size * min_batch_fill_ratio`; `commit()` acknowledges everything yielded so far.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from ddkafka.consumer import BaseKafkaConsumerRepository
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ProfileEventConsumerRepository(BaseKafkaConsumerRepository[ProfileEvent]):
|
|
88
|
+
bootstrap_servers = ['kafka:9092']
|
|
89
|
+
topic = 'profile_events'
|
|
90
|
+
group_id = 'analytics_context'
|
|
91
|
+
batch_size = 100
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
repo = ProfileEventConsumerRepository(partition=0)
|
|
95
|
+
await repo.start()
|
|
96
|
+
try:
|
|
97
|
+
async for batch in repo.get_batches():
|
|
98
|
+
await process(batch)
|
|
99
|
+
await repo.commit()
|
|
100
|
+
finally:
|
|
101
|
+
await repo.stop()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`get_batches(timestamp_threshold_minutes=5)` yields only messages older than five minutes, for consumers that
|
|
105
|
+
deliberately lag behind the producer.
|
|
106
|
+
|
|
107
|
+
Tunables are class attributes: `batch_size`, `poll_timeout_ms`, `min_batch_fill_ratio` and `config`
|
|
108
|
+
(`ConsumerConfig`, passed to `AIOKafkaConsumer` as keyword arguments). Missing or mistyped attributes fail at
|
|
109
|
+
class definition.
|
|
110
|
+
|
|
111
|
+
A record that cannot be deserialized (a schema the consumer does not know, a payload written by something else)
|
|
112
|
+
raises `MessageDeserializationError` with the topic, partition and offset, and the partition stays at that offset
|
|
113
|
+
until the group's offset is moved past it. A consumer whose stream can afford to lose such a record sets
|
|
114
|
+
`skip_undeserializable = True`: the record is logged with its offset and dropped, the rest of the batch goes on.
|
|
115
|
+
|
|
116
|
+
## Consumer maker
|
|
117
|
+
|
|
118
|
+
`KafkaConsumerRepositoryMaker` wraps a consumer in a distributed lock, so one partition is processed by one
|
|
119
|
+
worker at a time across the whole deployment. The lock is anything satisfying `Lockable` (`acquire`, `release`,
|
|
120
|
+
`extend`, `timeout`), for example `RedisLock` from [ddredis](https://github.com/davyddd/ddredis). Entering acquires
|
|
121
|
+
the lock and starts the consumer; every yielded batch is followed by a checkpoint (lock heartbeat + offset commit);
|
|
122
|
+
leaving commits, stops and releases. A lock that is already held raises from `acquire`, a consumer that fails to
|
|
123
|
+
start raises `ConsumerStartError` after releasing the lock.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from ddredis.lock import RedisLock
|
|
127
|
+
|
|
128
|
+
from ddkafka.consumer_maker import KafkaConsumerRepositoryMaker
|
|
129
|
+
|
|
130
|
+
async with KafkaConsumerRepositoryMaker(
|
|
131
|
+
consumer_class=ProfileEventConsumerRepository,
|
|
132
|
+
partition=0,
|
|
133
|
+
lock_class=RedisLock,
|
|
134
|
+
lock_kwargs={'redis_client': redis_client, 'timeout': 300},
|
|
135
|
+
) as maker:
|
|
136
|
+
async for batch in maker.get_batches():
|
|
137
|
+
await process(batch)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Every batch is committed as a whole after the caller is done with it; a batch the caller fails on is not committed
|
|
141
|
+
and is read again next time, so processing must tolerate repeated records (`idempotent_key`). `batch_size` bounds
|
|
142
|
+
how much is reprocessed. `maker.checkpoint()` commits the current batch, so call it only once the batch is processed.
|
|
143
|
+
|
|
144
|
+
With `stream=True` the consumer does not return when the topic runs dry: it keeps the lock alive and polls
|
|
145
|
+
again after `stream_round_pause`, so a cron that starts it acts as a supervisor and messages are picked up as
|
|
146
|
+
they land. The pace must fit the lock: `stream_round_pause` plus the consumer's poll timeout may not exceed half
|
|
147
|
+
of the lock timeout, otherwise the maker refuses at construction.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from typing import Literal, Self
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProducerConfig(BaseModel):
|
|
7
|
+
"""Keyword arguments passed to `aiokafka.AIOKafkaProducer` as they are (`model_dump()`).
|
|
8
|
+
|
|
9
|
+
Any other `AIOKafkaProducer` keyword argument (`compression_type`, `max_request_size`, ...) can be
|
|
10
|
+
passed as well and is forwarded untouched.
|
|
11
|
+
|
|
12
|
+
The defaults assume a cluster set up for durability (`default.replication.factor=3`,
|
|
13
|
+
`min.insync.replicas=2`): `acks='all'` is the client half of that guarantee, the rest is batching
|
|
14
|
+
tuned for a steady event stream rather than single sends.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(frozen=True, extra='allow')
|
|
18
|
+
|
|
19
|
+
acks: Literal[0, 1, 'all'] = Field(
|
|
20
|
+
default='all',
|
|
21
|
+
description=(
|
|
22
|
+
"How many brokers must confirm a record before `send` resolves. `'all'` waits for every in-sync "
|
|
23
|
+
"replica, which is the only setting where the cluster's `min.insync.replicas` applies: with "
|
|
24
|
+
'replication factor 3 and min ISR 2 a confirmed record survives the loss of one broker. `1` is '
|
|
25
|
+
'confirmed by the leader alone and is lost if the leader dies before a follower copies it; `0` '
|
|
26
|
+
'does not wait at all. Replication and min ISR are cluster settings chosen by devops; overriding '
|
|
27
|
+
'`acks` silently changes the delivery guarantee they set up, so do it knowingly. Note that '
|
|
28
|
+
"`'all'` needs the topic to actually have at least `min.insync.replicas` replicas: a topic "
|
|
29
|
+
'created with one replica rejects every write with `NotEnoughReplicas`.'
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
enable_idempotence: bool = Field(
|
|
33
|
+
default=False,
|
|
34
|
+
description=(
|
|
35
|
+
'Exactly one copy of each record even when a send is retried after a broker failure. Requires '
|
|
36
|
+
"`acks='all'` and costs a sequence number per partition. Off by default: records are keyed with "
|
|
37
|
+
'`idempotent_key`, so a duplicate is cheap to drop on the consumer side, and consumers are '
|
|
38
|
+
'at-least-once anyway.'
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
linger_ms: int = Field(
|
|
42
|
+
default=50,
|
|
43
|
+
description=(
|
|
44
|
+
'How long the producer waits for more records before sending a batch. 0 sends each record at '
|
|
45
|
+
'once; a small delay lets the records of one request share a batch, which is what makes '
|
|
46
|
+
'`bulk_create` cheap.'
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
max_batch_size: int = Field(
|
|
50
|
+
default=32_000,
|
|
51
|
+
description='Bytes per partition batch. A batch is sent when it reaches this size or `linger_ms` passes.',
|
|
52
|
+
)
|
|
53
|
+
retry_backoff_ms: int = Field(
|
|
54
|
+
default=500,
|
|
55
|
+
description='Pause before retrying a failed request (leader change, transient broker error).',
|
|
56
|
+
)
|
|
57
|
+
metadata_max_age_ms: int = Field(
|
|
58
|
+
default=30_000,
|
|
59
|
+
description=(
|
|
60
|
+
'How often the producer refreshes cluster metadata even without errors, so a new partition or a '
|
|
61
|
+
'moved leader is picked up within this time.'
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
request_timeout_ms: int = Field(
|
|
65
|
+
default=5_000,
|
|
66
|
+
description=(
|
|
67
|
+
"How long a produce request may wait for the broker's response before it fails. With `acks='all'` "
|
|
68
|
+
"this includes the followers catching up, so keep it above the broker's `replica.lag.time.max.ms`."
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@model_validator(mode='after')
|
|
73
|
+
def _idempotence_requires_acks_all(self) -> Self:
|
|
74
|
+
if self.enable_idempotence and self.acks != 'all':
|
|
75
|
+
raise ValueError("`enable_idempotence=True` requires `acks='all'`")
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ConsumerConfig(BaseModel):
|
|
80
|
+
"""Keyword arguments passed to `aiokafka.AIOKafkaConsumer` as they are (`model_dump()`).
|
|
81
|
+
|
|
82
|
+
Any other `AIOKafkaConsumer` keyword argument (`max_poll_records`, `isolation_level`, ...) can be
|
|
83
|
+
passed as well and is forwarded untouched.
|
|
84
|
+
|
|
85
|
+
The defaults make a batch consumer: offsets are committed by the repository after a batch is
|
|
86
|
+
processed (at-least-once) and fetches wait for a meaningful amount of data.
|
|
87
|
+
|
|
88
|
+
The repository assigns its partition explicitly (`assign`, not `subscribe`), so the consumer never
|
|
89
|
+
joins the group: `session_timeout_ms`, `heartbeat_interval_ms` and the other group membership
|
|
90
|
+
settings have no effect here and are left out. They can still be passed if a consumer subscribes.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
model_config = ConfigDict(frozen=True, extra='allow')
|
|
94
|
+
|
|
95
|
+
auto_offset_reset: Literal['earliest', 'latest', 'none'] = Field(
|
|
96
|
+
default='earliest',
|
|
97
|
+
description=(
|
|
98
|
+
'Where a group starts when it has no committed offset for a partition (first run, or the '
|
|
99
|
+
"committed offset was deleted by retention): `'earliest'` reads the whole retained history, "
|
|
100
|
+
"`'latest'` only new records, `'none'` raises and leaves the decision to the caller."
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
# Only False is accepted. Auto commit acknowledges the consumer's position, the offset right after the
|
|
104
|
+
# last record `getmany` returned, every `auto_commit_interval_ms`, regardless of what the application
|
|
105
|
+
# has done with those records. That breaks everything the repository relies on: `commit()` after a
|
|
106
|
+
# processed batch (at-least-once) becomes meaningless, records filtered out by
|
|
107
|
+
# `timestamp_threshold_minutes` get committed past and are never read again, and a partition that
|
|
108
|
+
# must stay on an undeserializable record is silently moved past it.
|
|
109
|
+
enable_auto_commit: Literal[False] = Field(
|
|
110
|
+
default=False,
|
|
111
|
+
description=(
|
|
112
|
+
'Must stay False: `BaseKafkaConsumerRepository.commit` acknowledges offsets after a batch is '
|
|
113
|
+
'processed. Auto commit would acknowledge records as soon as they are fetched, and a crash in the '
|
|
114
|
+
'middle of a batch would lose them.'
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
request_timeout_ms: int = Field(
|
|
118
|
+
default=25_000,
|
|
119
|
+
description='How long a request to the broker may wait for a response.',
|
|
120
|
+
)
|
|
121
|
+
retry_backoff_ms: int = Field(
|
|
122
|
+
default=1_000,
|
|
123
|
+
description='Pause before retrying a failed request (coordinator moved, transient broker error).',
|
|
124
|
+
)
|
|
125
|
+
fetch_min_bytes: int = Field(
|
|
126
|
+
default=120_000,
|
|
127
|
+
description=(
|
|
128
|
+
'The broker holds a fetch until this many bytes are available or `fetch_max_wait_ms` passes. '
|
|
129
|
+
'A high value turns a trickle of small records into fewer, fuller batches.'
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
fetch_max_bytes: int = Field(
|
|
133
|
+
default=125_000_000,
|
|
134
|
+
description='Upper bound on one fetch response across all partitions the consumer reads.',
|
|
135
|
+
)
|
|
136
|
+
fetch_max_wait_ms: int = Field(
|
|
137
|
+
default=1_000,
|
|
138
|
+
description='How long the broker may hold a fetch waiting for `fetch_min_bytes`.',
|
|
139
|
+
)
|
|
140
|
+
max_partition_fetch_bytes: int = Field(
|
|
141
|
+
default=25_000_000,
|
|
142
|
+
description=(
|
|
143
|
+
'Upper bound on the data returned for one partition in one fetch. Must be at least the largest '
|
|
144
|
+
'record in the topic, otherwise the consumer is stuck on it.'
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = ('ConsumerConfig', 'ProducerConfig')
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from abc import ABC
|
|
3
|
+
from collections.abc import AsyncIterator, Iterator
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from typing import Any, ClassVar, Generic, Protocol, Self, TypeVar
|
|
6
|
+
|
|
7
|
+
import msgpack
|
|
8
|
+
from aiokafka import AIOKafkaConsumer, TopicPartition
|
|
9
|
+
from ddutils.class_helpers import get_generic_base_argument, require_class_attribute
|
|
10
|
+
|
|
11
|
+
from ddkafka.config import ConsumerConfig
|
|
12
|
+
|
|
13
|
+
logger = getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Deserializable(Protocol):
|
|
17
|
+
@classmethod
|
|
18
|
+
def model_validate(cls, data: dict) -> Self: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DomainT = TypeVar('DomainT', bound=Deserializable)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MessageDeserializationError(Exception):
|
|
25
|
+
"""A record that cannot be turned into the domain type; names the record so the offset can be skipped."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, topic: str, partition: int, offset: int):
|
|
28
|
+
self.topic = topic
|
|
29
|
+
self.partition = partition
|
|
30
|
+
self.offset = offset
|
|
31
|
+
super().__init__(f'Cannot deserialize the message at {topic}[{partition}] offset {offset}')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BaseKafkaConsumerRepository(ABC, Generic[DomainT]):
|
|
35
|
+
"""
|
|
36
|
+
Base async Kafka consumer repository: one instance reads one partition of `topic` in batches.
|
|
37
|
+
|
|
38
|
+
Messages are msgpack payloads turned into the domain type with `model_validate`. `get_batches`
|
|
39
|
+
polls until the partition runs dry or a poll comes back too thin (`min_batch_fill_ratio`),
|
|
40
|
+
and `commit` acknowledges every batch yielded so far, including the one being processed.
|
|
41
|
+
|
|
42
|
+
A record that cannot be deserialized (a schema the consumer does not know, a foreign payload)
|
|
43
|
+
raises `MessageDeserializationError` with its offset, and the partition stays at that offset
|
|
44
|
+
until someone moves it. A consumer whose stream can afford to lose such a record sets
|
|
45
|
+
`skip_undeserializable = True`: the record is logged with its offset and dropped instead.
|
|
46
|
+
|
|
47
|
+
Class Attributes:
|
|
48
|
+
bootstrap_servers: Kafka servers address
|
|
49
|
+
topic: Topic name
|
|
50
|
+
group_id: Consumer group ID
|
|
51
|
+
batch_size: Max records per poll
|
|
52
|
+
poll_timeout_ms: Timeout for poll operation in milliseconds
|
|
53
|
+
min_batch_fill_ratio: Minimum batch fill ratio to continue (0.0-1.0)
|
|
54
|
+
skip_undeserializable: Drop records that cannot be deserialized instead of raising
|
|
55
|
+
config: Consumer configuration
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
class ProfileEventConsumerRepository(BaseKafkaConsumerRepository[ProfileEvent]):
|
|
59
|
+
bootstrap_servers = ['kafka:9092']
|
|
60
|
+
topic = 'profile_events'
|
|
61
|
+
group_id = 'analytics_context'
|
|
62
|
+
batch_size = 100
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
bootstrap_servers: ClassVar[list[str]]
|
|
66
|
+
topic: ClassVar[str]
|
|
67
|
+
group_id: ClassVar[str]
|
|
68
|
+
batch_size: ClassVar[int] = 1_000
|
|
69
|
+
poll_timeout_ms: ClassVar[int] = 10_000
|
|
70
|
+
min_batch_fill_ratio: ClassVar[float] = 0.1
|
|
71
|
+
skip_undeserializable: ClassVar[bool] = False
|
|
72
|
+
config: ClassVar[ConsumerConfig] = ConsumerConfig()
|
|
73
|
+
|
|
74
|
+
_domain_class: ClassVar[type[Deserializable]]
|
|
75
|
+
|
|
76
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
77
|
+
super().__init_subclass__(**kwargs)
|
|
78
|
+
|
|
79
|
+
domain_class = get_generic_base_argument(cls, BaseKafkaConsumerRepository)
|
|
80
|
+
if domain_class is None or isinstance(domain_class, TypeVar):
|
|
81
|
+
raise TypeError(
|
|
82
|
+
f'{cls.__name__} must specify domain type: class {cls.__name__}(BaseKafkaConsumerRepository[YourDomain])'
|
|
83
|
+
)
|
|
84
|
+
cls._domain_class = domain_class
|
|
85
|
+
|
|
86
|
+
require_class_attribute(cls, 'bootstrap_servers', list)
|
|
87
|
+
require_class_attribute(cls, 'topic', str)
|
|
88
|
+
require_class_attribute(cls, 'group_id', str)
|
|
89
|
+
if require_class_attribute(cls, 'batch_size', int) < 1:
|
|
90
|
+
raise ValueError(f'`batch_size` class attribute of {cls.__name__} must be positive')
|
|
91
|
+
if require_class_attribute(cls, 'poll_timeout_ms', int) < 0:
|
|
92
|
+
raise ValueError(f'`poll_timeout_ms` class attribute of {cls.__name__} must not be negative')
|
|
93
|
+
if not (0 <= require_class_attribute(cls, 'min_batch_fill_ratio', float) <= 1):
|
|
94
|
+
raise ValueError(f'`min_batch_fill_ratio` class attribute of {cls.__name__} must be between 0 and 1')
|
|
95
|
+
require_class_attribute(cls, 'skip_undeserializable', bool)
|
|
96
|
+
require_class_attribute(cls, 'config', ConsumerConfig)
|
|
97
|
+
|
|
98
|
+
def __init__(self, partition: int):
|
|
99
|
+
self._partition = partition
|
|
100
|
+
self._topic_partition = TopicPartition(self.topic, partition)
|
|
101
|
+
self._processed_offsets: dict[TopicPartition, int] = {}
|
|
102
|
+
self._create_consumer()
|
|
103
|
+
|
|
104
|
+
def _deserialize(self, records: list[Any]) -> Iterator[DomainT]:
|
|
105
|
+
# Done here rather than through aiokafka's `value_deserializer`, where a failure surfaces
|
|
106
|
+
# from `getmany` without the record it happened on.
|
|
107
|
+
for record in records:
|
|
108
|
+
try:
|
|
109
|
+
yield self._domain_class.model_validate(msgpack.loads(record.value)) # type: ignore[misc]
|
|
110
|
+
except Exception as e:
|
|
111
|
+
if not self.skip_undeserializable:
|
|
112
|
+
raise MessageDeserializationError(self.topic, self._partition, record.offset) from e
|
|
113
|
+
logger.error(
|
|
114
|
+
{
|
|
115
|
+
'message': 'KAFKA_CONSUMER: Skipped a message that cannot be deserialized',
|
|
116
|
+
'topic': self.topic,
|
|
117
|
+
'partition': self._partition,
|
|
118
|
+
'offset': record.offset,
|
|
119
|
+
'error': repr(e),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _create_consumer(self) -> None:
|
|
124
|
+
logger.info(
|
|
125
|
+
{
|
|
126
|
+
'message': 'KAFKA_CONSUMER: Creating consumer',
|
|
127
|
+
'topic': self.topic,
|
|
128
|
+
'partition': self._partition,
|
|
129
|
+
'group_id': self.group_id,
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
self._consumer = AIOKafkaConsumer(
|
|
134
|
+
bootstrap_servers=self.bootstrap_servers,
|
|
135
|
+
group_id=self.group_id,
|
|
136
|
+
**self.config.model_dump(),
|
|
137
|
+
)
|
|
138
|
+
self._consumer.assign([self._topic_partition])
|
|
139
|
+
|
|
140
|
+
async def start(self) -> None:
|
|
141
|
+
await self._consumer.start()
|
|
142
|
+
|
|
143
|
+
logger.info(
|
|
144
|
+
{'message': 'KAFKA_CONSUMER: Started', 'topic': self.topic, 'partition': self._partition, 'group_id': self.group_id}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def stop(self) -> None:
|
|
148
|
+
await self._consumer.stop()
|
|
149
|
+
logger.info(
|
|
150
|
+
{'message': 'KAFKA_CONSUMER: Stopped', 'topic': self.topic, 'partition': self._partition, 'group_id': self.group_id}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def _get_offset_limit(self, timestamp_threshold_minutes: int) -> int | None:
|
|
154
|
+
"""The first offset younger than the threshold, or None when the partition has no such message."""
|
|
155
|
+
threshold_ms = int(time.time() * 1000) - (timestamp_threshold_minutes * 60 * 1000)
|
|
156
|
+
offsets_for_times = await self._consumer.offsets_for_times({self._topic_partition: threshold_ms})
|
|
157
|
+
offset_and_timestamp = offsets_for_times.get(self._topic_partition)
|
|
158
|
+
return offset_and_timestamp.offset if offset_and_timestamp else None
|
|
159
|
+
|
|
160
|
+
async def _poll(self, offset_limit: int | None) -> list[Any]:
|
|
161
|
+
"""One `getmany` for the partition; with `offset_limit` only the records before it."""
|
|
162
|
+
records_by_partition = await self._consumer.getmany(
|
|
163
|
+
self._topic_partition, timeout_ms=self.poll_timeout_ms, max_records=self.batch_size
|
|
164
|
+
)
|
|
165
|
+
records = records_by_partition.get(self._topic_partition, [])
|
|
166
|
+
|
|
167
|
+
logger.info(
|
|
168
|
+
{
|
|
169
|
+
'message': 'KAFKA_CONSUMER: Retrieved batch',
|
|
170
|
+
'topic': self.topic,
|
|
171
|
+
'partition': self._partition,
|
|
172
|
+
'records_count': len(records),
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if offset_limit is not None:
|
|
177
|
+
records = [record for record in records if record.offset < offset_limit]
|
|
178
|
+
return records
|
|
179
|
+
|
|
180
|
+
async def get_batches(self, timestamp_threshold_minutes: int | None = None) -> AsyncIterator[tuple[DomainT, ...]]:
|
|
181
|
+
"""
|
|
182
|
+
Returns an async iterator over batches of domain entities.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
timestamp_threshold_minutes: Only messages OLDER than this threshold (in minutes) are included.
|
|
186
|
+
Defaults to None (no filtering).
|
|
187
|
+
"""
|
|
188
|
+
offset_limit: int | None = None
|
|
189
|
+
if timestamp_threshold_minutes is not None:
|
|
190
|
+
offset_limit = await self._get_offset_limit(timestamp_threshold_minutes)
|
|
191
|
+
|
|
192
|
+
while True:
|
|
193
|
+
records = await self._poll(offset_limit)
|
|
194
|
+
if not records:
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
values = tuple(self._deserialize(records))
|
|
198
|
+
records_count, last_offset = len(records), records[-1].offset
|
|
199
|
+
# The raw records are not needed past this point; dropping them before the batch is
|
|
200
|
+
# handed out frees the msgpack payloads while the caller works on the domain objects.
|
|
201
|
+
del records
|
|
202
|
+
|
|
203
|
+
# Recorded before the batch is handed out, so `commit` right after processing it
|
|
204
|
+
# acknowledges this batch and not the previous one. The caller is responsible for not
|
|
205
|
+
# committing a batch it failed on (the maker skips the commit on an exceptional exit).
|
|
206
|
+
self._processed_offsets[self._topic_partition] = last_offset + 1
|
|
207
|
+
|
|
208
|
+
if values: # a batch of skipped records only is nothing to hand out
|
|
209
|
+
yield values
|
|
210
|
+
|
|
211
|
+
# A poll that came back mostly empty means the partition is nearly drained: finish
|
|
212
|
+
# after this batch instead of waiting `poll_timeout_ms` for a handful more.
|
|
213
|
+
if records_count < self.batch_size * self.min_batch_fill_ratio:
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
async def commit(self) -> None:
|
|
217
|
+
if self._processed_offsets:
|
|
218
|
+
await self._consumer.commit(self._processed_offsets)
|
|
219
|
+
|
|
220
|
+
logger.info(
|
|
221
|
+
{
|
|
222
|
+
'message': 'KAFKA_CONSUMER: Committed offsets',
|
|
223
|
+
'topic': self.topic,
|
|
224
|
+
'partition': self._partition,
|
|
225
|
+
'offsets': str(self._processed_offsets),
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
self._processed_offsets.clear()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = ('BaseKafkaConsumerRepository', 'Deserializable', 'MessageDeserializationError')
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Any, Generic, Protocol, Self, TypeVar
|
|
5
|
+
|
|
6
|
+
from ddkafka.consumer import BaseKafkaConsumerRepository, Deserializable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Lockable(Protocol):
|
|
10
|
+
# How long one `extend` is worth. A streaming consumer has to beat faster than this, so the
|
|
11
|
+
# contract has to say it out loud rather than leave it to whoever wires the lock up.
|
|
12
|
+
timeout: float
|
|
13
|
+
|
|
14
|
+
async def acquire(self) -> None: ...
|
|
15
|
+
|
|
16
|
+
async def release(self) -> None: ...
|
|
17
|
+
|
|
18
|
+
async def extend(self) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConsumerStartError(Exception): ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# How much of the lock's timeout a streaming consumer is allowed to spend between two beats.
|
|
25
|
+
# The rest is headroom for the one thing the check cannot see — how long the caller takes over
|
|
26
|
+
# a batch, which is bounded by nothing here.
|
|
27
|
+
MAX_BEAT_SHARE_OF_LOCK_TIMEOUT = 0.5
|
|
28
|
+
|
|
29
|
+
DomainT = TypeVar('DomainT', bound=Deserializable)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class KafkaConsumerRepositoryMaker(Generic[DomainT]):
|
|
33
|
+
"""
|
|
34
|
+
Context manager for a Kafka consumer guarded by a distributed lock.
|
|
35
|
+
|
|
36
|
+
The lock (anything satisfying `Lockable`, e.g. `ddredis.lock.RedisLock`) is keyed by group,
|
|
37
|
+
topic and partition, so only one consumer per partition runs across all workers. Entering
|
|
38
|
+
acquires the lock and starts the consumer; every batch yielded by `get_batches` is followed
|
|
39
|
+
by a checkpoint (lock heartbeat + offset commit); leaving commits, stops and releases. An exit
|
|
40
|
+
caused by an exception does not commit, so the batch being processed is read again next time.
|
|
41
|
+
|
|
42
|
+
`stream` decides what happens when the topic runs dry. Without it the consumer drains what
|
|
43
|
+
is waiting and returns, which is what a periodic sync wants. With it the consumer stays on
|
|
44
|
+
its partition indefinitely, and the cron that starts it becomes a supervisor rather than a
|
|
45
|
+
schedule: every tick tries to start one, the lock turns all but the first away, and a tick
|
|
46
|
+
only does anything once the running consumer has died. Recovery from a death that leaves
|
|
47
|
+
the lock held (a killed worker) takes the lock's own `timeout`, so a streaming consumer
|
|
48
|
+
wants that timeout measured in tens of seconds, not minutes.
|
|
49
|
+
|
|
50
|
+
`stream_round_pause` is how long it waits between rounds — the topic's chance to refill,
|
|
51
|
+
and the ceiling on how long a message can sit once it has arrived. A consumer whose topic
|
|
52
|
+
trickles wants it longer, one whose latency is the point wants it short. It is bounded from
|
|
53
|
+
above by the lock: pause plus poll timeout is the longest the lock can go unextended, and a
|
|
54
|
+
pace that does not fit is refused at construction.
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
async with KafkaConsumerRepositoryMaker(
|
|
58
|
+
consumer_class=EventConsumerRepository,
|
|
59
|
+
partition=0,
|
|
60
|
+
lock_class=RedisLock,
|
|
61
|
+
lock_kwargs={'redis_client': redis_client, 'timeout': 300}
|
|
62
|
+
) as maker:
|
|
63
|
+
async for batch in maker.get_batches():
|
|
64
|
+
process(batch)
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
consumer_class: type[BaseKafkaConsumerRepository[DomainT]],
|
|
70
|
+
partition: int,
|
|
71
|
+
lock_class: type[Lockable],
|
|
72
|
+
lock_kwargs: dict[str, Any],
|
|
73
|
+
stream: bool = False,
|
|
74
|
+
stream_round_pause: timedelta = timedelta(seconds=1),
|
|
75
|
+
):
|
|
76
|
+
lock_key = self.build_lock_key(consumer_class, partition)
|
|
77
|
+
self.lock = lock_class(key=lock_key, **lock_kwargs) # type: ignore[call-arg]
|
|
78
|
+
self._consumer_class = consumer_class
|
|
79
|
+
self._partition = partition
|
|
80
|
+
self._stream = stream
|
|
81
|
+
self._stream_round_pause = stream_round_pause
|
|
82
|
+
|
|
83
|
+
if stream:
|
|
84
|
+
self._check_beat_fits_lock()
|
|
85
|
+
|
|
86
|
+
def _check_beat_fits_lock(self) -> None:
|
|
87
|
+
"""Refuse a pace that would let the lock die under a consumer that is still running.
|
|
88
|
+
|
|
89
|
+
A streaming consumer beats once per round, and a round is the pause plus a poll that may
|
|
90
|
+
wait its whole timeout out. Let that exceed the lock and the lock expires between beats:
|
|
91
|
+
the next tick then starts a second consumer on the same partition, both read it and both
|
|
92
|
+
commit, and nothing in the running system says so.
|
|
93
|
+
"""
|
|
94
|
+
poll_timeout = self._consumer_class.poll_timeout_ms / 1000
|
|
95
|
+
beat_ceiling = self._stream_round_pause.total_seconds() + poll_timeout
|
|
96
|
+
allowed = self.lock.timeout * MAX_BEAT_SHARE_OF_LOCK_TIMEOUT
|
|
97
|
+
if beat_ceiling > allowed:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f'{self._consumer_class.__name__} streams with up to {beat_ceiling:g}s between lock '
|
|
100
|
+
f'heartbeats (stream_round_pause {self._stream_round_pause.total_seconds():g}s + poll '
|
|
101
|
+
f'timeout {poll_timeout:g}s), which does not fit the '
|
|
102
|
+
f'{MAX_BEAT_SHARE_OF_LOCK_TIMEOUT:g} share of the lock timeout it is allowed '
|
|
103
|
+
f'({allowed:g}s of {self.lock.timeout:g}s). Shorten the pause or raise the lock timeout.'
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def build_lock_key(consumer_class: type[BaseKafkaConsumerRepository[Any]], partition: int) -> str:
|
|
108
|
+
return f'{consumer_class.group_id}_{consumer_class.topic}_partition_{partition}'
|
|
109
|
+
|
|
110
|
+
async def get_batches(self) -> AsyncIterator[tuple[DomainT, ...]]:
|
|
111
|
+
while True:
|
|
112
|
+
async for batch in self.consumer_repo.get_batches():
|
|
113
|
+
yield batch
|
|
114
|
+
await self.checkpoint()
|
|
115
|
+
|
|
116
|
+
if not self._stream:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
# The topic went quiet. Returning here would hand the partition back and leave the
|
|
120
|
+
# next message to whatever starts a consumer again — a cron tick, and with it a
|
|
121
|
+
# whole stage of the send path's latency. Instead the lock is kept alive and the
|
|
122
|
+
# consumer asked once more: its own poll timeout is the wait, so an idle partition
|
|
123
|
+
# costs one call every few seconds and a message is taken up as it lands.
|
|
124
|
+
#
|
|
125
|
+
# A checkpoint rather than a bare `extend`, so the heartbeat has one call site: a
|
|
126
|
+
# round that found nothing yields no batch, so without this an idle partition would
|
|
127
|
+
# have no heartbeat at all and the lock would expire under a running consumer.
|
|
128
|
+
await self.checkpoint()
|
|
129
|
+
|
|
130
|
+
# The pause lets records accumulate into fuller batches and is the floor under a
|
|
131
|
+
# poll that returns instantly, which would otherwise spin.
|
|
132
|
+
await asyncio.sleep(self._stream_round_pause.total_seconds())
|
|
133
|
+
|
|
134
|
+
async def checkpoint(self) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Extends the lock and commits every batch yielded so far, including the one being processed.
|
|
137
|
+
|
|
138
|
+
`get_batches` calls it after the caller is done with a batch. Calling it yourself from
|
|
139
|
+
inside the loop commits the current batch before it is processed: if the processing then
|
|
140
|
+
fails, those records are gone.
|
|
141
|
+
"""
|
|
142
|
+
await self.lock.extend()
|
|
143
|
+
await self.consumer_repo.commit()
|
|
144
|
+
|
|
145
|
+
async def __aenter__(self) -> Self:
|
|
146
|
+
await self.lock.acquire()
|
|
147
|
+
|
|
148
|
+
# Anything that fails after the lock is taken has to give it back, or the partition stays
|
|
149
|
+
# locked until the lock's timeout: the constructor (aiokafka needs a running loop) as much
|
|
150
|
+
# as `start` (no broker).
|
|
151
|
+
try:
|
|
152
|
+
self.consumer_repo = self._consumer_class(partition=self._partition)
|
|
153
|
+
await self.consumer_repo.start()
|
|
154
|
+
except Exception as e: # noqa: BLE001
|
|
155
|
+
await self._close()
|
|
156
|
+
raise ConsumerStartError('Failed to start consumer') from e
|
|
157
|
+
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
async def _close(self) -> None:
|
|
161
|
+
try:
|
|
162
|
+
if hasattr(self, 'consumer_repo'):
|
|
163
|
+
await self.consumer_repo.stop()
|
|
164
|
+
finally:
|
|
165
|
+
await self.lock.release()
|
|
166
|
+
|
|
167
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
168
|
+
# The offsets of a yielded batch are already recorded, so an exit caused by an error in
|
|
169
|
+
# the caller must not commit: the batch it failed on stays in the topic for the next run.
|
|
170
|
+
try:
|
|
171
|
+
if exc_type is None:
|
|
172
|
+
await self.consumer_repo.commit()
|
|
173
|
+
finally:
|
|
174
|
+
await self._close()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = ('ConsumerStartError', 'KafkaConsumerRepositoryMaker', 'Lockable')
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from abc import ABC
|
|
3
|
+
from asyncio import Future
|
|
4
|
+
from functools import partial
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
from typing import Any, ClassVar, Protocol
|
|
7
|
+
|
|
8
|
+
import msgpack
|
|
9
|
+
from aiokafka import AIOKafkaProducer
|
|
10
|
+
from ddutils.class_helpers import require_class_attribute
|
|
11
|
+
|
|
12
|
+
from ddkafka.config import ProducerConfig
|
|
13
|
+
|
|
14
|
+
logger = getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Producible(Protocol):
|
|
18
|
+
@property
|
|
19
|
+
def idempotent_key(self) -> str | None: ...
|
|
20
|
+
|
|
21
|
+
def model_dump(self, *args: Any, **kwargs: Any) -> dict: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def serialize_value(event: Producible) -> bytes:
|
|
25
|
+
return msgpack.dumps(event.model_dump(mode='json'))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def serialize_key(key: Any) -> bytes | None:
|
|
29
|
+
# aiokafka hands the key to the serializer even when it is None, and a key of b'None' would
|
|
30
|
+
# route every keyless event to one partition.
|
|
31
|
+
return None if key is None else str(key).encode('utf-8')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _log_delivery_failure(topic: str, key: Any, future: Future) -> None:
|
|
35
|
+
# `send` resolves when the record is buffered; the broker's answer lands in this future later.
|
|
36
|
+
# Nobody awaits it, so without this callback a rejected record (`NotEnoughReplicas`, a timeout)
|
|
37
|
+
# would only show up as "Future exception was never retrieved" from asyncio.
|
|
38
|
+
if future.cancelled():
|
|
39
|
+
return
|
|
40
|
+
error = future.exception()
|
|
41
|
+
if error is not None:
|
|
42
|
+
logger.error({'message': 'KAFKA_PRODUCER: Delivery failed', 'topic': topic, 'key': key, 'error': repr(error)})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BaseKafkaProducerRepository(ABC):
|
|
46
|
+
"""
|
|
47
|
+
Base async Kafka producer.
|
|
48
|
+
|
|
49
|
+
One `AIOKafkaProducer` is created lazily per subclass, once per process, and shared by all its
|
|
50
|
+
instances, so a producer repository can be instantiated freely. Events are serialized with msgpack from
|
|
51
|
+
`model_dump(mode='json')` and keyed by `idempotent_key`.
|
|
52
|
+
|
|
53
|
+
`create` and `bulk_create` return once the record is in the producer's buffer; aiokafka sends
|
|
54
|
+
buffered records in batches (`linger_ms`, `max_batch_size`). A record the broker rejects is
|
|
55
|
+
reported with an ERROR log entry, since nothing awaits its delivery.
|
|
56
|
+
|
|
57
|
+
The buffer only reaches Kafka while the process runs: call `stop()` on application shutdown,
|
|
58
|
+
otherwise up to `linger_ms` worth of records is lost on SIGTERM.
|
|
59
|
+
|
|
60
|
+
Class Attributes:
|
|
61
|
+
bootstrap_servers: Kafka servers address
|
|
62
|
+
config: Producer configuration
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
class EventProducerRepository(BaseKafkaProducerRepository):
|
|
66
|
+
bootstrap_servers = ['kafka:9092']
|
|
67
|
+
config = ProducerConfig(linger_ms=100)
|
|
68
|
+
|
|
69
|
+
producer_repository_impl = EventProducerRepository()
|
|
70
|
+
await producer_repository_impl.bulk_create('topic', events)
|
|
71
|
+
...
|
|
72
|
+
await EventProducerRepository.stop() # on shutdown
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
bootstrap_servers: ClassVar[list[str]]
|
|
76
|
+
config: ClassVar[ProducerConfig] = ProducerConfig()
|
|
77
|
+
|
|
78
|
+
_producer: ClassVar[AIOKafkaProducer | None] = None
|
|
79
|
+
_producer_lock: ClassVar[asyncio.Lock]
|
|
80
|
+
_stopped: ClassVar[bool] = False
|
|
81
|
+
|
|
82
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
83
|
+
super().__init_subclass__(**kwargs)
|
|
84
|
+
require_class_attribute(cls, 'bootstrap_servers', list)
|
|
85
|
+
require_class_attribute(cls, 'config', ProducerConfig)
|
|
86
|
+
cls._producer_lock = asyncio.Lock()
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
async def _get_producer(cls) -> AIOKafkaProducer:
|
|
90
|
+
if cls._producer is not None:
|
|
91
|
+
return cls._producer
|
|
92
|
+
|
|
93
|
+
# The lock makes the first concurrent sends start one producer instead of one each.
|
|
94
|
+
async with cls._producer_lock:
|
|
95
|
+
if cls._stopped:
|
|
96
|
+
raise RuntimeError(f'{cls.__name__} producer was stopped')
|
|
97
|
+
if cls._producer is None:
|
|
98
|
+
producer = AIOKafkaProducer(
|
|
99
|
+
bootstrap_servers=cls.bootstrap_servers,
|
|
100
|
+
value_serializer=serialize_value,
|
|
101
|
+
key_serializer=serialize_key,
|
|
102
|
+
**cls.config.model_dump(),
|
|
103
|
+
)
|
|
104
|
+
await producer.start()
|
|
105
|
+
cls._producer = producer
|
|
106
|
+
return cls._producer
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
async def stop(cls) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Sends what is still buffered and closes the producer. Call it on application shutdown.
|
|
112
|
+
|
|
113
|
+
A producer is created once per process: after `stop` the repository refuses to send.
|
|
114
|
+
"""
|
|
115
|
+
async with cls._producer_lock:
|
|
116
|
+
cls._stopped = True
|
|
117
|
+
if cls._producer is not None:
|
|
118
|
+
await cls._producer.stop()
|
|
119
|
+
cls._producer = None
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
async def _send(cls, producer: AIOKafkaProducer, topic: str, event: Producible) -> None:
|
|
123
|
+
key = event.idempotent_key
|
|
124
|
+
future = await producer.send(topic, key=key, value=event)
|
|
125
|
+
future.add_done_callback(partial(_log_delivery_failure, topic, key))
|
|
126
|
+
|
|
127
|
+
async def create(self, topic: str, event: Producible) -> None:
|
|
128
|
+
producer = await self._get_producer()
|
|
129
|
+
await self._send(producer, topic, event)
|
|
130
|
+
|
|
131
|
+
async def bulk_create(self, topic: str, events: list[Producible]) -> None:
|
|
132
|
+
producer = await self._get_producer()
|
|
133
|
+
for event in events:
|
|
134
|
+
await self._send(producer, topic, event)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
__all__ = ('BaseKafkaProducerRepository', 'Producible', 'serialize_key', 'serialize_value')
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ddkafka"
|
|
3
|
+
description = "Kafka helpers: batch consumer, lock-guarded consumer maker and msgpack producer on top of aiokafka"
|
|
4
|
+
version = "0.0.1"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.11,<3.15"
|
|
9
|
+
keywords = [
|
|
10
|
+
"python",
|
|
11
|
+
"kafka",
|
|
12
|
+
"aiokafka",
|
|
13
|
+
"consumer",
|
|
14
|
+
"producer",
|
|
15
|
+
"ddkafka",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"aiokafka>=0.12,<0.15",
|
|
27
|
+
"msgpack>=1.0,<2",
|
|
28
|
+
"ddutils>=0.2.3,<0.3.0",
|
|
29
|
+
"pydantic>=2.0,<3",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[[project.authors]]
|
|
33
|
+
name = "davyddd"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Repository = "https://github.com/davyddd/ddkafka"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"ipdb==0.13.13",
|
|
41
|
+
"ipython==9.16.1",
|
|
42
|
+
]
|
|
43
|
+
test = [
|
|
44
|
+
"pytest==9.0.3",
|
|
45
|
+
"pytest-cov==5.0.0",
|
|
46
|
+
"parameterized==0.9.0",
|
|
47
|
+
]
|
|
48
|
+
linter = [
|
|
49
|
+
"ruff==0.15.10",
|
|
50
|
+
"ty==0.0.15",
|
|
51
|
+
"complexipy==8.0.1",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.uv.build-backend]
|
|
55
|
+
module-root = ""
|
|
56
|
+
|
|
57
|
+
[build-system]
|
|
58
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
59
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ddkafka"
|
|
3
|
+
description = "Kafka helpers: batch consumer, lock-guarded consumer maker and msgpack producer on top of aiokafka"
|
|
4
|
+
version = "0.0.1"
|
|
5
|
+
authors = [{ name = "davyddd" }]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.11,<3.15"
|
|
10
|
+
keywords = ["python", "kafka", "aiokafka", "consumer", "producer", "ddkafka"]
|
|
11
|
+
# uv_build does not derive these from requires-python (poetry did); PyPI and the pyversions badge read them
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"aiokafka>=0.12,<0.15",
|
|
22
|
+
"msgpack>=1.0,<2",
|
|
23
|
+
"ddutils>=0.2.3,<0.3.0",
|
|
24
|
+
"pydantic>=2.0,<3",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Repository = "https://github.com/davyddd/ddkafka"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"ipdb==0.13.13",
|
|
33
|
+
"ipython==9.16.1",
|
|
34
|
+
]
|
|
35
|
+
test = [
|
|
36
|
+
"pytest==9.0.3",
|
|
37
|
+
"pytest-cov==5.0.0",
|
|
38
|
+
"parameterized==0.9.0",
|
|
39
|
+
]
|
|
40
|
+
linter = [
|
|
41
|
+
"ruff==0.15.10",
|
|
42
|
+
"ty==0.0.15",
|
|
43
|
+
"complexipy==8.0.1",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.uv.build-backend]
|
|
47
|
+
module-root = ""
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
51
|
+
build-backend = "uv_build"
|