potik 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- potik-0.1.0/.gitignore +25 -0
- potik-0.1.0/CHANGELOG.md +18 -0
- potik-0.1.0/LICENSE +21 -0
- potik-0.1.0/PKG-INFO +110 -0
- potik-0.1.0/README.md +73 -0
- potik-0.1.0/examples/rpc/README.md +44 -0
- potik-0.1.0/examples/rpc/api.py +65 -0
- potik-0.1.0/examples/rpc/common.py +48 -0
- potik-0.1.0/examples/rpc/updater.py +36 -0
- potik-0.1.0/examples/tracing/README.md +61 -0
- potik-0.1.0/examples/tracing/checkout.py +49 -0
- potik-0.1.0/examples/tracing/common.py +99 -0
- potik-0.1.0/examples/tracing/compose.yml +38 -0
- potik-0.1.0/examples/tracing/fulfillment.py +105 -0
- potik-0.1.0/examples/tracing/notifier.py +58 -0
- potik-0.1.0/pyproject.toml +136 -0
- potik-0.1.0/src/potik/__init__.py +54 -0
- potik-0.1.0/src/potik/build.py +124 -0
- potik-0.1.0/src/potik/core/__init__.py +59 -0
- potik-0.1.0/src/potik/core/committer.py +208 -0
- potik-0.1.0/src/potik/core/config.py +187 -0
- potik-0.1.0/src/potik/core/consumer.py +79 -0
- potik-0.1.0/src/potik/core/errors.py +44 -0
- potik-0.1.0/src/potik/core/health.py +61 -0
- potik-0.1.0/src/potik/core/middleware.py +208 -0
- potik-0.1.0/src/potik/core/offsets.py +95 -0
- potik-0.1.0/src/potik/core/partitions.py +582 -0
- potik-0.1.0/src/potik/core/producer.py +278 -0
- potik-0.1.0/src/potik/core/replies.py +274 -0
- potik-0.1.0/src/potik/core/ruff.toml +22 -0
- potik-0.1.0/src/potik/core/runtime.py +509 -0
- potik-0.1.0/src/potik/core/topics.py +22 -0
- potik-0.1.0/src/potik/core/types.py +131 -0
- potik-0.1.0/src/potik/handler.py +278 -0
- potik-0.1.0/src/potik/middleware/__init__.py +49 -0
- potik-0.1.0/src/potik/middleware/concurrency.py +48 -0
- potik-0.1.0/src/potik/middleware/exception.py +372 -0
- potik-0.1.0/src/potik/middleware/metrics.py +211 -0
- potik-0.1.0/src/potik/middleware/ruff.toml +20 -0
- potik-0.1.0/src/potik/middleware/semconv.py +41 -0
- potik-0.1.0/src/potik/middleware/topics.py +137 -0
- potik-0.1.0/src/potik/middleware/tracing.py +292 -0
- potik-0.1.0/src/potik/py.typed +0 -0
- potik-0.1.0/src/potik/router.py +145 -0
- potik-0.1.0/src/potik/serde.py +226 -0
- potik-0.1.0/src/potik/testing.py +207 -0
- potik-0.1.0/src/potik/topic.py +238 -0
- potik-0.1.0/tests/integration/app.py +75 -0
- potik-0.1.0/tests/integration/conftest.py +134 -0
- potik-0.1.0/tests/integration/harness.py +255 -0
- potik-0.1.0/tests/integration/test_chaos.py +53 -0
- potik-0.1.0/tests/integration/test_examples.py +136 -0
- potik-0.1.0/tests/integration/test_fencing.py +128 -0
- potik-0.1.0/tests/integration/test_middleware.py +156 -0
- potik-0.1.0/tests/integration/test_outage.py +32 -0
- potik-0.1.0/tests/integration/test_producer.py +55 -0
- potik-0.1.0/tests/integration/test_replies.py +109 -0
- potik-0.1.0/tests/integration/test_runtime.py +191 -0
- potik-0.1.0/tests/integration/test_static_membership.py +69 -0
- potik-0.1.0/tests/integration/test_topics.py +109 -0
- potik-0.1.0/tests/integration/test_tracing.py +206 -0
- potik-0.1.0/tests/integration/test_typed.py +62 -0
- potik-0.1.0/tests/typing/check_public_types.py +126 -0
- potik-0.1.0/tests/unit/conftest.py +8 -0
- potik-0.1.0/tests/unit/test_cancellation.py +123 -0
- potik-0.1.0/tests/unit/test_concurrency.py +56 -0
- potik-0.1.0/tests/unit/test_config.py +17 -0
- potik-0.1.0/tests/unit/test_exceptions.py +73 -0
- potik-0.1.0/tests/unit/test_handlers.py +567 -0
- potik-0.1.0/tests/unit/test_key_concurrency.py +399 -0
- potik-0.1.0/tests/unit/test_mock_cluster.py +126 -0
- potik-0.1.0/tests/unit/test_offsets.py +36 -0
- potik-0.1.0/tests/unit/test_partitions.py +205 -0
- potik-0.1.0/tests/unit/test_producer_contract.py +168 -0
- potik-0.1.0/tests/unit/test_reply_listener.py +159 -0
- potik-0.1.0/tests/unit/test_telemetry.py +244 -0
- potik-0.1.0/tests/unit/test_topic_check.py +37 -0
- potik-0.1.0/tools/kafka_broker.py +137 -0
potik-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
**/.venv
|
|
11
|
+
|
|
12
|
+
# Environment variables
|
|
13
|
+
**/.env
|
|
14
|
+
**/.env.*
|
|
15
|
+
!**/.env.example
|
|
16
|
+
|
|
17
|
+
# local
|
|
18
|
+
.local/
|
|
19
|
+
|
|
20
|
+
# benchmark outputs
|
|
21
|
+
benchmarks/results/
|
|
22
|
+
benchmarks/comparison/results/
|
|
23
|
+
|
|
24
|
+
# docs build output
|
|
25
|
+
site/
|
potik-0.1.0/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
potik follows [Semantic Versioning](https://semver.org). Before 1.0, a minor version can break the API.
|
|
4
|
+
|
|
5
|
+
## 0.1.0 (2026-10-29)
|
|
6
|
+
|
|
7
|
+
The first release.
|
|
8
|
+
|
|
9
|
+
- Async handlers whose outputs commit with the input's offset in one Kafka transaction, so `read_committed` consumers see each output exactly once.
|
|
10
|
+
- Concurrent partitions, with records in order within each partition, and handlers that may run for as long as they need.
|
|
11
|
+
- Opt-in key concurrency, which runs a topic's records with different keys concurrently and keeps each key's records in order, with exactly-once outputs.
|
|
12
|
+
- Typed topics with pydantic, msgspec, and text serdes, and async serdes for formats that need I/O to decode.
|
|
13
|
+
- Exception rules that retry handlers, skip records, or write dead letters. A failure without a rule stops only its partition.
|
|
14
|
+
- Concurrency limits, a startup check for missing topics, and OpenTelemetry tracing and metrics as middleware.
|
|
15
|
+
- Shutdown on SIGTERM, and liveness and readiness checks for Kubernetes probes.
|
|
16
|
+
- A `Producer` that sends outside transactions, and a `MockCluster` for tests.
|
|
17
|
+
- A `ReplyListener` that awaits replies to requests in the API replica that sent them.
|
|
18
|
+
- `potik.core`, the runtime without the typed layer.
|
potik-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Danylo Kielsa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
potik-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: potik
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An async Python framework for exactly-once Kafka services.
|
|
5
|
+
Project-URL: Homepage, https://jarv1s10.github.io/potik/
|
|
6
|
+
Project-URL: Documentation, https://jarv1s10.github.io/potik/
|
|
7
|
+
Project-URL: Repository, https://github.com/Jarv1s10/potik
|
|
8
|
+
Project-URL: Issues, https://github.com/Jarv1s10/potik/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Jarv1s10/potik/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Danylo Kielsa <danylo.kielsa@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: anyio,asyncio,confluent-kafka,exactly-once,kafka,stream-processing,transactions
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Framework :: AnyIO
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.12
|
|
26
|
+
Requires-Dist: anyio>=4.14.0
|
|
27
|
+
Requires-Dist: confluent-kafka>=2.16.0
|
|
28
|
+
Requires-Dist: typing-extensions>=4.14.0
|
|
29
|
+
Provides-Extra: msgspec
|
|
30
|
+
Requires-Dist: msgspec>=0.19.0; extra == 'msgspec'
|
|
31
|
+
Provides-Extra: otel
|
|
32
|
+
Requires-Dist: opentelemetry-api>=1.30.0; extra == 'otel'
|
|
33
|
+
Requires-Dist: opentelemetry-semantic-conventions>=0.51b0; extra == 'otel'
|
|
34
|
+
Provides-Extra: pydantic
|
|
35
|
+
Requires-Dist: pydantic>=2.10.0; extra == 'pydantic'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# potik
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/potik/) [](https://pypi.org/project/potik/) [](https://github.com/Jarv1s10/potik/blob/main/LICENSE)
|
|
41
|
+
|
|
42
|
+
potik is an async Python framework for Kafka services that read records, process them, and write new ones. A handler returns its output records, and potik commits them in one Kafka transaction with the input's offset. Consumers downstream see each output exactly once, even after a crash or a rebalance.
|
|
43
|
+
|
|
44
|
+
> [!WARNING]
|
|
45
|
+
> potik is alpha. Its API can change between minor versions until 1.0.
|
|
46
|
+
|
|
47
|
+
## Example
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from typing import Annotated
|
|
51
|
+
|
|
52
|
+
import anyio
|
|
53
|
+
from pydantic import BaseModel
|
|
54
|
+
|
|
55
|
+
import potik
|
|
56
|
+
from potik import Config, Key, ProducerRecord, Router, Topic
|
|
57
|
+
from potik.serde import PydanticJson
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Order(BaseModel):
|
|
61
|
+
sku: str
|
|
62
|
+
amount: float
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Accepted(BaseModel):
|
|
66
|
+
sku: str
|
|
67
|
+
total: float
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
json = PydanticJson()
|
|
71
|
+
orders = Topic("orders", Order, serde=json)
|
|
72
|
+
accepted = Topic("orders.accepted", Accepted, serde=json)
|
|
73
|
+
router = Router()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.handler(orders)
|
|
77
|
+
async def accept(order: Order, customer: Annotated[str, Key()]) -> ProducerRecord:
|
|
78
|
+
return accepted(Accepted(sku=order.sku, total=order.amount * 1.2), key=customer)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
runtime = potik.build_runtime(Config(bootstrap_servers="localhost:9092", group_id="acceptor"), [router])
|
|
82
|
+
anyio.run(runtime.run)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Features
|
|
86
|
+
|
|
87
|
+
- One transactional producer per app commits outputs and offsets for all partitions together (KIP-447).
|
|
88
|
+
- Partitions run concurrently, and records within a partition run in order, or with key concurrency, in order per key. A handler can run for minutes without dropping the app from its consumer group.
|
|
89
|
+
- A `Topic` declares its value and key types, and type checkers check every record a handler returns.
|
|
90
|
+
- A failing handler stops only its own partition. Middleware adds retries and dead letter topics.
|
|
91
|
+
- The app shuts down on SIGTERM after it commits finished work. Its liveness and readiness checks are ready to serve as Kubernetes probes from any HTTP framework.
|
|
92
|
+
- OpenTelemetry tracing follows each record through its handler, and its outputs to the transaction that commits them.
|
|
93
|
+
|
|
94
|
+
## Install
|
|
95
|
+
|
|
96
|
+
potik needs Python 3.12+ and Kafka 4.0+.
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
uv add "potik[pydantic]"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Documentation
|
|
103
|
+
|
|
104
|
+
- [Quickstart](https://jarv1s10.github.io/potik/quickstart/)
|
|
105
|
+
- [Runtime](https://jarv1s10.github.io/potik/runtime/): delivery, failures, configuration, and production
|
|
106
|
+
- [Middleware](https://jarv1s10.github.io/potik/middleware/): retries, dead letters, concurrency limits, tracing, and metrics
|
|
107
|
+
- [Typed API](https://jarv1s10.github.io/potik/typed/): topics, serdes, handlers, and testing
|
|
108
|
+
- [Producer](https://jarv1s10.github.io/potik/producer/) and [request-reply](https://jarv1s10.github.io/potik/request-reply/)
|
|
109
|
+
|
|
110
|
+
To contribute, see [CONTRIBUTING.md](https://github.com/Jarv1s10/potik/blob/main/CONTRIBUTING.md).
|
potik-0.1.0/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# potik
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/potik/) [](https://pypi.org/project/potik/) [](https://github.com/Jarv1s10/potik/blob/main/LICENSE)
|
|
4
|
+
|
|
5
|
+
potik is an async Python framework for Kafka services that read records, process them, and write new ones. A handler returns its output records, and potik commits them in one Kafka transaction with the input's offset. Consumers downstream see each output exactly once, even after a crash or a rebalance.
|
|
6
|
+
|
|
7
|
+
> [!WARNING]
|
|
8
|
+
> potik is alpha. Its API can change between minor versions until 1.0.
|
|
9
|
+
|
|
10
|
+
## Example
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import anyio
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
import potik
|
|
19
|
+
from potik import Config, Key, ProducerRecord, Router, Topic
|
|
20
|
+
from potik.serde import PydanticJson
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Order(BaseModel):
|
|
24
|
+
sku: str
|
|
25
|
+
amount: float
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Accepted(BaseModel):
|
|
29
|
+
sku: str
|
|
30
|
+
total: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
json = PydanticJson()
|
|
34
|
+
orders = Topic("orders", Order, serde=json)
|
|
35
|
+
accepted = Topic("orders.accepted", Accepted, serde=json)
|
|
36
|
+
router = Router()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.handler(orders)
|
|
40
|
+
async def accept(order: Order, customer: Annotated[str, Key()]) -> ProducerRecord:
|
|
41
|
+
return accepted(Accepted(sku=order.sku, total=order.amount * 1.2), key=customer)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
runtime = potik.build_runtime(Config(bootstrap_servers="localhost:9092", group_id="acceptor"), [router])
|
|
45
|
+
anyio.run(runtime.run)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- One transactional producer per app commits outputs and offsets for all partitions together (KIP-447).
|
|
51
|
+
- Partitions run concurrently, and records within a partition run in order, or with key concurrency, in order per key. A handler can run for minutes without dropping the app from its consumer group.
|
|
52
|
+
- A `Topic` declares its value and key types, and type checkers check every record a handler returns.
|
|
53
|
+
- A failing handler stops only its own partition. Middleware adds retries and dead letter topics.
|
|
54
|
+
- The app shuts down on SIGTERM after it commits finished work. Its liveness and readiness checks are ready to serve as Kubernetes probes from any HTTP framework.
|
|
55
|
+
- OpenTelemetry tracing follows each record through its handler, and its outputs to the transaction that commits them.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
potik needs Python 3.12+ and Kafka 4.0+.
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
uv add "potik[pydantic]"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Documentation
|
|
66
|
+
|
|
67
|
+
- [Quickstart](https://jarv1s10.github.io/potik/quickstart/)
|
|
68
|
+
- [Runtime](https://jarv1s10.github.io/potik/runtime/): delivery, failures, configuration, and production
|
|
69
|
+
- [Middleware](https://jarv1s10.github.io/potik/middleware/): retries, dead letters, concurrency limits, tracing, and metrics
|
|
70
|
+
- [Typed API](https://jarv1s10.github.io/potik/typed/): topics, serdes, handlers, and testing
|
|
71
|
+
- [Producer](https://jarv1s10.github.io/potik/producer/) and [request-reply](https://jarv1s10.github.io/potik/request-reply/)
|
|
72
|
+
|
|
73
|
+
To contribute, see [CONTRIBUTING.md](https://github.com/Jarv1s10/potik/blob/main/CONTRIBUTING.md).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Request-reply across replicas
|
|
2
|
+
|
|
3
|
+
An API sends each CRM update to Kafka and answers its HTTP request with the updater's reply:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
api (Starlette, N replicas) POST /records/{id} -> crm.updates, then awaits crm.updates.status
|
|
7
|
+
updater (potik) crm.updates -> crm.updates.status, copying the correlation-id header
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Each API replica opens a `ReplyListener` in its lifespan. It reads every partition of `crm.updates.status`, without a consumer group, and hands each reply to the request in this replica that awaits its correlation ID. The API enters `expect()` before it sends a request, so that a fast reply cannot arrive first. A shared consumer group would hand each partition to one replica, so most replies would reach a replica that does not await them.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
uv sync --extra pydantic
|
|
14
|
+
podman compose -f examples/tracing/compose.yml up -d --wait kafka # KAFKA_PORT=19092 if port 9092 is taken
|
|
15
|
+
|
|
16
|
+
# KAFKA_BOOTSTRAP=localhost:19092 for a moved broker
|
|
17
|
+
uv run python examples/rpc/updater.py &
|
|
18
|
+
for port in 8000 8001 8002; do uv run uvicorn api:app --app-dir examples/rpc --port $port & done
|
|
19
|
+
|
|
20
|
+
for port in 8000 8001 8002 8000 8001 8002; do
|
|
21
|
+
curl -s -X POST localhost:$port/records/opp-$RANDOM -H 'content-type: application/json' -d '{"Stage": "Won"}'; echo
|
|
22
|
+
done
|
|
23
|
+
kill %1 %2 %3 %4
|
|
24
|
+
podman compose -f examples/tracing/compose.yml down
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Each request gets its own status: `200` with `{"ok": true}`, or `422` with `{"ok": false}` for the one update in five that the updater rejects.
|
|
28
|
+
|
|
29
|
+
## Other ways to correlate
|
|
30
|
+
|
|
31
|
+
potik leaves the correlation ID's place to the app. Here the API sends it in a `correlation-id` header, and the updater copies it to its reply. It can also travel in the records' values, or in an HTTP call that makes a service reply to Kafka. `correlate` then reads it from wherever the reply carries it:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
def job_id(record: ConsumerRecord) -> str | None:
|
|
35
|
+
return json.loads(record.value() or b"{}").get("job_id")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
listener = ReplyListener("jobs.status", bootstrap_servers=BOOTSTRAP, correlate=job_id)
|
|
39
|
+
|
|
40
|
+
with listener.expect() as pending:
|
|
41
|
+
await scheduler.schedule(job_id=pending.correlation_id, fields=fields)
|
|
42
|
+
with anyio.fail_after(30):
|
|
43
|
+
status = await pending.wait()
|
|
44
|
+
```
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The requester: a Starlette app that sends each update to Kafka and answers with the updater's reply.
|
|
2
|
+
|
|
3
|
+
Run as many replicas as you like. Each one reads every reply, and keeps only the replies to its own requests.
|
|
4
|
+
|
|
5
|
+
Usage: uv run uvicorn api:app --app-dir examples/rpc --port 8000
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncGenerator
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
|
|
11
|
+
import anyio
|
|
12
|
+
from common import BOOTSTRAP, CORRELATION_ID, Update, setup, statuses, updates
|
|
13
|
+
from starlette.applications import Starlette
|
|
14
|
+
from starlette.requests import Request
|
|
15
|
+
from starlette.responses import JSONResponse, PlainTextResponse
|
|
16
|
+
from starlette.routing import Route
|
|
17
|
+
|
|
18
|
+
from potik import ConsumerRecord, Producer, ReplyListener
|
|
19
|
+
|
|
20
|
+
TIMEOUT = 10.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def correlation_id(reply: ConsumerRecord) -> str | None:
|
|
24
|
+
"""Return the reply's last `correlation-id` header, which the updater copied from the request."""
|
|
25
|
+
found = dict(reply.headers() or ()).get(CORRELATION_ID)
|
|
26
|
+
return found.decode() if isinstance(found, bytes) else found
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@asynccontextmanager
|
|
30
|
+
async def lifespan(api: Starlette) -> AsyncGenerator[dict[str, Producer | ReplyListener]]:
|
|
31
|
+
setup("api")
|
|
32
|
+
# The listener opens before the app takes requests, so it reads the reply to each of them.
|
|
33
|
+
async with (
|
|
34
|
+
Producer(BOOTSTRAP) as producer,
|
|
35
|
+
ReplyListener(statuses.name, bootstrap_servers=BOOTSTRAP, correlate=correlation_id) as listener,
|
|
36
|
+
):
|
|
37
|
+
yield {"producer": producer, "listener": listener}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def update(request: Request) -> JSONResponse:
|
|
41
|
+
record_id = request.path_params["record_id"]
|
|
42
|
+
update = Update(record_id=record_id, fields=await request.json())
|
|
43
|
+
listener: ReplyListener = request.state.listener
|
|
44
|
+
# Await the reply before sending, so that a fast reply cannot arrive first.
|
|
45
|
+
with listener.expect() as pending:
|
|
46
|
+
record = updates(update, key=record_id, headers={CORRELATION_ID: pending.correlation_id})
|
|
47
|
+
try:
|
|
48
|
+
with anyio.fail_after(TIMEOUT):
|
|
49
|
+
await request.state.producer.send(record)
|
|
50
|
+
reply = await pending.wait()
|
|
51
|
+
except TimeoutError:
|
|
52
|
+
return JSONResponse({"error": "no reply yet, and the update may still apply"}, 504)
|
|
53
|
+
status = await statuses.decode(reply.value() or b"")
|
|
54
|
+
return JSONResponse(status.model_dump(), 200 if status.ok else 422)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def livez(request: Request) -> PlainTextResponse:
|
|
58
|
+
"""Fail once the producer or the listener stops, so that Kubernetes restarts the replica."""
|
|
59
|
+
failure = request.state.producer.failure or request.state.listener.failure
|
|
60
|
+
return PlainTextResponse("ok\n") if failure is None else PlainTextResponse(f"{failure}\n", 503)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
app = Starlette(
|
|
64
|
+
routes=[Route("/records/{record_id}", update, methods=["POST"]), Route("/livez", livez)], lifespan=lifespan
|
|
65
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The contracts that the API and the updater share: the request and reply topics, and their values."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from confluent_kafka import KafkaError, KafkaException
|
|
7
|
+
from confluent_kafka.admin import AdminClient, NewTopic
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from potik import Topic
|
|
11
|
+
from potik.serde import PydanticJson
|
|
12
|
+
|
|
13
|
+
BOOTSTRAP = os.environ.get("KAFKA_BOOTSTRAP", "localhost:9092")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Update(BaseModel):
|
|
17
|
+
record_id: str
|
|
18
|
+
fields: dict[str, str | int | float | bool | None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Status(BaseModel):
|
|
22
|
+
record_id: str
|
|
23
|
+
ok: bool
|
|
24
|
+
error: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# The header that carries a request's ID, which the updater copies to its reply. potik leaves the convention to the app.
|
|
28
|
+
CORRELATION_ID = "correlation-id"
|
|
29
|
+
# Both topics are keyed by the record's ID, so each record's updates run in order.
|
|
30
|
+
JSON = PydanticJson()
|
|
31
|
+
updates = Topic("crm.updates", Update, serde=JSON)
|
|
32
|
+
statuses = Topic("crm.updates.status", Status, serde=JSON)
|
|
33
|
+
# Partitions per topic.
|
|
34
|
+
TOPICS = {updates.name: 3, statuses.name: 3}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def setup(service: str) -> None:
|
|
38
|
+
"""Configure logging and create missing topics."""
|
|
39
|
+
logging.basicConfig(level=logging.INFO, format=f"%(asctime)s {service}[{os.getpid()}] %(levelname)s %(message)s")
|
|
40
|
+
admin = AdminClient({"bootstrap.servers": BOOTSTRAP})
|
|
41
|
+
existing = admin.list_topics(timeout=10).topics
|
|
42
|
+
missing = [NewTopic(name, partitions) for name, partitions in TOPICS.items() if name not in existing]
|
|
43
|
+
for future in admin.create_topics(missing).values() if missing else ():
|
|
44
|
+
try:
|
|
45
|
+
future.result()
|
|
46
|
+
except KafkaException as exc:
|
|
47
|
+
if exc.args[0].code() != KafkaError.TOPIC_ALREADY_EXISTS: # Another service created it meanwhile.
|
|
48
|
+
raise
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The responder: a plain potik service that applies each update and replies with its status.
|
|
2
|
+
|
|
3
|
+
Each reply copies its request's `correlation-id` header, and commits in one transaction with the request's offset. One update in five fails, to show an error reply.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import random
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
from common import BOOTSTRAP, CORRELATION_ID, Status, Update, setup, statuses, updates
|
|
11
|
+
|
|
12
|
+
import potik
|
|
13
|
+
from potik import Config, Header, Key, ProducerRecord, Router, resume_or_earliest
|
|
14
|
+
|
|
15
|
+
router = Router()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.handler(updates, output_topics=[statuses])
|
|
19
|
+
async def apply(
|
|
20
|
+
update: Update, record_id: Annotated[str, Key()], correlation_id: Annotated[str | None, Header(CORRELATION_ID)]
|
|
21
|
+
) -> ProducerRecord[Status]:
|
|
22
|
+
await anyio.sleep(random.uniform(0.05, 0.5)) # the CRM's API call
|
|
23
|
+
error = "the CRM rejected the update" if random.random() < 0.2 else None
|
|
24
|
+
status = Status(record_id=update.record_id, ok=error is None, error=error)
|
|
25
|
+
# A request without a correlation ID, e.g. a fire-and-forget one, still gets a status that nobody awaits.
|
|
26
|
+
return statuses(status, key=record_id, headers={CORRELATION_ID: correlation_id})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def main() -> None:
|
|
30
|
+
# A request sent before the updater first joins its group still gets its reply.
|
|
31
|
+
config = Config(bootstrap_servers=BOOTSTRAP, group_id="updater", offset_policy=resume_or_earliest)
|
|
32
|
+
await potik.build_runtime(config, [router]).run()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
setup("updater")
|
|
36
|
+
anyio.run(main)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Tracing across apps
|
|
2
|
+
|
|
3
|
+
Three services handle each order, and Jaeger shows its trace end to end:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
checkout (potik Producer) -> orders
|
|
7
|
+
fulfillment (potik) orders -> orders.confirmed | orders.dlq
|
|
8
|
+
notifier (potik) orders.confirmed -> notifications, orders.dlq -> (helpdesk ticket)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`TracingMiddleware` gives each order a tree like this, across services:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
POST /checkout checkout
|
|
15
|
+
└─ send orders checkout
|
|
16
|
+
└─ process orders fulfillment one per handler call, retries included
|
|
17
|
+
├─ UPDATE stock, POST /charges fulfillment the handler's own spans
|
|
18
|
+
└─ create orders.confirmed fulfillment one per output, propagated in its headers
|
|
19
|
+
└─ process orders.confirmed notifier
|
|
20
|
+
└─ create notifications notifier
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Each transaction gets a trace of its own, rooted at its `send` span. The `send` span links to the `create` span of each output in the transaction, and Jaeger lists these links under the span's "References".
|
|
24
|
+
|
|
25
|
+
- `fulfillment` retries random payment timeouts. The handler's spans from every attempt are children of one `process orders` span. That span has an `exception` event for each failed attempt.
|
|
26
|
+
- `fulfillment` dead-letters every 7th order. Its `process orders` span succeeds and records the `OutOfStockError` as an `exception` event. Its trace continues in `notifier`'s `process orders.dlq`.
|
|
27
|
+
- All three services add `messaging.kafka.message.key` to the tracing middleware's `attributes`, so spans carry each order's customer id.
|
|
28
|
+
- The 10th order crashes `fulfillment`'s run once. The restarted run replays the orders that it handled but had not committed, so their traces show a second `process orders` span next to the first one. Only the second one's outputs commit, so only its `create` span has a link from a transaction and a `process` span in `notifier`.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
uv sync --extra otel --extra pydantic
|
|
32
|
+
podman compose -f examples/tracing/compose.yml up -d --wait # KAFKA_PORT=19092 if port 9092 is taken
|
|
33
|
+
|
|
34
|
+
# KAFKA_BOOTSTRAP=localhost:19092 for a moved broker
|
|
35
|
+
uv run python examples/tracing/fulfillment.py &
|
|
36
|
+
uv run python examples/tracing/notifier.py &
|
|
37
|
+
uv run python examples/tracing/checkout.py 21 0.3 # prints each order's trace id
|
|
38
|
+
|
|
39
|
+
open http://localhost:16686 # service "checkout", or /trace/<trace id>
|
|
40
|
+
# OTEL_TRACES_EXPORTER=none to run the services without exporting spans
|
|
41
|
+
kill %1 %2
|
|
42
|
+
podman compose -f examples/tracing/compose.yml down
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Health probes
|
|
46
|
+
|
|
47
|
+
`fulfillment` and `notifier` serve `/livez` and `/readyz` with uvicorn, on ports 8081 and 8082, or `HEALTH_PORT`. Each runs the server as a task next to its runtime, as [Health checks](../../docs/runtime/production.md#health-checks) shows:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
curl localhost:8081/readyz # ok
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Logs and baggage
|
|
54
|
+
|
|
55
|
+
A handler logs within its `process` span's context. The committer logs within its transaction's span, e.g. when it repeats a transaction for a stale group epoch. An OpenTelemetry logging handler, such as the SDK's `LoggingHandler`, adds the trace and span ids to these log records, so the backend shows them with the trace.
|
|
56
|
+
|
|
57
|
+
Baggage set upstream, such as a tenant id, arrives with the trace context. The handler sees it, and the headers of every output propagate it. To add it to the `process` and `create` spans as attributes, add `BaggageSpanProcessor` from `opentelemetry-processor-baggage` to the tracer provider. A transaction starts its own trace and holds the outputs of many records, so its spans have no baggage.
|
|
58
|
+
|
|
59
|
+
## Metrics
|
|
60
|
+
|
|
61
|
+
`potik.middleware.metrics.MetricsMiddleware` records `messaging.client.consumed.messages` and `messaging.process.duration` per topic and partition. It records `messaging.client.operation.duration` and `messaging.client.sent.messages` per transaction. Jaeger stores no metrics, so this example leaves it out. To use it, put it first in the middleware list next to `TracingMiddleware`, and give it a `MeterProvider` that exports to your backend.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""A checkout API that sends one order per request through a potik Producer, starting each order's trace.
|
|
2
|
+
|
|
3
|
+
Usage: checkout.py [ORDERS=20] [INTERVAL_S=0.5]
|
|
4
|
+
|
|
5
|
+
Every 7th order is for an out-of-stock item, and fulfillment dead-letters it. The 10th is for a new item, which crashes fulfillment's run once.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
import sys
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
import anyio
|
|
13
|
+
from common import BOOTSTRAP, KEYED, Order, orders, setup
|
|
14
|
+
from opentelemetry import trace
|
|
15
|
+
from opentelemetry.trace import SpanKind
|
|
16
|
+
|
|
17
|
+
from potik import Producer
|
|
18
|
+
from potik.middleware.tracing import TracingMiddleware
|
|
19
|
+
|
|
20
|
+
setup("checkout")
|
|
21
|
+
tracer = trace.get_tracer("checkout")
|
|
22
|
+
count = int(sys.argv[1]) if len(sys.argv) > 1 else 20
|
|
23
|
+
interval = float(sys.argv[2]) if len(sys.argv) > 2 else 0.5
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def main() -> None:
|
|
27
|
+
# The same middleware as the potik services, so each send gets a span and carries its context.
|
|
28
|
+
async with Producer(BOOTSTRAP, middleware=[TracingMiddleware(optional_attributes=KEYED)]) as producer:
|
|
29
|
+
for n in range(1, count + 1):
|
|
30
|
+
order = Order(
|
|
31
|
+
id=str(uuid4()),
|
|
32
|
+
customer=f"customer-{random.randint(1, 5)}",
|
|
33
|
+
sku="SKU-SOLD-OUT" if n % 7 == 0 else "SKU-NEW" if n == 10 else f"SKU-{random.randint(100, 999)}",
|
|
34
|
+
amount=round(random.uniform(5, 250), 2),
|
|
35
|
+
)
|
|
36
|
+
with tracer.start_as_current_span(
|
|
37
|
+
"POST /checkout",
|
|
38
|
+
kind=SpanKind.SERVER,
|
|
39
|
+
attributes={"http.request.method": "POST", "http.route": "/checkout"},
|
|
40
|
+
) as request:
|
|
41
|
+
request.set_attribute("order.id", order.id)
|
|
42
|
+
with tracer.start_as_current_span("validate cart"):
|
|
43
|
+
await anyio.sleep(random.uniform(0.005, 0.02))
|
|
44
|
+
await producer.send(orders(order, key=order.customer))
|
|
45
|
+
print(f"order {n}/{count}: {order.id} {order.sku} trace {request.get_span_context().trace_id:032x}")
|
|
46
|
+
await anyio.sleep(interval)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
anyio.run(main)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Shared setup of the tracing example: topics, their values, and the OpenTelemetry SDK."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
from confluent_kafka import KafkaError, KafkaException
|
|
10
|
+
from confluent_kafka.admin import AdminClient, NewTopic
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
13
|
+
from opentelemetry.sdk.resources import Resource
|
|
14
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
15
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
from starlette.applications import Starlette
|
|
18
|
+
from starlette.requests import Request
|
|
19
|
+
from starlette.responses import PlainTextResponse
|
|
20
|
+
from starlette.routing import Route
|
|
21
|
+
|
|
22
|
+
from potik import Runtime, Topic
|
|
23
|
+
from potik.middleware.tracing import TracingMiddleware
|
|
24
|
+
from potik.serde import PydanticJson
|
|
25
|
+
|
|
26
|
+
BOOTSTRAP = os.environ.get("KAFKA_BOOTSTRAP", "localhost:9092")
|
|
27
|
+
# Each service's health probes listen on HEALTH_PORT, or on a port of its own.
|
|
28
|
+
HEALTH_PORT = os.environ.get("HEALTH_PORT")
|
|
29
|
+
# The tracing middleware's attributes, plus each record's key, the order's customer id.
|
|
30
|
+
KEYED = TracingMiddleware.DEFAULT_OPTIONAL_ATTRIBUTES | {"messaging.kafka.message.key"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Order(BaseModel):
|
|
34
|
+
id: str
|
|
35
|
+
customer: str
|
|
36
|
+
sku: str
|
|
37
|
+
amount: float
|
|
38
|
+
status: str = "placed"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Notification(BaseModel):
|
|
42
|
+
channel: str
|
|
43
|
+
order: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# The contracts that the services share, which pin the values' format. Every record is keyed by its customer.
|
|
47
|
+
JSON = PydanticJson()
|
|
48
|
+
orders = Topic("orders", Order, serde=JSON)
|
|
49
|
+
confirmed = Topic("orders.confirmed", Order, serde=JSON)
|
|
50
|
+
dead_letters = Topic("orders.dlq", Order, serde=JSON)
|
|
51
|
+
notifications = Topic("notifications", Notification, serde=JSON)
|
|
52
|
+
# Partitions per topic.
|
|
53
|
+
TOPICS = {orders.name: 3, confirmed.name: 3, dead_letters.name: 1, notifications.name: 3}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def setup(service: str) -> None:
|
|
57
|
+
"""Configure logging, create missing topics, and export spans.
|
|
58
|
+
|
|
59
|
+
Spans go over OTLP (`OTEL_EXPORTER_OTLP_*`), or nowhere if `OTEL_TRACES_EXPORTER=none`.
|
|
60
|
+
"""
|
|
61
|
+
logging.basicConfig(level=logging.INFO, format=f"%(asctime)s {service}[{os.getpid()}] %(levelname)s %(message)s")
|
|
62
|
+
admin = AdminClient({"bootstrap.servers": BOOTSTRAP})
|
|
63
|
+
existing = admin.list_topics(timeout=10).topics
|
|
64
|
+
missing = [NewTopic(name, partitions) for name, partitions in TOPICS.items() if name not in existing]
|
|
65
|
+
for future in admin.create_topics(missing).values() if missing else ():
|
|
66
|
+
try:
|
|
67
|
+
future.result()
|
|
68
|
+
except KafkaException as exc:
|
|
69
|
+
if exc.args[0].code() != KafkaError.TOPIC_ALREADY_EXISTS: # Another service created it meanwhile.
|
|
70
|
+
raise
|
|
71
|
+
resource = Resource.create(
|
|
72
|
+
{"service.name": service, "service.instance.id": f"{socket.gethostname()}-{os.getpid()}"}
|
|
73
|
+
)
|
|
74
|
+
provider = TracerProvider(resource=resource)
|
|
75
|
+
if os.environ.get("OTEL_TRACES_EXPORTER") != "none":
|
|
76
|
+
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
|
|
77
|
+
trace.set_tracer_provider(provider)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def probe_server(current: Callable[[], Runtime], port: int) -> uvicorn.Server:
|
|
81
|
+
"""Return a server of the current runtime's `/livez` and `/readyz`, on `HEALTH_PORT` or `port`.
|
|
82
|
+
|
|
83
|
+
Run it as a task next to the runtime, so that the runtime handles SIGTERM.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
async def livez(request: Request) -> PlainTextResponse:
|
|
87
|
+
return probe(current().liveness())
|
|
88
|
+
|
|
89
|
+
async def readyz(request: Request) -> PlainTextResponse:
|
|
90
|
+
return probe(current().readiness())
|
|
91
|
+
|
|
92
|
+
app = Starlette(routes=[Route("/livez", livez), Route("/readyz", readyz)])
|
|
93
|
+
config = uvicorn.Config(app, host="0.0.0.0", port=int(HEALTH_PORT or port), log_level="warning", lifespan="off")
|
|
94
|
+
return uvicorn.Server(config)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def probe(reason: str | None) -> PlainTextResponse:
|
|
98
|
+
"""Answer a probe with 200, or with 503 and the reason it fails."""
|
|
99
|
+
return PlainTextResponse("ok\n") if reason is None else PlainTextResponse(f"{reason}\n", 503)
|