potik 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.
potik/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """Typed handlers for Kafka topics.
2
+
3
+ Declare each `Topic` with the types and serdes of its keys and values, register handlers on a `Router`, and build a core `Runtime` from the routers with `build_runtime()`. The package also re-exports the core names that services use.
4
+ """
5
+
6
+ from potik.build import build_runtime
7
+ from potik.core.config import Config
8
+ from potik.core.errors import InvalidOutputError, PermanentError, PotikError
9
+ from potik.core.offsets import OffsetContext, OffsetPolicy, resume_or_earliest, resume_or_fail, resume_or_latest
10
+ from potik.core.producer import Producer
11
+ from potik.core.replies import PendingReply, ReplyListener
12
+ from potik.core.runtime import Runtime
13
+ from potik.core.types import (
14
+ ConsumerRecord,
15
+ Delivery,
16
+ ProducerRecord,
17
+ RuntimeStatus,
18
+ )
19
+ from potik.handler import Context, DecodeError, HandlerResult, Header, Key
20
+ from potik.middleware.topics import TopicCheckError
21
+ from potik.router import RegistrationError, Router
22
+ from potik.topic import EncodeError, OutputTopic, Topic
23
+
24
+ __all__ = [
25
+ "Config",
26
+ "ConsumerRecord",
27
+ "Context",
28
+ "DecodeError",
29
+ "Delivery",
30
+ "EncodeError",
31
+ "HandlerResult",
32
+ "Header",
33
+ "InvalidOutputError",
34
+ "Key",
35
+ "OffsetContext",
36
+ "OffsetPolicy",
37
+ "OutputTopic",
38
+ "PendingReply",
39
+ "PermanentError",
40
+ "PotikError",
41
+ "Producer",
42
+ "ProducerRecord",
43
+ "RegistrationError",
44
+ "ReplyListener",
45
+ "Router",
46
+ "Runtime",
47
+ "RuntimeStatus",
48
+ "Topic",
49
+ "TopicCheckError",
50
+ "build_runtime",
51
+ "resume_or_earliest",
52
+ "resume_or_fail",
53
+ "resume_or_latest",
54
+ ]
potik/build.py ADDED
@@ -0,0 +1,124 @@
1
+ """Build a core `Runtime` from routers of typed handlers."""
2
+
3
+ from collections.abc import Awaitable, Iterable, Sequence
4
+ from typing import cast
5
+
6
+ from potik.core.config import Config
7
+ from potik.core.middleware import BaseMiddleware, _chain, _middleware_tuple
8
+ from potik.core.runtime import Runtime
9
+ from potik.core.types import ConsumerRecord, ProducerRecord, RecordHandler
10
+ from potik.handler import _NO_CONTEXT
11
+ from potik.middleware.concurrency import ConcurrencyMiddleware, _validate_limit
12
+ from potik.middleware.exception import ExceptionMiddleware, Rule
13
+ from potik.middleware.topics import TopicCheckMiddleware, _declared_topics
14
+ from potik.router import RegistrationError, Router, _callable_name, _Registration
15
+
16
+ __all__ = [
17
+ "build_runtime",
18
+ ]
19
+
20
+
21
+ def build_runtime(
22
+ config: Config,
23
+ routers: Iterable[Router],
24
+ *,
25
+ context: object = _NO_CONTEXT,
26
+ middleware: Iterable[BaseMiddleware] = (),
27
+ exception_rules: Iterable[Rule[Exception]] = (),
28
+ max_concurrency: int | None = None,
29
+ check_topics: bool = True,
30
+ ) -> Runtime:
31
+ """Build a `Runtime` that runs the routers' handlers.
32
+
33
+ Open the resources that handlers need first, e.g. a database pool or a `Producer`, then build the runtime with them and run it. A runtime runs once, so another run needs a new runtime with new middleware instances.
34
+
35
+ Each record passes through `middleware`, then the exception rules, then the concurrency limits, then its handler.
36
+
37
+ Args:
38
+ config: The runtime limits and client options.
39
+ routers: The routers whose handlers to run. The runtime copies their handlers, so later registrations have no effect.
40
+ context: What `Context()` parameters take, e.g. a dataclass of the service's clients.
41
+ middleware: Middleware instances, outermost first (see `BaseMiddleware`). Pass exception rules as `exception_rules`, not as an `ExceptionMiddleware`.
42
+ exception_rules: Rules that retry or recover from handler exceptions, tried in order (see `ExceptionMiddleware`). They run inside `middleware`, so e.g. tracing and metrics see one call per record, including its retries. An exception that no rule handles stops the record's partition, or fails the run (see `Config.stop_failed_partitions`). To apply a rule to some topics only, use `on(..., topics=...)`.
43
+ max_concurrency: The maximum number of handler calls that run at once, across all partitions and topics, e.g. the size of a database pool, or None for no limit. Like each handler's own `max_concurrency`, it limits only handler calls, so retry backoff holds no slot.
44
+ check_topics: Whether `run()` first checks that the topics exist, with a `TopicCheckMiddleware` before `middleware`. It checks the handled topics and the output topics of the handlers, routers, middleware, and exception rules. A missing topic fails the run with `TopicCheckError`. Turn it off e.g. to start before the topics are created.
45
+
46
+ Returns:
47
+ The runtime. Call its `run()` to run it.
48
+
49
+ Raises:
50
+ RegistrationError: If the routers have no handlers, two routers handle the same topic, a handler takes `Context()` and there is no `context`, or `middleware` includes an `ExceptionMiddleware`.
51
+ TypeError: If a router is not a `Router`, a middleware is not a `BaseMiddleware` instance, or an exception rule is a type or is not callable.
52
+ ValueError: If `max_concurrency` is not a positive integer or None.
53
+
54
+ Example:
55
+ ```python
56
+ async def main() -> None:
57
+ async with create_pool() as db:
58
+ await potik.build_runtime(
59
+ config,
60
+ [router],
61
+ context=db,
62
+ middleware=[TracingMiddleware()],
63
+ exception_rules=[
64
+ on(TimeoutError, retry(max_attempts=5)),
65
+ on(Exception, dead_letter(dead_letters)),
66
+ ],
67
+ ).run()
68
+ ```
69
+ """
70
+ _validate_limit(max_concurrency, "max_concurrency")
71
+ stack = _middleware_tuple(middleware)
72
+ for item in stack:
73
+ if isinstance(item, ExceptionMiddleware):
74
+ raise RegistrationError("pass exception rules as build_runtime(exception_rules=...)")
75
+ if rules := tuple(exception_rules):
76
+ stack = (*stack, ExceptionMiddleware(*rules))
77
+
78
+ registrations: dict[str, _Registration] = {}
79
+ outputs = [topic for item in stack for topic in _declared_topics(item)]
80
+ for router in routers:
81
+ if not isinstance(router, Router):
82
+ raise TypeError(f"routers must be Router instances, got {type(router).__name__}")
83
+ for topic, registration in router.registrations.items():
84
+ if (existing := registrations.get(topic)) is not None:
85
+ raise RegistrationError(
86
+ f"topic {topic!r} is handled by both {_callable_name(existing.handler)} "
87
+ f"and {_callable_name(registration.handler)}"
88
+ )
89
+ registrations[topic] = registration
90
+ outputs += registration.output_topics
91
+ outputs += router.output_topics
92
+ if not registrations:
93
+ raise RegistrationError("a runtime needs at least one handler")
94
+
95
+ per_topic = {
96
+ topic: limit for topic, registration in registrations.items() if (limit := registration.max_concurrency)
97
+ }
98
+ limits = (
99
+ (ConcurrencyMiddleware(max_concurrency=max_concurrency, per_topic=per_topic),)
100
+ if max_concurrency or per_topic
101
+ else ()
102
+ )
103
+ handlers = {
104
+ topic: cast(RecordHandler, _chain(limits, "handle", _bind(registration, context)))
105
+ for topic, registration in registrations.items()
106
+ }
107
+
108
+ def handle(record: ConsumerRecord) -> Awaitable[Sequence[ProducerRecord]]:
109
+ return handlers[cast(str, record.topic())](record)
110
+
111
+ topics = tuple(registrations)
112
+ if check_topics:
113
+ stack = (TopicCheckMiddleware((*topics, *outputs)), *stack)
114
+ key_concurrency = {
115
+ topic: limit for topic, registration in registrations.items() if (limit := registration.key_concurrency)
116
+ }
117
+ return Runtime(config, topics, handle, middleware=stack, key_concurrency=key_concurrency)
118
+
119
+
120
+ def _bind(registration: _Registration, context: object) -> RecordHandler:
121
+ try:
122
+ return registration.bind(context)
123
+ except LookupError as exc:
124
+ raise RegistrationError(f"handler {_callable_name(registration.handler)} {exc}") from exc
potik/core/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """The core runtime, a minimal wrapper around `confluent-kafka`.
2
+
3
+ It works without the typed layer: a handler takes a raw `ConsumerRecord` and returns a list or tuple of `ProducerRecord`s.
4
+ """
5
+
6
+ from potik.core.config import Config
7
+ from potik.core.errors import InvalidOffsetError, InvalidOutputError, PartitionsLostError, PermanentError, PotikError
8
+ from potik.core.middleware import BaseMiddleware, ProducerScope, RunScope
9
+ from potik.core.offsets import (
10
+ OffsetContext,
11
+ OffsetPolicy,
12
+ resume_or_earliest,
13
+ resume_or_fail,
14
+ resume_or_latest,
15
+ )
16
+ from potik.core.producer import Producer
17
+ from potik.core.replies import PendingReply, ReplyListener
18
+ from potik.core.runtime import Runtime
19
+ from potik.core.types import (
20
+ ConsumerRecord,
21
+ Delivery,
22
+ Headers,
23
+ PartitionStatus,
24
+ ProducerRecord,
25
+ RecordHandler,
26
+ RecordSender,
27
+ RuntimeStatus,
28
+ Transaction,
29
+ )
30
+
31
+ __all__ = [
32
+ "BaseMiddleware",
33
+ "Config",
34
+ "ConsumerRecord",
35
+ "Delivery",
36
+ "Headers",
37
+ "InvalidOffsetError",
38
+ "InvalidOutputError",
39
+ "OffsetContext",
40
+ "OffsetPolicy",
41
+ "PartitionStatus",
42
+ "PartitionsLostError",
43
+ "PendingReply",
44
+ "PermanentError",
45
+ "PotikError",
46
+ "Producer",
47
+ "ProducerRecord",
48
+ "ProducerScope",
49
+ "RecordHandler",
50
+ "RecordSender",
51
+ "ReplyListener",
52
+ "RunScope",
53
+ "Runtime",
54
+ "RuntimeStatus",
55
+ "Transaction",
56
+ "resume_or_earliest",
57
+ "resume_or_fail",
58
+ "resume_or_latest",
59
+ ]
@@ -0,0 +1,208 @@
1
+ """Commit handled records' outputs and offsets in transactions, through one transactional producer."""
2
+
3
+ import logging
4
+ import time
5
+ from collections.abc import Mapping, Sequence
6
+ from functools import partial
7
+ from types import MappingProxyType
8
+
9
+ import anyio
10
+ from confluent_kafka import KafkaError, KafkaException, Message, Producer, TopicPartition
11
+
12
+ from potik.core.config import Config
13
+ from potik.core.consumer import ConsumerThread
14
+ from potik.core.errors import PotikError
15
+ from potik.core.middleware import BaseMiddleware, _call_once
16
+ from potik.core.types import ProducerRecord, Transaction
17
+
18
+ __all__: list[str] = []
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _COMMIT_ATTEMPTS = 3
23
+ # How often an idle producer serves its callbacks (errors, statistics, logs). Committing serves them otherwise.
24
+ _IDLE_POLL = 1.0
25
+ _STALE_EPOCH = frozenset({KafkaError.ILLEGAL_GENERATION, KafkaError.STALE_MEMBER_EPOCH})
26
+ # Reported for the outputs of an aborted transaction, e.g. one repeated for a stale epoch.
27
+ _PURGED = frozenset({KafkaError._PURGE_QUEUE, KafkaError._PURGE_INFLIGHT})
28
+
29
+
30
+ class CommitterStoppedError(PotikError):
31
+ """The run stopped, so handled records can no longer commit."""
32
+
33
+
34
+ class Committer:
35
+ """Commits completed records of all partitions through one transactional producer.
36
+
37
+ Each transaction atomically produces a batch's outputs and commits its inputs' next offsets with the consumer's group metadata (KIP-447). With the metadata, the coordinator rejects commits from a fenced or stale group member. The next batch accumulates while a transaction is in flight. `commit_batch_size` caps it and backpressures the handlers.
38
+
39
+ A transaction rejected for a stale group epoch is repeated. Any other failed transaction fails the run. Its offsets stay uncommitted, and its inputs replay on the next run.
40
+
41
+ Each transaction runs through the `commit` stage of `middleware`.
42
+ """
43
+
44
+ def __init__(self, config: Config, consumer: ConsumerThread, middleware: Sequence[BaseMiddleware] = ()) -> None:
45
+ self._middleware = middleware
46
+ self._delivery_error: KafkaError | None = None
47
+ self._producer = Producer(config._producer_config(self._on_failed_delivery))
48
+ self._consumer = consumer
49
+ self._init_timeout = config.transaction_timeout_ms / 1000
50
+ self._interval = config.commit_interval_ms / 1000
51
+ self._capacity = config.commit_batch_size
52
+ # Producer calls block in C, so they run on a worker thread. They are joined, never abandoned, when the awaiting task is cancelled.
53
+ self._thread = anyio.CapacityLimiter(1)
54
+ self._scope = anyio.CancelScope()
55
+ self._offsets: dict[tuple[str, int], int] = {} # (topic, partition) -> next offset
56
+ self._outputs: list[ProducerRecord] = []
57
+ self._records = 0
58
+ self._has_work = anyio.Event() # the batch is non-empty
59
+ self._commit_requested = anyio.Event() # the batch is full, or a flush waits for it
60
+ self._batch_taken = anyio.Event() # the batch was taken by a transaction
61
+ self._batch_committed = anyio.Event() # a transaction committed
62
+ self._batches_taken = 0 # batches taken
63
+ self._batches_committed = 0 # transactions committed
64
+ self._next_start = 0.0
65
+ self._pending_since: float | None = None # monotonic time the batch became non-empty
66
+ self._committing_since: float | None = None
67
+ self._stopped: BaseException | None = None
68
+
69
+ async def start(self) -> None:
70
+ await anyio.to_thread.run_sync(self._producer.init_transactions, self._init_timeout, limiter=self._thread)
71
+
72
+ @property
73
+ def full(self) -> bool:
74
+ return self._records >= self._capacity
75
+
76
+ async def wait_space(self) -> None:
77
+ while self._records >= self._capacity:
78
+ self._check()
79
+ await self._batch_taken.wait()
80
+
81
+ @property
82
+ def oldest_uncommitted_at(self) -> float | None:
83
+ """The monotonic time when the oldest completed but uncommitted record was added."""
84
+ return self._committing_since if self._committing_since is not None else self._pending_since
85
+
86
+ def add(self, partition: tuple[str, int], next_offset: int, outputs: Sequence[ProducerRecord]) -> None:
87
+ if not self._offsets:
88
+ self._pending_since = time.monotonic()
89
+ self._offsets[partition] = next_offset
90
+ if outputs:
91
+ self._outputs.extend(outputs)
92
+ self._records += 1
93
+ self._has_work.set()
94
+ if self._records >= self._capacity:
95
+ self._commit_requested.set()
96
+
97
+ async def flush(self) -> None:
98
+ """Commit everything added so far, without waiting for the commit interval."""
99
+ target = self._batches_taken
100
+ if self._offsets:
101
+ target += 1
102
+ self._commit_requested.set()
103
+ while self._batches_committed < target:
104
+ self._check()
105
+ await self._batch_committed.wait()
106
+
107
+ def stop(self, reason: BaseException) -> None:
108
+ """Commit nothing more, and fail current and future waiters."""
109
+ if self._stopped is None:
110
+ self._stopped = reason
111
+ self._scope.cancel()
112
+ self._batch_taken.set()
113
+ self._batch_committed.set()
114
+
115
+ def _check(self) -> None:
116
+ if self._stopped is not None:
117
+ raise CommitterStoppedError("the run stopped, so handled records stay uncommitted") from self._stopped
118
+
119
+ async def run(self) -> None:
120
+ with self._scope:
121
+ while True:
122
+ with anyio.move_on_after(_IDLE_POLL):
123
+ await self._has_work.wait()
124
+ if not self._has_work.is_set():
125
+ await anyio.to_thread.run_sync(self._producer.poll, 0, limiter=self._thread)
126
+ continue
127
+ delay = self._next_start - anyio.current_time()
128
+ if delay > 0:
129
+ with anyio.move_on_after(delay):
130
+ await self._commit_requested.wait()
131
+ await self._commit()
132
+
133
+ async def _commit(self) -> None:
134
+ offsets = [TopicPartition(topic, partition, offset) for (topic, partition), offset in self._offsets.items()]
135
+ transaction = Transaction(
136
+ # Read-only: commit middleware must not commit outputs without their inputs' offsets.
137
+ offsets=MappingProxyType(self._offsets),
138
+ outputs=self._outputs,
139
+ )
140
+ self._offsets, self._outputs, self._records = {}, [], 0
141
+ self._committing_since, self._pending_since = self._pending_since, None
142
+ self._has_work, self._commit_requested = anyio.Event(), anyio.Event()
143
+ self._batch_taken.set()
144
+ self._batch_taken = anyio.Event()
145
+ self._batches_taken += 1
146
+ self._next_start = anyio.current_time() + self._interval
147
+ await _call_once(
148
+ self._middleware, "commit", partial(self._commit_transaction, transaction.offsets, offsets), transaction
149
+ )
150
+ self._batches_committed += 1
151
+ self._committing_since = None
152
+ self._batch_committed.set()
153
+ self._batch_committed = anyio.Event()
154
+
155
+ async def _commit_transaction(
156
+ self, committing: Mapping[tuple[str, int], int], offsets: list[TopicPartition], transaction: Transaction
157
+ ) -> None:
158
+ if transaction.offsets is not committing:
159
+ raise RuntimeError("commit middleware must not change a transaction's offsets")
160
+ outputs = transaction.outputs
161
+ for attempt in range(1, _COMMIT_ATTEMPTS + 1):
162
+ # Read the group metadata right before the transaction. The batch holds only owned partitions' offsets, because a revocation commits its partitions' work before it completes. The coordinator rejects a stale epoch, including one bumped by an unrelated assignment change (KIP-848) while this transaction was in preparation. _transact aborts such a transaction, so repeating it with the new epoch is safe. The coordinator checks only the epoch, never which partitions the member owns. A repeat is safe only because a fenced member keeps its stale epoch: librdkafka rejoins with a new one only after the poller serves the lost partitions, which stops the run. During a drain, nothing polls, so the member stays fenced and the coordinator rejects every repeat (tests/integration/test_fencing.py).
163
+ metadata: bytes = await self._consumer.call(lambda consumer: consumer.consumer_group_metadata())
164
+ try:
165
+ await anyio.to_thread.run_sync(self._transact, outputs, offsets, metadata, limiter=self._thread)
166
+ break
167
+ except KafkaException as exc:
168
+ if attempt == _COMMIT_ATTEMPTS or exc.args[0].code() not in _STALE_EPOCH:
169
+ raise
170
+ logger.info("Retrying a transaction that the broker rejected during a rebalance: %s", exc)
171
+ await anyio.sleep(0.05 * attempt)
172
+
173
+ def _transact(self, outputs: Sequence[ProducerRecord], offsets: list[TopicPartition], metadata: bytes) -> None:
174
+ producer = self._producer
175
+ # A failed commit notes the first failed delivery of its own attempt, not of an attempt aborted before it.
176
+ self._delivery_error = None
177
+ producer.begin_transaction()
178
+ try:
179
+ produce = producer.produce
180
+ for topic, key, value, headers, partition in outputs:
181
+ try:
182
+ produce(topic, value, key, partition, headers=headers)
183
+ except BufferError:
184
+ # The client queue is full. Deliver the queued outputs first. It returns within message.timeout.ms, which librdkafka keeps at or below transaction.timeout.ms.
185
+ producer.flush()
186
+ produce(topic, value, key, partition, headers=headers)
187
+ # Transactional API timeouts default to the remaining transaction timeout.
188
+ producer.send_offsets_to_transaction(offsets, metadata)
189
+ except BaseException:
190
+ producer.abort_transaction()
191
+ raise
192
+ try:
193
+ producer.commit_transaction()
194
+ except KafkaException as exc:
195
+ # Noted first, so that the note survives an abort that raises. Its error then chains this one.
196
+ if self._delivery_error is not None:
197
+ exc.add_note(f"first failed delivery: {self._delivery_error}")
198
+ # Abort only a commit known to have failed. Leave an unknown outcome to the coordinator, and never retry or abort it.
199
+ if exc.args[0].txn_requires_abort():
200
+ producer.abort_transaction()
201
+ raise
202
+
203
+ def _on_failed_delivery(self, error: KafkaError, message: Message) -> None:
204
+ if self._delivery_error is None and error.code() not in _PURGED:
205
+ self._delivery_error = error
206
+
207
+ async def close(self) -> None:
208
+ await anyio.to_thread.run_sync(self._producer.close, limiter=self._thread)
potik/core/config.py ADDED
@@ -0,0 +1,187 @@
1
+ """The runtime's limits and client options, and the client settings that the runtime owns."""
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from dataclasses import dataclass, field
5
+ from types import MappingProxyType
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from confluent_kafka import KafkaError, Message
10
+
11
+ from potik.core.offsets import OffsetPolicy, resume_or_latest
12
+
13
+ __all__ = [
14
+ "Config",
15
+ ]
16
+
17
+ # librdkafka prefetches up to 100k records per partition, 64MB in total, in responses of up to 1MB per partition, then backs off fetching for a second. Prefetch less and refill sooner, because the runtime buffers records too.
18
+ _CONSUMER_DEFAULTS = {
19
+ "queued.min.messages": 2000,
20
+ "queued.max.messages.kbytes": 16384,
21
+ "max.partition.fetch.bytes": 131072,
22
+ "fetch.max.bytes": 8388608,
23
+ "fetch.queue.backoff.ms": 20,
24
+ }
25
+ # librdkafka backs off reconnecting for up to 10s. After an outage, a client may then stay disconnected long enough to time out the transaction in flight. Reconnect within a second of the broker's return, like the Java clients.
26
+ # librdkafka's producer asks the broker to create a missing topic, which a broker with Kafka's defaults does with one partition. Fail the send instead.
27
+ _CLIENT_DEFAULTS = {"reconnect.backoff.max.ms": 1000, "allow.auto.create.topics": False}
28
+
29
+ # Client options the runtime's correctness depends on.
30
+ _CONSUMER_OWNED = frozenset(
31
+ {
32
+ "bootstrap.servers",
33
+ "group.id",
34
+ "group.protocol",
35
+ "enable.auto.commit",
36
+ "enable.auto.offset.store",
37
+ "enable.partition.eof",
38
+ "isolation.level",
39
+ "auto.offset.reset",
40
+ "max.poll.interval.ms",
41
+ }
42
+ )
43
+ # Client options that any producer's guarantees and delivery reports depend on.
44
+ _PRODUCER_OWNED = frozenset(
45
+ {
46
+ "bootstrap.servers",
47
+ "transactional.id",
48
+ "enable.idempotence",
49
+ "acks",
50
+ "request.required.acks",
51
+ "delivery.report.only.error",
52
+ "on_delivery",
53
+ "dr_cb",
54
+ "dr_msg_cb",
55
+ }
56
+ )
57
+ _TRANSACTIONAL_OWNED = _PRODUCER_OWNED | {"transaction.timeout.ms", "metadata.recovery.strategy"}
58
+
59
+
60
+ @dataclass(frozen=True, kw_only=True)
61
+ class Config:
62
+ """Runtime limits and librdkafka client options.
63
+
64
+ The runtime owns the client settings that its guarantees depend on, e.g. `isolation.level` and `enable.auto.commit`, and the options cannot override them. Callbacks in the options, e.g. `error_cb`, run on the runtime's client threads, so they must be thread-safe functions, not coroutines.
65
+
66
+ The producer's `transactional.id` is `{transactional_id_prefix}-{random}`, new each run. With static membership, i.e. a `group.instance.id` in the options, it derives from the group and instance IDs instead. A restarted instance then aborts the transaction that its crashed run left open, so `read_committed` readers do not wait `transaction_timeout_ms` for it. A second live process with the same instance ID fences the first one's producer.
67
+
68
+ Args:
69
+ bootstrap_servers: The cluster's bootstrap servers.
70
+ group_id: The consumer group.
71
+ offset_policy: Picks the start offset of each newly assigned partition (see `OffsetPolicy`). `resume_or_latest`, the default, `resume_or_earliest`, and `resume_or_fail` resume the committed offset, and differ only for a partition without one.
72
+ max_buffered_bytes: The memory of the fetched records that have not gone to a transaction yet, across all partitions, i.e. those that wait for their handler, run, or wait for an earlier record of their partition (see the `key_concurrency` of `Runtime`). A record counts as its key and value plus 350 bytes for its objects and a typical `traceparent` header, so a UUID key and 1KB of JSON count as about 1.4KB. With consumer lag, the buffers stay full, so this is steady memory, not a peak. Each partition gets an equal share, at least 16KB, so the total can exceed it with more than `max_buffered_bytes / 16384` partitions. Handlers that do I/O need only a few buffered records each. Records arrive one record batch of a partition at a time, and a share smaller than the batches of the topics' producers makes partitions pause and fetch records again, which slows handlers that take about a millisecond or less. It does not count librdkafka's prefetch queue, which potik's defaults limit to about 24MB, handler outputs, or handled records that wait for their transaction, at most two `commit_batch_size` batches. A fetch of records larger than the previous fetch's can exceed a partition's share, by at most the prefetch queue, so the budget is a target, not a hard limit.
73
+ stop_failed_partitions: Whether a failure of one partition stops it, instead of failing the run. A stopped partition handles nothing past the failed record, which stays uncommitted, while other partitions keep running. The record runs again once the partition is assigned again, e.g. after a restart. A failure is a handler exception that no exception rule handles, or a fetch error, e.g. a committed offset that retention deleted.
74
+ commit_batch_size: The maximum number of handled records per transaction.
75
+ commit_interval_ms: The minimum time between the starts of two transactions, unless the batch fills first. A transaction also waits for the previous one to finish, so batches grow with throughput.
76
+ transaction_timeout_ms: The producer's `transaction.timeout.ms`, after which the broker aborts an open transaction. At least 1000.
77
+ rebalance_timeout_ms: The longest that the runtime may take to handle an assignment or a revocation, including the revoked partitions' grace period and final commit. Exceeding it fails the run. Must be less than `max_poll_interval_ms`.
78
+ revocation_grace_ms: How long in-flight handlers of revoked partitions may run before the runtime cancels them. Their records run again on the partitions' next owner. The default, 0, cancels them at once. Must be less than `rebalance_timeout_ms`.
79
+ shutdown_grace_ms: How long in-flight handlers may run on shutdown before the runtime cancels them. The default, 0, cancels them at once. Plus `rebalance_timeout_ms`, it limits the whole shutdown, including the final commit, and must be less than `max_poll_interval_ms`.
80
+ max_poll_interval_ms: The consumer's `max.poll.interval.ms`. The runtime keeps polling while handlers run, so handlers may take longer than this.
81
+ transactional_id_prefix: The start of the producer's `transactional.id`. Give each app its own, so that its ACL on the prefixed `TransactionalId` resource covers no other app's IDs.
82
+ options: librdkafka options of both clients, e.g. for the connection, TLS, SASL, OAuth, `client.id`, or logging.
83
+ consumer_options: librdkafka options of the consumer, e.g. `group.remote.assignor`, `group.instance.id`, `client.rack`, or fetch sizes.
84
+ producer_options: librdkafka options of the transactional producer, e.g. `compression.type` or `linger.ms`.
85
+
86
+ Raises:
87
+ ValueError: If a limit is out of range, or options override a setting that the runtime owns.
88
+ """
89
+
90
+ bootstrap_servers: str
91
+ group_id: str
92
+ offset_policy: OffsetPolicy = resume_or_latest
93
+ max_buffered_bytes: int = 16 * 1024 * 1024
94
+ stop_failed_partitions: bool = True
95
+ commit_batch_size: int = 1000
96
+ commit_interval_ms: int = 100
97
+ transaction_timeout_ms: int = 10_000
98
+ rebalance_timeout_ms: int = 60_000
99
+ revocation_grace_ms: int = 0
100
+ shutdown_grace_ms: int = 0
101
+ max_poll_interval_ms: int = 300_000
102
+ transactional_id_prefix: str = "potik"
103
+ options: Mapping[str, Any] = field(default_factory=dict)
104
+ consumer_options: Mapping[str, Any] = field(default_factory=dict)
105
+ producer_options: Mapping[str, Any] = field(default_factory=dict)
106
+
107
+ def __post_init__(self) -> None:
108
+ if not self.bootstrap_servers or not self.group_id or not self.transactional_id_prefix:
109
+ raise ValueError("bootstrap_servers, group_id, and transactional_id_prefix must not be empty")
110
+ if not callable(self.offset_policy):
111
+ raise TypeError("offset_policy must be callable")
112
+ for name in (
113
+ "max_buffered_bytes",
114
+ "commit_batch_size",
115
+ "transaction_timeout_ms",
116
+ "rebalance_timeout_ms",
117
+ "max_poll_interval_ms",
118
+ ):
119
+ value = getattr(self, name)
120
+ if type(value) is not int or value < 1:
121
+ raise ValueError(f"{name} must be a positive integer, got {value!r}")
122
+ for name in ("commit_interval_ms", "revocation_grace_ms", "shutdown_grace_ms"):
123
+ value = getattr(self, name)
124
+ if type(value) is not int or value < 0:
125
+ raise ValueError(f"{name} must be a non-negative integer, got {value!r}")
126
+ if self.transaction_timeout_ms < 1000:
127
+ raise ValueError("transaction_timeout_ms must be at least 1000 (librdkafka minimum)")
128
+ if self.rebalance_timeout_ms >= self.max_poll_interval_ms:
129
+ raise ValueError("rebalance_timeout_ms must be less than max_poll_interval_ms")
130
+ if self.revocation_grace_ms >= self.rebalance_timeout_ms:
131
+ raise ValueError("revocation_grace_ms must be less than rebalance_timeout_ms")
132
+ # Polling stops during a shutdown. Handlers get shutdown_grace_ms to finish, and cancelled ones get up to rebalance_timeout_ms more. If that exceeds max_poll_interval_ms, the group fences the final commit.
133
+ if self.shutdown_grace_ms + self.rebalance_timeout_ms >= self.max_poll_interval_ms:
134
+ raise ValueError("shutdown_grace_ms + rebalance_timeout_ms must be less than max_poll_interval_ms")
135
+ for name, owned in (
136
+ ("options", _CONSUMER_OWNED | _TRANSACTIONAL_OWNED),
137
+ ("consumer_options", _CONSUMER_OWNED),
138
+ ("producer_options", _TRANSACTIONAL_OWNED),
139
+ ):
140
+ options = getattr(self, name)
141
+ if conflicts := owned.intersection(options):
142
+ raise ValueError(f"{name} cannot include settings that the runtime manages: {sorted(conflicts)}")
143
+ object.__setattr__(self, name, MappingProxyType(dict(options)))
144
+
145
+ def client_config(self) -> dict[str, Any]:
146
+ """Return librdkafka options to connect a client of your own to the cluster, e.g. an `AdminClient`."""
147
+ return _client_config(self.bootstrap_servers, self.options)
148
+
149
+ def _consumer_config(self) -> dict[str, Any]:
150
+ return {
151
+ **_CONSUMER_DEFAULTS,
152
+ **self.client_config(),
153
+ **self.consumer_options,
154
+ "group.id": self.group_id,
155
+ "group.protocol": "consumer", # KIP-848 broker-driven incremental rebalances
156
+ "enable.auto.commit": False,
157
+ "enable.auto.offset.store": False,
158
+ "enable.partition.eof": False,
159
+ "isolation.level": "read_committed",
160
+ # Out-of-range positions fail instead of silently skipping records.
161
+ "auto.offset.reset": "error",
162
+ "max.poll.interval.ms": self.max_poll_interval_ms,
163
+ }
164
+
165
+ def _producer_config(self, on_failed_delivery: Callable[[KafkaError, Message], None]) -> dict[str, Any]:
166
+ instance = {**self.options, **self.consumer_options}.get("group.instance.id")
167
+ # The group's length makes the ID unambiguous. Without it, group "a-b" with instance "c" and group "a" with instance "b-c" would fence each other.
168
+ suffix = f"{len(self.group_id)}-{self.group_id}-{instance}" if instance else uuid4()
169
+ return {
170
+ **_idempotent({**self.client_config(), **self.producer_options}),
171
+ "transactional.id": f"{self.transactional_id_prefix}-{suffix}",
172
+ "transaction.timeout.ms": self.transaction_timeout_ms,
173
+ # Re-bootstrapping while all brokers are down drops the connection that a pending transactional call waits on. That call then blocks forever, ignoring its timeout (librdkafka 2.16.0).
174
+ "metadata.recovery.strategy": "none",
175
+ "delivery.report.only.error": True,
176
+ "on_delivery": on_failed_delivery,
177
+ }
178
+
179
+
180
+ def _client_config(bootstrap_servers: str, options: Mapping[str, Any]) -> dict[str, Any]:
181
+ """Return the options of a client to `bootstrap_servers`: potik's defaults, overridden by `options`."""
182
+ return {**_CLIENT_DEFAULTS, **options, "bootstrap.servers": bootstrap_servers}
183
+
184
+
185
+ def _idempotent(options: Mapping[str, Any]) -> dict[str, Any]:
186
+ """Return a producer's `options`, made idempotent and waiting for all in-sync replicas."""
187
+ return {**options, "enable.idempotence": True, "acks": "all"}