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.
@@ -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
+