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 +54 -0
- potik/build.py +124 -0
- potik/core/__init__.py +59 -0
- potik/core/committer.py +208 -0
- potik/core/config.py +187 -0
- potik/core/consumer.py +79 -0
- potik/core/errors.py +44 -0
- potik/core/health.py +61 -0
- potik/core/middleware.py +208 -0
- potik/core/offsets.py +95 -0
- potik/core/partitions.py +582 -0
- potik/core/producer.py +278 -0
- potik/core/replies.py +274 -0
- potik/core/runtime.py +509 -0
- potik/core/topics.py +22 -0
- potik/core/types.py +131 -0
- potik/handler.py +278 -0
- potik/middleware/__init__.py +49 -0
- potik/middleware/concurrency.py +48 -0
- potik/middleware/exception.py +372 -0
- potik/middleware/metrics.py +211 -0
- potik/middleware/semconv.py +41 -0
- potik/middleware/topics.py +137 -0
- potik/middleware/tracing.py +292 -0
- potik/py.typed +0 -0
- potik/router.py +145 -0
- potik/serde.py +226 -0
- potik/testing.py +207 -0
- potik/topic.py +238 -0
- potik-0.1.0.dist-info/METADATA +110 -0
- potik-0.1.0.dist-info/RECORD +33 -0
- potik-0.1.0.dist-info/WHEEL +4 -0
- potik-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|
potik/core/committer.py
ADDED
|
@@ -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"}
|