glean-klient 0.1.0__py3-none-any.whl
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.
- glean_klient-0.1.0.dist-info/METADATA +955 -0
- glean_klient-0.1.0.dist-info/RECORD +12 -0
- glean_klient-0.1.0.dist-info/WHEEL +5 -0
- glean_klient-0.1.0.dist-info/entry_points.txt +2 -0
- glean_klient-0.1.0.dist-info/top_level.txt +1 -0
- klient/__init__.py +106 -0
- klient/__main__.py +1226 -0
- klient/admin.py +286 -0
- klient/consumer.py +629 -0
- klient/metrics.py +34 -0
- klient/producer.py +820 -0
- klient/relay.py +101 -0
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: glean-klient
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: confluent-kafka>=2.12.0
|
|
8
|
+
Requires-Dist: click>=8.0.0
|
|
9
|
+
|
|
10
|
+
# klient
|
|
11
|
+
|
|
12
|
+
Lightweight Python wrappers around `confluent-kafka` providing:
|
|
13
|
+
|
|
14
|
+
- Unified Producer, Consumer, and Admin helpers with clear sync & async APIs
|
|
15
|
+
- Built-in transactional produce (sync & async context managers)
|
|
16
|
+
- Environment–aware configuration loading from `~/.kafka` (merge default + named env)
|
|
17
|
+
- Simple CLI entrypoint (`python -m klient ...`) for common admin & consume patterns
|
|
18
|
+
- Sensible defaults (isolation level defaults to `read_committed`, unlimited streaming)
|
|
19
|
+
|
|
20
|
+
The project favors clarity over cleverness: thin abstractions, explicit naming, and test-backed behavior.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
### Prerequisites
|
|
27
|
+
|
|
28
|
+
Kafka broker accessible (local or remote), Python >=3.12.
|
|
29
|
+
|
|
30
|
+
### Install via editable clone
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git clone https://github.com/your-org/glean-kafka.git
|
|
34
|
+
cd glean-kafka
|
|
35
|
+
pip install -e .[dev]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The `[dev]` extra (defined in `pyproject.toml`) installs testing dependencies (`pytest`, `pytest-asyncio`, `pytest-cov`).
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Quick Start (Library)
|
|
43
|
+
|
|
44
|
+
### Producing Messages
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from klient.producer import KafkaProducer, ProducerConfig
|
|
48
|
+
|
|
49
|
+
producer = KafkaProducer(ProducerConfig(bootstrap_servers="localhost:9092"))
|
|
50
|
+
result = producer.produce(topic="events", key=b"user-1", value=b"hello")
|
|
51
|
+
print(result.status, result.partition, result.offset)
|
|
52
|
+
|
|
53
|
+
# Async
|
|
54
|
+
import asyncio
|
|
55
|
+
async def main():
|
|
56
|
+
aresult = await producer.aproduce(topic="events", key=b"user-2", value=b"hi async")
|
|
57
|
+
print(aresult.status)
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
|
|
60
|
+
producer.close()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Consuming (poll single / batch / stream)
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from klient.consumer import KafkaConsumer, ConsumerConfig
|
|
67
|
+
|
|
68
|
+
consumer = KafkaConsumer(ConsumerConfig(bootstrap_servers="localhost:9092", group_id="demo", topics=["events"])) # supply topics list in config
|
|
69
|
+
|
|
70
|
+
# Explicit subscription (list-only API). Provide a list even for a single topic:
|
|
71
|
+
consumer.subscribe(["events"]) # list of topic strings
|
|
72
|
+
|
|
73
|
+
# Single poll (returns MessageResult | None)
|
|
74
|
+
msg = consumer.poll(timeout=1.0)
|
|
75
|
+
if msg:
|
|
76
|
+
print(msg.key, msg.value)
|
|
77
|
+
|
|
78
|
+
# Batch consume (up to max_messages or until timeout window exhausted)
|
|
79
|
+
batch = consumer.consume_messages(max_messages=10, timeout=5.0)
|
|
80
|
+
for m in batch:
|
|
81
|
+
print(m.offset)
|
|
82
|
+
|
|
83
|
+
# Streaming (unlimited by default). Break manually.
|
|
84
|
+
for m in consumer.message_stream(timeout=1.0):
|
|
85
|
+
print(m.key, m.value)
|
|
86
|
+
if some_condition():
|
|
87
|
+
break
|
|
88
|
+
consumer.stop() # stop underlying loop if streaming helper created one
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Seeking to Specific Offsets
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from klient.consumer import KafkaConsumer, ConsumerConfig
|
|
95
|
+
|
|
96
|
+
consumer = KafkaConsumer(ConsumerConfig(bootstrap_servers="localhost:9092", group_id="demo"))
|
|
97
|
+
consumer.subscribe(["events"])
|
|
98
|
+
|
|
99
|
+
# Seek to offset 1000 in ALL partitions of the topic
|
|
100
|
+
consumer.seek_to_offset(topic="events", offset=1000)
|
|
101
|
+
|
|
102
|
+
# Seek to offset 1000 in a SPECIFIC partition
|
|
103
|
+
consumer.seek_to_offset(topic="events", offset=1000, partition=0)
|
|
104
|
+
|
|
105
|
+
# Now poll or consume from that offset
|
|
106
|
+
msg = consumer.poll(timeout=1.0)
|
|
107
|
+
if msg:
|
|
108
|
+
print(f"Message at offset {msg.offset}: {msg.value}")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Admin Operations
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from klient.admin import KafkaAdmin, AdminConfig
|
|
115
|
+
|
|
116
|
+
admin = KafkaAdmin(AdminConfig(bootstrap_servers="localhost:9092"))
|
|
117
|
+
admin.create_topics([{ "topic": "events", "num_partitions": 1, "replication_factor": 1 }])
|
|
118
|
+
print(admin.list_topics())
|
|
119
|
+
admin.delete_topics(["events"]) # cleanup
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Transactions
|
|
125
|
+
|
|
126
|
+
Enable transactions by supplying a `transactional_id` in `ProducerConfig`. The wrapper initializes transactions lazily.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from klient.producer import KafkaProducer, ProducerConfig
|
|
130
|
+
|
|
131
|
+
producer = KafkaProducer(ProducerConfig(bootstrap_servers="localhost:9092", transactional_id="tx-demo"))
|
|
132
|
+
|
|
133
|
+
with producer.transaction(): # sync context manager
|
|
134
|
+
producer.produce("events", key=b"k1", value=b"payload-1")
|
|
135
|
+
producer.produce("events", key=b"k2", value=b"payload-2")
|
|
136
|
+
# raise to trigger abort
|
|
137
|
+
|
|
138
|
+
# Async
|
|
139
|
+
import asyncio
|
|
140
|
+
async def run_tx():
|
|
141
|
+
async with producer.atransaction():
|
|
142
|
+
await producer.aproduce("events", key=b"k3", value=b"async-1")
|
|
143
|
+
asyncio.run(run_tx())
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Error handling: a raised exception inside the context causes `abort_transaction()` and re-raises. Begin/commit/abort each return a `TransactionResult` with `success`, `error`, and optional `transaction_id`.
|
|
147
|
+
|
|
148
|
+
### Continuous Async Transaction Stream
|
|
149
|
+
|
|
150
|
+
Below is an example of producing messages continuously in discrete transactional batches. Each loop iteration creates a new transaction, allowing partial failure without impacting previous committed batches. A cancellation signal (Ctrl+C) or external event stops the loop gracefully.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
import asyncio
|
|
154
|
+
import signal
|
|
155
|
+
from klient.producer import KafkaProducer, ProducerConfig
|
|
156
|
+
|
|
157
|
+
stop = asyncio.Event()
|
|
158
|
+
|
|
159
|
+
def handle_sigint(*_):
|
|
160
|
+
stop.set()
|
|
161
|
+
|
|
162
|
+
signal.signal(signal.SIGINT, handle_sigint)
|
|
163
|
+
|
|
164
|
+
producer = KafkaProducer(ProducerConfig(
|
|
165
|
+
bootstrap_servers="localhost:9092",
|
|
166
|
+
transactional_id="stream-tx-producer" # stable id per producer instance
|
|
167
|
+
))
|
|
168
|
+
|
|
169
|
+
async def produce_forever(batch_size: int = 5, delay: float = 0.5):
|
|
170
|
+
counter = 0
|
|
171
|
+
while not stop.is_set():
|
|
172
|
+
async with producer.atransaction(): # new transaction each batch
|
|
173
|
+
for i in range(batch_size):
|
|
174
|
+
key = f"user-{counter}".encode()
|
|
175
|
+
value = f"payload-{counter}".encode()
|
|
176
|
+
await producer.aproduce("events", key=key, value=value)
|
|
177
|
+
counter += 1
|
|
178
|
+
await asyncio.sleep(delay) # pacing; remove for max throughput
|
|
179
|
+
|
|
180
|
+
# Optional final flush (transactions already committed)
|
|
181
|
+
producer.flush()
|
|
182
|
+
|
|
183
|
+
asyncio.run(produce_forever())
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Considerations:
|
|
187
|
+
|
|
188
|
+
1. Throughput: Increase `batch_size` and reduce `delay` for higher message rates.
|
|
189
|
+
2. Ordering: All messages in a single transaction appear atomically to `read_committed` consumers.
|
|
190
|
+
3. Backpressure: If delivery reports back up, adjust producer config (e.g., linger, batch size, queue limits).
|
|
191
|
+
4. Shutdown: SIGINT sets the event; the current transaction completes before exit.
|
|
192
|
+
5. Retry semantics: `confluent-kafka` handles retries internally; aborted transactions never become visible when isolation is `read_committed`.
|
|
193
|
+
|
|
194
|
+
### Consuming From One Topic and Producing To Another (Relay)
|
|
195
|
+
|
|
196
|
+
Common pattern: read, transform, and forward. Below are sync and async relay examples. The async version batches each relay group into a transaction for atomicity.
|
|
197
|
+
|
|
198
|
+
#### Synchronous Relay (Simple Transform)
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from klient.consumer import KafkaConsumer, ConsumerConfig
|
|
202
|
+
from klient.producer import KafkaProducer, ProducerConfig
|
|
203
|
+
|
|
204
|
+
consumer = KafkaConsumer(ConsumerConfig(
|
|
205
|
+
bootstrap_servers="localhost:9092",
|
|
206
|
+
group_id="relay-group",
|
|
207
|
+
isolation_level="read_committed"
|
|
208
|
+
))
|
|
209
|
+
consumer.subscribe(["input-topic"]) # list-only subscription
|
|
210
|
+
|
|
211
|
+
producer = KafkaProducer(ProducerConfig(
|
|
212
|
+
bootstrap_servers="localhost:9092"
|
|
213
|
+
))
|
|
214
|
+
|
|
215
|
+
for msg in consumer.message_stream(timeout=1.0):
|
|
216
|
+
# Basic transform: uppercase value, preserve key
|
|
217
|
+
new_value = msg.value.upper() if msg.value else b""
|
|
218
|
+
producer.produce("output-topic", key=msg.key, value=new_value)
|
|
219
|
+
# Optionally flush periodically for latency control
|
|
220
|
+
# if msg.offset % 100 == 0: producer.flush()
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
#### Async Transactional Relay (Batch Atomicity)
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
import asyncio
|
|
227
|
+
from klient.consumer import KafkaConsumer, ConsumerConfig
|
|
228
|
+
from klient.producer import KafkaProducer, ProducerConfig
|
|
229
|
+
|
|
230
|
+
consumer = KafkaConsumer(ConsumerConfig(
|
|
231
|
+
bootstrap_servers="localhost:9092",
|
|
232
|
+
group_id="relay-async-group",
|
|
233
|
+
isolation_level="read_committed"
|
|
234
|
+
))
|
|
235
|
+
consumer.subscribe(["input-topic"]) # list-only subscription
|
|
236
|
+
|
|
237
|
+
producer = KafkaProducer(ProducerConfig(
|
|
238
|
+
bootstrap_servers="localhost:9092",
|
|
239
|
+
transactional_id="relay-tx-producer"
|
|
240
|
+
))
|
|
241
|
+
|
|
242
|
+
async def relay_forever(batch_size: int = 25):
|
|
243
|
+
buffer = []
|
|
244
|
+
async for msg in consumer.amessage_stream(timeout=1.0):
|
|
245
|
+
buffer.append(msg)
|
|
246
|
+
if len(buffer) >= batch_size:
|
|
247
|
+
async with producer.atransaction():
|
|
248
|
+
for m in buffer:
|
|
249
|
+
# Example enrichment: append offset metadata
|
|
250
|
+
enriched = (m.value or b"") + f"|offset={m.offset}".encode()
|
|
251
|
+
await producer.aproduce("output-topic", key=m.key, value=enriched)
|
|
252
|
+
buffer.clear()
|
|
253
|
+
|
|
254
|
+
asyncio.run(relay_forever())
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Notes:
|
|
258
|
+
|
|
259
|
+
1. Backpressure: Adjust `batch_size` to tune commit frequency vs latency.
|
|
260
|
+
2. Ordering: Within a transaction all output messages become visible together; cross-transaction ordering depends on source order + processing time.
|
|
261
|
+
3. Isolation: Using `read_committed` avoids relaying aborted input messages.
|
|
262
|
+
4. Flow Control: Add a max loop runtime or cancellation signal for graceful shutdown.
|
|
263
|
+
5. Error Handling: Exceptions inside the transactional context abort that batch only; upstream offsets still advance because messages were read—consider manual offset management if exactly-once relay semantics are required.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Environment Configuration Loading
|
|
268
|
+
|
|
269
|
+
Single file model: `~/.kafka/config.json` (or an explicit path you pass). This JSON file may contain:
|
|
270
|
+
|
|
271
|
+
- A `default` object with baseline Kafka client properties.
|
|
272
|
+
- One or more named environment objects (`dev`, `prod`, etc.).
|
|
273
|
+
|
|
274
|
+
When an environment name is provided (CLI `--env` or wrapper factory parameter), the effective configuration is the shallow merge of `default` overlaid by the named environment object (environment values win). If no environment is specified, the `default` object is used; if `default` is absent, the raw top-level mapping is returned.
|
|
275
|
+
|
|
276
|
+
No per-environment standalone files are read; only the single config file is considered.
|
|
277
|
+
|
|
278
|
+
### Role-Specific Environment Names
|
|
279
|
+
|
|
280
|
+
Producer and consumer often require different Kafka client properties (e.g. batching, compression, fetch settings). Define separate environment blocks like `prod-producer` and `prod-consumer` in the same config file:
|
|
281
|
+
|
|
282
|
+
```jsonc
|
|
283
|
+
{
|
|
284
|
+
"default": {"bootstrap.servers": "shared:9092", "client.id": "app-base"},
|
|
285
|
+
"prod-producer": {"bootstrap.servers": "prod-write:9092", "compression.type": "lz4", "linger.ms": 25},
|
|
286
|
+
"prod-consumer": {"bootstrap.servers": "prod-read:9092", "auto.offset.reset": "earliest", "fetch.max.bytes": 5242880}
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Library usage:
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
from klient import resolve_env_config, KafkaProducer, ProducerConfig, KafkaConsumer, ConsumerConfig
|
|
294
|
+
|
|
295
|
+
producer_raw = resolve_env_config('prod-producer', None)
|
|
296
|
+
consumer_raw = resolve_env_config('prod-consumer', None)
|
|
297
|
+
|
|
298
|
+
producer = KafkaProducer(ProducerConfig(
|
|
299
|
+
bootstrap_servers=producer_raw['bootstrap.servers'],
|
|
300
|
+
additional_config={k: v for k, v in producer_raw.items() if k != 'bootstrap.servers'}
|
|
301
|
+
))
|
|
302
|
+
|
|
303
|
+
consumer = KafkaConsumer(ConsumerConfig(
|
|
304
|
+
bootstrap_servers=consumer_raw['bootstrap.servers'],
|
|
305
|
+
group_id='analytics-group',
|
|
306
|
+
additional_config={k: v for k, v in consumer_raw.items() if k not in ('bootstrap.servers', 'group.id')}
|
|
307
|
+
))
|
|
308
|
+
consumer.subscribe(['input-topic'])
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
CLI usage with role-specific environments:
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
python -m klient --config-file ~/.kafka/config.json \
|
|
316
|
+
--producer-env prod-producer \
|
|
317
|
+
--consumer-env prod-consumer \
|
|
318
|
+
produce transaction events --count 10 --transactional-id tx-prod
|
|
319
|
+
|
|
320
|
+
python -m klient --config-file ~/.kafka/config.json \
|
|
321
|
+
--consumer-env prod-consumer \
|
|
322
|
+
consume stream events --timeout 1
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
If a role-specific env is not provided, the global `--env` (if set) applies; otherwise only `default` keys are used.
|
|
326
|
+
|
|
327
|
+
Section splitting: A combined env file may contain scoped objects:
|
|
328
|
+
|
|
329
|
+
```jsonc
|
|
330
|
+
{
|
|
331
|
+
"default": {
|
|
332
|
+
"bootstrap_servers": "localhost:9092"
|
|
333
|
+
},
|
|
334
|
+
"dev": {
|
|
335
|
+
"bootstrap_servers": "dev-broker:9092",
|
|
336
|
+
"producer": {"linger_ms": 5},
|
|
337
|
+
"consumer": {"group_id": "demo-group", "auto_offset_reset": "earliest"},
|
|
338
|
+
"admin": {"security_protocol": "PLAINTEXT"}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Factory helpers resolve and split config (note: subscribe now requires a list):
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
from klient.producer import KafkaProducer
|
|
347
|
+
prod = KafkaProducer.from_env_config("dev")
|
|
348
|
+
|
|
349
|
+
from klient.consumer import KafkaConsumer
|
|
350
|
+
cons = KafkaConsumer.from_env_config("dev", topics=["events"]) # topics is list
|
|
351
|
+
|
|
352
|
+
from klient.admin import KafkaAdmin
|
|
353
|
+
adm = KafkaAdmin.from_env_config("dev")
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
If an env is missing, an empty dict is returned (wrappers fall back to explicit arguments or defaults like `localhost:9092`).
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Isolation Level (Default: read_committed)
|
|
361
|
+
|
|
362
|
+
The consumer default was changed from `read_uncommitted` to `read_committed` to hide aborted transactional messages. Override per CLI flag or `ConsumerConfig(isolation_level="read_uncommitted")` when debugging.
|
|
363
|
+
|
|
364
|
+
| Level | Behavior |
|
|
365
|
+
|-------|----------|
|
|
366
|
+
| read_committed | Only committed transactional messages + non-transactional |
|
|
367
|
+
| read_uncommitted | Includes pending + aborted transactional messages |
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## CLI Usage
|
|
372
|
+
|
|
373
|
+
Invoke via module:
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
python -m klient --help
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Configuration Display
|
|
380
|
+
|
|
381
|
+
Use `--show-config` to display all Kafka configuration details before executing any command:
|
|
382
|
+
|
|
383
|
+
```bash
|
|
384
|
+
# Show configuration for a consumer command
|
|
385
|
+
python -m klient --show-config consume poll events --group my-group
|
|
386
|
+
|
|
387
|
+
# Show configuration with specific environment
|
|
388
|
+
python -m klient -e production --show-config admin list-topics
|
|
389
|
+
|
|
390
|
+
# Show configuration for producer with detailed command info
|
|
391
|
+
python -m klient --show-config produce send events --key user1 --value "hello"
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
The configuration display includes:
|
|
395
|
+
|
|
396
|
+
- **Connection Details**: Bootstrap servers, environment names
|
|
397
|
+
- **Command Information**: Specific command parameters and options
|
|
398
|
+
- **Configuration Sections**: All producer, consumer, and admin settings
|
|
399
|
+
- **Security**: Sensitive values (passwords, keys, secrets) are automatically masked
|
|
400
|
+
- **Available Environments**: Lists all configured environments from config file
|
|
401
|
+
|
|
402
|
+
### Output Options
|
|
403
|
+
|
|
404
|
+
Control output formatting, filtering, and destination:
|
|
405
|
+
|
|
406
|
+
```bash
|
|
407
|
+
# Write output to file instead of stdout
|
|
408
|
+
python -m klient admin list-topics --output-file topics.json
|
|
409
|
+
|
|
410
|
+
# Pretty-formatted JSON (default)
|
|
411
|
+
python -m klient --json info config-dump
|
|
412
|
+
|
|
413
|
+
# Compact JSON (single line, no extra spaces)
|
|
414
|
+
python -m klient --json --compact-json info config-dump
|
|
415
|
+
|
|
416
|
+
# Filter output with regex pattern
|
|
417
|
+
python -m klient --filter "prod" info config-dump
|
|
418
|
+
|
|
419
|
+
# Filter JSON by specific key (key:regex syntax)
|
|
420
|
+
python -m klient --json --filter "bootstrap.servers:kafka" info config-dump
|
|
421
|
+
|
|
422
|
+
# Combine all options: filtered, formatted output to file
|
|
423
|
+
python -m klient --json --compact-json --filter "server:prod" --output-file filtered.json info config-dump
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
#### Filtering Examples
|
|
427
|
+
|
|
428
|
+
**Text Filtering:**
|
|
429
|
+
```bash
|
|
430
|
+
# Show only lines containing "kafka"
|
|
431
|
+
python -m klient --filter "kafka" admin list-topics
|
|
432
|
+
|
|
433
|
+
# Case-insensitive regex patterns
|
|
434
|
+
python -m klient --filter "prod.*server" info config-dump
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
**JSON Key Filtering:**
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
# Filter by specific key values
|
|
441
|
+
python -m klient --json --filter "topic:events" consume poll events
|
|
442
|
+
|
|
443
|
+
# Filter nested JSON structures
|
|
444
|
+
python -m klient --json --filter "host:prod" info config-dump
|
|
445
|
+
|
|
446
|
+
# Use regex patterns in key filtering
|
|
447
|
+
python -m klient --json --filter "name:^test.*" admin list-topics
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Key commands (abbreviated) reflecting current subcommand syntax:
|
|
451
|
+
|
|
452
|
+
Produce one message (sync):
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
python -m klient produce send events --key k1 --value hello
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Transactional batch (N messages in one commit):
|
|
459
|
+
|
|
460
|
+
```bash
|
|
461
|
+
python -m klient produce transaction events --count 5 --transactional-id batch-1
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Produce from JSON file (array format: `[{}, {}, ...]`):
|
|
465
|
+
|
|
466
|
+
```bash
|
|
467
|
+
# Basic usage - send entire JSON object as message value
|
|
468
|
+
python -m klient produce from-file events messages.json
|
|
469
|
+
|
|
470
|
+
# Extract specific fields for message components
|
|
471
|
+
python -m klient produce from-file events data.json --key-field user_id --partition-field shard
|
|
472
|
+
|
|
473
|
+
# Transactional batch processing from file
|
|
474
|
+
python -m klient produce from-file events batch.json --transactional-id tx-1 --batch-size 50
|
|
475
|
+
|
|
476
|
+
# Extract headers from JSON field
|
|
477
|
+
python -m klient produce from-file events events.json --headers-field metadata --key-field id
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
**JSON File Format:**
|
|
481
|
+
The JSON file must contain an array of objects. Each object becomes a separate Kafka message:
|
|
482
|
+
|
|
483
|
+
```json
|
|
484
|
+
[
|
|
485
|
+
{
|
|
486
|
+
"user_id": "user1",
|
|
487
|
+
"data": "message content",
|
|
488
|
+
"shard": 0,
|
|
489
|
+
"metadata": {"type": "event", "version": "1.0"}
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
"user_id": "user2",
|
|
493
|
+
"data": "another message",
|
|
494
|
+
"shard": 1,
|
|
495
|
+
"metadata": {"type": "command", "version": "2.0"}
|
|
496
|
+
}
|
|
497
|
+
]
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Field extraction options:
|
|
501
|
+
|
|
502
|
+
- `--key-field`: Extract this field as message key (removed from value)
|
|
503
|
+
- `--partition-field`: Extract this field as partition number (removed from value)
|
|
504
|
+
- `--headers-field`: Extract this object field as message headers (removed from value)
|
|
505
|
+
- Remaining fields become the JSON message value
|
|
506
|
+
|
|
507
|
+
### Consuming Messages
|
|
508
|
+
|
|
509
|
+
All consume commands require a topic name as the first positional argument after the subcommand:
|
|
510
|
+
|
|
511
|
+
```bash
|
|
512
|
+
python -m klient consume <subcommand> <TOPIC> [options...]
|
|
513
|
+
# Examples:
|
|
514
|
+
python -m klient consume poll my-topic --group my-group
|
|
515
|
+
python -m klient consume batch events --count 10
|
|
516
|
+
python -m klient consume stream logs --limit 100
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
Poll once (single message attempt):
|
|
520
|
+
|
|
521
|
+
```bash
|
|
522
|
+
python -m klient consume poll events --group g1 --timeout 2
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Batch (bounded fetch up to --count messages):
|
|
526
|
+
|
|
527
|
+
```bash
|
|
528
|
+
python -m klient consume batch events --group g1 --count 10 --timeout 5
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
Stream (unlimited; interrupt with Ctrl+C):
|
|
532
|
+
|
|
533
|
+
```bash
|
|
534
|
+
python -m klient consume stream events --group g1 --timeout 1
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
Stream with limit (first N then exit):
|
|
538
|
+
|
|
539
|
+
```bash
|
|
540
|
+
python -m klient consume stream events --group g1 --timeout 1 --limit 100
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
Stream with graceful shutdown grace period (allow in-flight processing to finish):
|
|
544
|
+
|
|
545
|
+
```bash
|
|
546
|
+
python -m klient consume stream events --group g1 --timeout 1 --grace-period 2.5
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
Seek to specific offset before consuming (available in poll, batch, and stream):
|
|
550
|
+
|
|
551
|
+
```bash
|
|
552
|
+
# Seek to offset 1000 in ALL partitions
|
|
553
|
+
python -m klient consume poll events --group g1 --seek-to-offset 1000
|
|
554
|
+
|
|
555
|
+
# Seek to offset 1000 in SPECIFIC partition
|
|
556
|
+
python -m klient consume poll events --group g1 --seek-to-offset 1000 --seek-to-partition 0
|
|
557
|
+
|
|
558
|
+
# Works with batch and stream commands too
|
|
559
|
+
python -m klient consume batch events --group g1 --seek-to-offset 500
|
|
560
|
+
python -m klient consume stream events --group g1 --seek-to-offset 2000 --seek-to-partition 1
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
Isolation override example:
|
|
564
|
+
|
|
565
|
+
```bash
|
|
566
|
+
python -m klient consume poll events --group g1 --isolation read_uncommitted
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Config dump (merged view for env):
|
|
570
|
+
|
|
571
|
+
```bash
|
|
572
|
+
python -m klient info config-dump --env dev
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
See inline `--help` for each subcommand; consume help text explains poll vs batch vs stream semantics. Multi-topic subscription is supported only via library usage (`consumer.subscribe(["t1","t2"])`); the CLI stream/poll/batch commands accept a single topic argument.
|
|
576
|
+
|
|
577
|
+
---
|
|
578
|
+
|
|
579
|
+
## Testing & Coverage
|
|
580
|
+
|
|
581
|
+
Run tests:
|
|
582
|
+
|
|
583
|
+
```bash
|
|
584
|
+
pytest -q
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
Collect coverage (library files; CLI omitted):
|
|
588
|
+
|
|
589
|
+
```bash
|
|
590
|
+
pytest --cov=klient --cov-report=term-missing
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Goal: maintain >=80% coverage. Add focused tests for error paths before broad refactors.
|
|
594
|
+
|
|
595
|
+
---
|
|
596
|
+
|
|
597
|
+
## Coding Guidelines
|
|
598
|
+
|
|
599
|
+
See `.github/copilot-instructions.md` for naming, error handling, logging, and testing standards. Changes affecting public API must update README + tests in the same commit.
|
|
600
|
+
|
|
601
|
+
---
|
|
602
|
+
|
|
603
|
+
## Roadmap / Next Steps
|
|
604
|
+
|
|
605
|
+
- Sustain >=80% coverage; expand edge-path tests (rebalance failures, half-open circuit breaker)
|
|
606
|
+
- Add optional SASL/SSL config examples
|
|
607
|
+
- Structured logging integration hook for external log routers
|
|
608
|
+
- Benchmarks for high-throughput produce/consume scenarios
|
|
609
|
+
- Prometheus / OpenMetrics exporter (wrapping `metrics.snapshot()`)
|
|
610
|
+
- Advanced circuit breaker (time-based half-open state)
|
|
611
|
+
- Pluggable metrics sink (push counters to external system)
|
|
612
|
+
|
|
613
|
+
---
|
|
614
|
+
|
|
615
|
+
## Resiliency & Advanced Features
|
|
616
|
+
|
|
617
|
+
### Error Classes
|
|
618
|
+
|
|
619
|
+
Producer:
|
|
620
|
+
|
|
621
|
+
- `KafkaProducerError` (base)
|
|
622
|
+
- `KafkaProducerRetriableError` (temporary issues; auto-retried in `produce_with_retry`)
|
|
623
|
+
- `KafkaProducerFatalError` (non-recoverable)
|
|
624
|
+
- `KafkaTransactionError` (transaction lifecycle failure)
|
|
625
|
+
|
|
626
|
+
### Retry, Jitter & Circuit Breaker
|
|
627
|
+
|
|
628
|
+
`produce_with_retry` / `aproduce_with_retry` implement exponential backoff with +/-10% jitter to reduce thundering herds against brokers during partial outages. Backoff formula per attempt `sleep = base_backoff * 2^(attempt-1) +/- 10% jitter`.
|
|
629
|
+
|
|
630
|
+
Circuit breaker semantics: when the maximum attempts are exhausted for a single produce call, the breaker is considered "open" for that invocation (logged as JSON event `produce_circuit_open` / `produce_async_circuit_open`). A subsequent successful produce call logs circuit closure. This lightweight breaker prevents silent spin when persistent failures occur and provides an instrumentation hook (log monitor can alert on open events). For more advanced scenarios, extend with time-based half-open states.
|
|
631
|
+
|
|
632
|
+
### Error Code Classification
|
|
633
|
+
|
|
634
|
+
Producer maintains sets of Kafka error codes for retriable, fatal, and fencing conditions (see `producer.py`: `RETRIABLE_ERROR_CODES`, `FATAL_ERROR_CODES`, `FENCING_ERROR_CODES`). These are populated defensively (wrapped in `try/except AttributeError` for version portability). Delivery callback logs classification buckets. Extend sets as operational patterns emerge.
|
|
635
|
+
|
|
636
|
+
### Structured JSON Logging
|
|
637
|
+
|
|
638
|
+
Transaction lifecycle and retry circuit breaker events emit compact JSON objects (e.g. `{"event":"transaction_commit","transaction_id":"relay-tx-producer","duration_ms":3.2}`). Rebalance callbacks similarly emit `{"event":"rebalance_assign",...}` forms. This enables direct ingestion by log pipelines (Splunk/ELK). Avoid parsing non-JSON human-formatted logs.
|
|
639
|
+
|
|
640
|
+
### Metrics (In-Process Counters)
|
|
641
|
+
|
|
642
|
+
Module `klient.metrics` offers thread-safe counters: `inc(name: str, value: int = 1)`, `get(name: str)`, and `snapshot()` (returns copy of all counters). Integrated / emitted counters:
|
|
643
|
+
|
|
644
|
+
- `producer.tx.begin|commit|abort`
|
|
645
|
+
- `consumer.rebalance.assign|revoke|lost`
|
|
646
|
+
- `consumer.shutdown.signal` (first SIGINT/SIGTERM received during stream)
|
|
647
|
+
- `consumer.shutdown.complete` (stream termination path executed)
|
|
648
|
+
- (Optional extension) `producer.retry` per retriable attempt
|
|
649
|
+
|
|
650
|
+
Example:
|
|
651
|
+
|
|
652
|
+
```python
|
|
653
|
+
from klient.metrics import snapshot
|
|
654
|
+
print(snapshot()) # {'producer.tx.begin': 12, 'consumer.rebalance.assign': 3, ...}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
Prometheus integration can wrap `snapshot()` periodically; for large-scale multi-process exporters use shared storage (Redis/memfd) or native client libs.
|
|
658
|
+
|
|
659
|
+
### Relay Concurrency
|
|
660
|
+
|
|
661
|
+
`ExactlyOnceRelay.arun(..., max_in_flight=N)` allows up to N transactional batches in-flight concurrently (semaphore controlled). This improves throughput for high-latency commit scenarios while preserving batch atomicity. Tune `batch_size` + `max_in_flight` to balance memory and latency.
|
|
662
|
+
|
|
663
|
+
### Isolation & Visibility
|
|
664
|
+
|
|
665
|
+
Use `read_committed` consumers when relaying from transactional producers to avoid forwarding aborted messages. The relay commits source offsets only after a successful target transaction commit.
|
|
666
|
+
|
|
667
|
+
### Extending Resiliency
|
|
668
|
+
|
|
669
|
+
Planned enhancements: configurable circuit breaker (cooldown window), pluggable metrics sink, dynamic error code refresh. Contributions welcome—keep changes focused and tested.
|
|
670
|
+
|
|
671
|
+
- `KafkaProducerFencedError` (fencing / competing transactional id)
|
|
672
|
+
|
|
673
|
+
Consumer:
|
|
674
|
+
|
|
675
|
+
- `KafkaConsumerError` (base)
|
|
676
|
+
- `KafkaConsumerRetriableError` (safe to retry poll)
|
|
677
|
+
- `KafkaConsumerFatalError` (stop consumption)
|
|
678
|
+
- `KafkaConsumerRebalanceError` (issues in assignment callbacks)
|
|
679
|
+
|
|
680
|
+
### Rebalance Callbacks
|
|
681
|
+
|
|
682
|
+
Pass `on_assign`, `on_revoke`, `on_lost` to `KafkaConsumer` constructor. Exceptions raised inside callbacks are wrapped in `KafkaConsumerRebalanceError` to surface issues without silent failure.
|
|
683
|
+
|
|
684
|
+
### Retriable Produce
|
|
685
|
+
|
|
686
|
+
Use:
|
|
687
|
+
|
|
688
|
+
```python
|
|
689
|
+
producer.produce_with_retry("topic", value=b"data", max_attempts=5, base_backoff=0.05)
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
Async:
|
|
693
|
+
|
|
694
|
+
```python
|
|
695
|
+
await producer.aproduce_with_retry("topic", value=b"data")
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
Populate `RETRIABLE_ERROR_CODES`, `FATAL_ERROR_CODES`, `FENCING_ERROR_CODES` in `producer.py` for environment-specific tuning.
|
|
699
|
+
|
|
700
|
+
### Exactly-Once Relay Helper
|
|
701
|
+
|
|
702
|
+
Module: `klient.relay.ExactlyOnceRelay`
|
|
703
|
+
|
|
704
|
+
```python
|
|
705
|
+
from klient.consumer import KafkaConsumer, ConsumerConfig
|
|
706
|
+
from klient.producer import KafkaProducer, ProducerConfig
|
|
707
|
+
from klient.relay import ExactlyOnceRelay
|
|
708
|
+
|
|
709
|
+
cons = KafkaConsumer(ConsumerConfig(bootstrap_servers="localhost:9092", group_id="relay", isolation_level="read_committed"))
|
|
710
|
+
prod = KafkaProducer(ProducerConfig(bootstrap_servers="localhost:9092", transactional_id="relay-tx"))
|
|
711
|
+
|
|
712
|
+
def transform(msg):
|
|
713
|
+
return msg # identity or modify msg.value
|
|
714
|
+
|
|
715
|
+
relay = ExactlyOnceRelay(cons, prod, transform=transform, batch_size=25)
|
|
716
|
+
relay.run("input-topic", "output-topic") # blocking
|
|
717
|
+
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
Async variant:
|
|
722
|
+
|
|
723
|
+
```python
|
|
724
|
+
await relay.arun("input-topic", "output-topic")
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
Guarantee model: offsets are committed only after transaction success, reducing risk of duplicate downstream visibility while still relying on broker EOS guarantees.
|
|
728
|
+
|
|
729
|
+
### Multi-Topic Subscription
|
|
730
|
+
|
|
731
|
+
Subscribe with more than one topic by passing a list:
|
|
732
|
+
|
|
733
|
+
```python
|
|
734
|
+
consumer.subscribe(["orders", "payments", "audit-events"]) # list-only API
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
When using the CLI, you still specify a single topic per invocation; for multi-topic inspection create a small script using the library.
|
|
738
|
+
|
|
739
|
+
### Observability Highlights
|
|
740
|
+
|
|
741
|
+
Implemented:
|
|
742
|
+
|
|
743
|
+
- Counters for `producer.tx.begin|commit|abort`, rebalance events, and shutdown lifecycle
|
|
744
|
+
- Transaction duration logging (`TransactionResult.duration_ms` in JSON logs)
|
|
745
|
+
- Structured JSON logs for transaction lifecycle, rebalance callbacks, stream shutdown events (`shutdown_requested`, `stream_ended`)
|
|
746
|
+
- Retry backoff with jitter and circuit breaker open/close events
|
|
747
|
+
- Async relay concurrency control (`max_in_flight`) for higher throughput
|
|
748
|
+
|
|
749
|
+
Shutdown JSON events (example):
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
```json
|
|
753
|
+
{"event":"shutdown_requested","signal":2,"topic":"events"}
|
|
754
|
+
{"event":"stream_ended","reason":"signal","messages":42,"duration_s":3.417}
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
These are emitted directly to stdout; integrate with log pipeline by tailing the process output.
|
|
759
|
+
|
|
760
|
+
### Graceful Shutdown
|
|
761
|
+
|
|
762
|
+
The stream consumer (`consume stream`) installs SIGINT/SIGTERM handlers. On first signal:
|
|
763
|
+
|
|
764
|
+
1. Counter `consumer.shutdown.signal` increments
|
|
765
|
+
2. A JSON line `{"event":"shutdown_requested",...}` is emitted
|
|
766
|
+
3. Loop enters a grace window (`--grace-period`, default 2s) allowing in-flight processing to finish
|
|
767
|
+
4. After grace, offsets commit (if auto-commit enabled) and the loop ends
|
|
768
|
+
5. Counter `consumer.shutdown.complete` increments and a final `stream_ended` JSON event prints
|
|
769
|
+
|
|
770
|
+
Set `--grace-period 0` for immediate termination (still emits both events). Use longer periods for at-least-once downstream processing guarantees.
|
|
771
|
+
|
|
772
|
+
### Continuous Transactional Relay (Library First)
|
|
773
|
+
|
|
774
|
+
While a CLI helper exists, production integrations typically import the wrappers directly for clearer control, richer transformation logic, and structured instrumentation. Below are canonical synchronous and asynchronous patterns.
|
|
775
|
+
|
|
776
|
+
#### Sync Relay with Transactions
|
|
777
|
+
|
|
778
|
+
```python
|
|
779
|
+
from klient.consumer import KafkaConsumer, ConsumerConfig
|
|
780
|
+
from klient.producer import KafkaProducer, ProducerConfig
|
|
781
|
+
|
|
782
|
+
SOURCE = "input-topic"
|
|
783
|
+
TARGET = "output-topic"
|
|
784
|
+
|
|
785
|
+
consumer = KafkaConsumer(ConsumerConfig(
|
|
786
|
+
bootstrap_servers="localhost:9092",
|
|
787
|
+
group_id="relay-sync",
|
|
788
|
+
topics=[SOURCE],
|
|
789
|
+
isolation_level="read_committed",
|
|
790
|
+
enable_auto_commit=False, # commit only after successful target transaction
|
|
791
|
+
))
|
|
792
|
+
|
|
793
|
+
producer = KafkaProducer(ProducerConfig(
|
|
794
|
+
bootstrap_servers="localhost:9092",
|
|
795
|
+
transactional_id="relay-sync-tx"
|
|
796
|
+
))
|
|
797
|
+
|
|
798
|
+
def transform_value(value_bytes: bytes) -> bytes:
|
|
799
|
+
"""Example transform: append a marker; return bytes."""
|
|
800
|
+
base = value_bytes.decode() if value_bytes else ""
|
|
801
|
+
return f"{base}|processed".encode()
|
|
802
|
+
|
|
803
|
+
batch = []
|
|
804
|
+
BATCH_SIZE = 25
|
|
805
|
+
|
|
806
|
+
try:
|
|
807
|
+
for msg in consumer.message_stream(timeout=0.5):
|
|
808
|
+
batch.append(msg)
|
|
809
|
+
if len(batch) >= BATCH_SIZE:
|
|
810
|
+
producer.begin_transaction()
|
|
811
|
+
for m in batch:
|
|
812
|
+
transformed = transform_value(m.value)
|
|
813
|
+
producer.produce(TARGET, key=m.key.decode() if m.key else None, value=transformed, flush=False)
|
|
814
|
+
producer.commit_transaction()
|
|
815
|
+
consumer.commit() # commit source offsets only after transaction success
|
|
816
|
+
batch.clear()
|
|
817
|
+
except KeyboardInterrupt:
|
|
818
|
+
# Allow current batch to finish or abort.
|
|
819
|
+
if producer.in_transaction:
|
|
820
|
+
producer.abort_transaction()
|
|
821
|
+
finally:
|
|
822
|
+
consumer.stop()
|
|
823
|
+
producer.close()
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
#### Async Relay with Concurrency
|
|
827
|
+
|
|
828
|
+
```python
|
|
829
|
+
import asyncio
|
|
830
|
+
from klient.consumer import KafkaConsumer, ConsumerConfig
|
|
831
|
+
from klient.producer import KafkaProducer, ProducerConfig
|
|
832
|
+
|
|
833
|
+
SOURCE = "input-topic"
|
|
834
|
+
TARGET = "output-topic"
|
|
835
|
+
|
|
836
|
+
consumer = KafkaConsumer(ConsumerConfig(
|
|
837
|
+
bootstrap_servers="localhost:9092",
|
|
838
|
+
group_id="relay-async",
|
|
839
|
+
topics=[SOURCE],
|
|
840
|
+
isolation_level="read_committed",
|
|
841
|
+
enable_auto_commit=False,
|
|
842
|
+
))
|
|
843
|
+
|
|
844
|
+
producer = KafkaProducer(ProducerConfig(
|
|
845
|
+
bootstrap_servers="localhost:9092",
|
|
846
|
+
transactional_id="relay-async-tx"
|
|
847
|
+
))
|
|
848
|
+
|
|
849
|
+
stop_event = asyncio.Event()
|
|
850
|
+
|
|
851
|
+
def _sigint_handler():
|
|
852
|
+
stop_event.set()
|
|
853
|
+
|
|
854
|
+
async def transform_bytes(b: bytes) -> bytes:
|
|
855
|
+
# Illustrative async transform (could call external service)
|
|
856
|
+
await asyncio.sleep(0) # yield
|
|
857
|
+
text = b.decode() if b else ""
|
|
858
|
+
return f"{text}|async".encode()
|
|
859
|
+
|
|
860
|
+
async def relay_forever(batch_size: int = 50):
|
|
861
|
+
buffer = []
|
|
862
|
+
async for msg in consumer.amessage_stream(timeout=0.5):
|
|
863
|
+
if stop_event.is_set():
|
|
864
|
+
break
|
|
865
|
+
buffer.append(msg)
|
|
866
|
+
if len(buffer) >= batch_size:
|
|
867
|
+
async with producer.atransaction():
|
|
868
|
+
for m in buffer:
|
|
869
|
+
transformed = await transform_bytes(m.value)
|
|
870
|
+
await producer.aproduce(TARGET, key=m.key.decode() if m.key else None, value=transformed)
|
|
871
|
+
consumer.commit()
|
|
872
|
+
buffer.clear()
|
|
873
|
+
# Flush residual messages (optional):
|
|
874
|
+
if buffer:
|
|
875
|
+
async with producer.atransaction():
|
|
876
|
+
for m in buffer:
|
|
877
|
+
transformed = await transform_bytes(m.value)
|
|
878
|
+
await producer.aproduce(TARGET, key=m.key.decode() if m.key else None, value=transformed)
|
|
879
|
+
consumer.commit()
|
|
880
|
+
|
|
881
|
+
async def main():
|
|
882
|
+
loop = asyncio.get_running_loop()
|
|
883
|
+
try:
|
|
884
|
+
loop.add_signal_handler(getattr(__import__('signal'), 'SIGINT'), _sigint_handler)
|
|
885
|
+
except (NotImplementedError, ValueError):
|
|
886
|
+
pass
|
|
887
|
+
await relay_forever()
|
|
888
|
+
consumer.stop()
|
|
889
|
+
producer.close()
|
|
890
|
+
|
|
891
|
+
asyncio.run(main())
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
Key points:
|
|
895
|
+
|
|
896
|
+
1. Source offsets are committed only after successful target transaction commit (exactly-once style).
|
|
897
|
+
2. Transform can be sync or async; ensure deterministic output for idempotence.
|
|
898
|
+
3. Graceful cancellation waits for current transaction scope to finish (avoid partial batch). Use a timeout wrapper if hard bounds are required.
|
|
899
|
+
4. For high throughput, shard by key/partition or use `ExactlyOnceRelay` with `max_in_flight` (async) for parallel in-flight transactions.
|
|
900
|
+
5. See `examples/continuous_relay_async.py` for a complete async variant with retry and error handling.
|
|
901
|
+
|
|
902
|
+
### Continuous Transactional Relay (CLI)
|
|
903
|
+
|
|
904
|
+
A common pattern is to consume from one topic, transform each message, and produce to another topic atomically in transactional batches. The CLI provides a `relay stream` command for this:
|
|
905
|
+
|
|
906
|
+
```bash
|
|
907
|
+
python -m klient relay stream source-topic target-topic \
|
|
908
|
+
--group relay-g1 \
|
|
909
|
+
--transactional-id relay-tx-id \
|
|
910
|
+
--batch-size 50 \
|
|
911
|
+
--timeout 0.5 \
|
|
912
|
+
--grace-period 2 \
|
|
913
|
+
--transform 'value.upper()'
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
Behavior:
|
|
917
|
+
|
|
918
|
+
- Buffers up to `--batch-size` messages from `source-topic` under `--group`.
|
|
919
|
+
- Begins a transaction, applies `--transform` expression to each message value (`value` bound to decoded UTF-8 string), produces all outputs to `target-topic`.
|
|
920
|
+
- Commits the transaction; on success commits source offsets (exactly-once style handoff) and emits `relay_batch_committed` JSON event.
|
|
921
|
+
- On transactional failure the batch is aborted (`relay_batch_aborted` event) and offsets are NOT committed (messages will be retried on next pass).
|
|
922
|
+
- Graceful shutdown via SIGINT/SIGTERM triggers `shutdown_requested` + waits up to `--grace-period` seconds before final `relay_stream_ended` event.
|
|
923
|
+
|
|
924
|
+
Metrics involved:
|
|
925
|
+
|
|
926
|
+
- `relay.batch.commit` increments per committed batch.
|
|
927
|
+
- `relay.messages.forwarded` aggregates total messages successfully forwarded.
|
|
928
|
+
- `consumer.shutdown.signal` / `consumer.shutdown.complete` for lifecycle.
|
|
929
|
+
|
|
930
|
+
Library alternative for tighter control or custom transformation logic can use `ExactlyOnceRelay` directly or write an async loop with `atransaction()` contexts.
|
|
931
|
+
|
|
932
|
+
### Future Enhancements
|
|
933
|
+
|
|
934
|
+
- Populate error code sets with actual `KafkaError` codes
|
|
935
|
+
- Backoff jitter & circuit breaker after repeated failures
|
|
936
|
+
- Configurable max in-flight transactions for high-throughput relays
|
|
937
|
+
- Pluggable metrics reporter interface
|
|
938
|
+
|
|
939
|
+
---
|
|
940
|
+
|
|
941
|
+
## Troubleshooting
|
|
942
|
+
|
|
943
|
+
| Symptom | Suggestion |
|
|
944
|
+
|---------|------------|
|
|
945
|
+
| No messages consumed | Check topic name, group id, and that messages are actually produced. Increase `timeout`. |
|
|
946
|
+
| Abort errors in transactions | Ensure broker supports transactions and `transactional.id` is stable per producer instance. |
|
|
947
|
+
| Config env not found | Verify filename or presence of `default` block in combined config.json. |
|
|
948
|
+
| Unexpected duplicates | Consider enabling idempotence (auto when `transactional_id` set). |
|
|
949
|
+
|
|
950
|
+
---
|
|
951
|
+
|
|
952
|
+
## License
|
|
953
|
+
|
|
954
|
+
MIT (or organization internal) — update as appropriate.
|
|
955
|
+
|