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.
Files changed (78) hide show
  1. potik-0.1.0/.gitignore +25 -0
  2. potik-0.1.0/CHANGELOG.md +18 -0
  3. potik-0.1.0/LICENSE +21 -0
  4. potik-0.1.0/PKG-INFO +110 -0
  5. potik-0.1.0/README.md +73 -0
  6. potik-0.1.0/examples/rpc/README.md +44 -0
  7. potik-0.1.0/examples/rpc/api.py +65 -0
  8. potik-0.1.0/examples/rpc/common.py +48 -0
  9. potik-0.1.0/examples/rpc/updater.py +36 -0
  10. potik-0.1.0/examples/tracing/README.md +61 -0
  11. potik-0.1.0/examples/tracing/checkout.py +49 -0
  12. potik-0.1.0/examples/tracing/common.py +99 -0
  13. potik-0.1.0/examples/tracing/compose.yml +38 -0
  14. potik-0.1.0/examples/tracing/fulfillment.py +105 -0
  15. potik-0.1.0/examples/tracing/notifier.py +58 -0
  16. potik-0.1.0/pyproject.toml +136 -0
  17. potik-0.1.0/src/potik/__init__.py +54 -0
  18. potik-0.1.0/src/potik/build.py +124 -0
  19. potik-0.1.0/src/potik/core/__init__.py +59 -0
  20. potik-0.1.0/src/potik/core/committer.py +208 -0
  21. potik-0.1.0/src/potik/core/config.py +187 -0
  22. potik-0.1.0/src/potik/core/consumer.py +79 -0
  23. potik-0.1.0/src/potik/core/errors.py +44 -0
  24. potik-0.1.0/src/potik/core/health.py +61 -0
  25. potik-0.1.0/src/potik/core/middleware.py +208 -0
  26. potik-0.1.0/src/potik/core/offsets.py +95 -0
  27. potik-0.1.0/src/potik/core/partitions.py +582 -0
  28. potik-0.1.0/src/potik/core/producer.py +278 -0
  29. potik-0.1.0/src/potik/core/replies.py +274 -0
  30. potik-0.1.0/src/potik/core/ruff.toml +22 -0
  31. potik-0.1.0/src/potik/core/runtime.py +509 -0
  32. potik-0.1.0/src/potik/core/topics.py +22 -0
  33. potik-0.1.0/src/potik/core/types.py +131 -0
  34. potik-0.1.0/src/potik/handler.py +278 -0
  35. potik-0.1.0/src/potik/middleware/__init__.py +49 -0
  36. potik-0.1.0/src/potik/middleware/concurrency.py +48 -0
  37. potik-0.1.0/src/potik/middleware/exception.py +372 -0
  38. potik-0.1.0/src/potik/middleware/metrics.py +211 -0
  39. potik-0.1.0/src/potik/middleware/ruff.toml +20 -0
  40. potik-0.1.0/src/potik/middleware/semconv.py +41 -0
  41. potik-0.1.0/src/potik/middleware/topics.py +137 -0
  42. potik-0.1.0/src/potik/middleware/tracing.py +292 -0
  43. potik-0.1.0/src/potik/py.typed +0 -0
  44. potik-0.1.0/src/potik/router.py +145 -0
  45. potik-0.1.0/src/potik/serde.py +226 -0
  46. potik-0.1.0/src/potik/testing.py +207 -0
  47. potik-0.1.0/src/potik/topic.py +238 -0
  48. potik-0.1.0/tests/integration/app.py +75 -0
  49. potik-0.1.0/tests/integration/conftest.py +134 -0
  50. potik-0.1.0/tests/integration/harness.py +255 -0
  51. potik-0.1.0/tests/integration/test_chaos.py +53 -0
  52. potik-0.1.0/tests/integration/test_examples.py +136 -0
  53. potik-0.1.0/tests/integration/test_fencing.py +128 -0
  54. potik-0.1.0/tests/integration/test_middleware.py +156 -0
  55. potik-0.1.0/tests/integration/test_outage.py +32 -0
  56. potik-0.1.0/tests/integration/test_producer.py +55 -0
  57. potik-0.1.0/tests/integration/test_replies.py +109 -0
  58. potik-0.1.0/tests/integration/test_runtime.py +191 -0
  59. potik-0.1.0/tests/integration/test_static_membership.py +69 -0
  60. potik-0.1.0/tests/integration/test_topics.py +109 -0
  61. potik-0.1.0/tests/integration/test_tracing.py +206 -0
  62. potik-0.1.0/tests/integration/test_typed.py +62 -0
  63. potik-0.1.0/tests/typing/check_public_types.py +126 -0
  64. potik-0.1.0/tests/unit/conftest.py +8 -0
  65. potik-0.1.0/tests/unit/test_cancellation.py +123 -0
  66. potik-0.1.0/tests/unit/test_concurrency.py +56 -0
  67. potik-0.1.0/tests/unit/test_config.py +17 -0
  68. potik-0.1.0/tests/unit/test_exceptions.py +73 -0
  69. potik-0.1.0/tests/unit/test_handlers.py +567 -0
  70. potik-0.1.0/tests/unit/test_key_concurrency.py +399 -0
  71. potik-0.1.0/tests/unit/test_mock_cluster.py +126 -0
  72. potik-0.1.0/tests/unit/test_offsets.py +36 -0
  73. potik-0.1.0/tests/unit/test_partitions.py +205 -0
  74. potik-0.1.0/tests/unit/test_producer_contract.py +168 -0
  75. potik-0.1.0/tests/unit/test_reply_listener.py +159 -0
  76. potik-0.1.0/tests/unit/test_telemetry.py +244 -0
  77. potik-0.1.0/tests/unit/test_topic_check.py +37 -0
  78. 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/
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/potik)](https://pypi.org/project/potik/) [![Python](https://img.shields.io/pypi/pyversions/potik)](https://pypi.org/project/potik/) [![License](https://img.shields.io/pypi/l/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
+ [![PyPI](https://img.shields.io/pypi/v/potik)](https://pypi.org/project/potik/) [![Python](https://img.shields.io/pypi/pyversions/potik)](https://pypi.org/project/potik/) [![License](https://img.shields.io/pypi/l/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)