From 11a61ca4c95e508ba2ae68b682d12955819eb8a8 Mon Sep 17 00:00:00 2001 From: Andrey Tatarinov Date: Thu, 1 Oct 2026 18:44:34 +0000 Subject: [PATCH 1/2] Fix ruff warning --- packages/tkati-core/tkati_core/stats.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tkati-core/tkati_core/stats.py b/packages/tkati-core/tkati_core/stats.py index cb0fb97..7057ee0 100644 --- a/packages/tkati-core/tkati_core/stats.py +++ b/packages/tkati-core/tkati_core/stats.py @@ -13,7 +13,7 @@ import threading import time -from collections.abc import Iterator +from collections.abc import Generator from contextlib import contextmanager from dataclasses import dataclass, field @@ -81,7 +81,7 @@ def record(self, phase: str, seconds: float) -> None: self.phase_sec[phase] = self.phase_sec.get(phase, 0.0) + seconds @contextmanager - def phase(self, name: str) -> Iterator[None]: + def phase(self, name: str) -> Generator[None]: """Time a block and add it to `name`'s running total.""" started = time.perf_counter() try: From 65fcc51d82cf3b38093c12e1b87741dccadfc454 Mon Sep 17 00:00:00 2001 From: rustam810 Date: Thu, 1 Oct 2026 16:03:09 +0200 Subject: [PATCH 2/2] Kafka: pass librdkafka properties through from settings `[input.config]` / `[output.config]` set any librdkafka property on the consumer or producer, merged over the ones derived from the typed fields. Compression is the first user: `"compression.type" = "zstd"`. The properties tkati sets itself are rejected at settings-parse time rather than merged over. librdkafka accepts `enable.auto.commit=true` silently, and the consumer's explicit-commit protocol depends on it being false. No defaults changed. Design: design-docs/2026-10-01-kafka-config-passthrough.md Partly addresses F5 in design-docs/2026-09-27-tkati-core-audit.md. design doc: Kafka client config passthrough --- CHANGELOG.md | 35 ++ design-docs/2026-09-27-tkati-core-audit.md | 16 +- .../2026-10-01-kafka-config-passthrough.md | 382 ++++++++++++++++++ packages/tkati-core/CHANGELOG.md | 14 + packages/tkati-core/Cargo.lock | 2 +- packages/tkati-core/Cargo.toml | 2 +- packages/tkati-core/README.md | 45 +++ packages/tkati-core/pyproject.toml | 2 +- packages/tkati-core/tests/test_consumer.py | 63 ++- packages/tkati-core/tests/test_producer.py | 93 ++++- .../tkati-core/tkati_core/kafka/consumer.py | 9 +- .../tkati-core/tkati_core/kafka/producer.py | 24 +- .../tkati-core/tkati_core/kafka/settings.py | 71 +++- packages/tkati-dashboard/pyproject.toml | 4 +- packages/tkati-node-dedup/CHANGELOG.md | 7 + packages/tkati-node-dedup/README.md | 6 + packages/tkati-node-dedup/pyproject.toml | 4 +- packages/tkati-node-el/CHANGELOG.md | 7 + packages/tkati-node-el/README.md | 6 + packages/tkati-node-el/pyproject.toml | 4 +- pyproject.toml | 2 +- 21 files changed, 771 insertions(+), 27 deletions(-) create mode 100644 design-docs/2026-10-01-kafka-config-passthrough.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eebc9f..b200a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,41 @@ beyond a package's own generated workflows). Because every package's make a package a component of the change — list only packages whose code, tests, config or docs changed. +## 0.8.3 + +### loopqqlz — Kafka client config passthrough [tkati-core, tkati-node-dedup, tkati-node-el, repo] + +- `[input.config]` and `[output.config]` (and `[dlq.config]`) set any librdkafka + property on the consumer or producer: compression, `linger.ms`, `acks`, + `client.id`, `SASL_PLAINTEXT`/`PLAIN` auth. Values may be strings, ints or + bools, and the dict is merged over the properties derived from the typed + fields. No Rust change was needed — `config_entries` already stringified + values and lowered bools +- Property names and values are librdkafka's to validate, at client + construction: `compresion.type` fails node startup with `No such configuration + property`, `compression.type = "brotli"` with `Invalid value "brotli" for + configuration property "compression.codec"`. So there is no property list in + Python to go stale +- The properties tkati sets itself — `PRODUCER_RESERVED`, `CONSUMER_RESERVED`, + both public — are rejected at settings-parse time rather than merged over. + `enable.auto.commit` is the one that matters: librdkafka accepts `true` + silently, and the consumer's explicit-commit protocol depends on it being + false, so a passthrough that could override it would turn a node's + rewind-on-failure path into data loss. The other four are reserved only + because a typed field already sets them +- Compression is set this way rather than through a typed field: librdkafka's + own error names the bad codec at the same startup a pydantic error would, so a + `Literal` would have bought one better message at the cost of a second + mechanism for one property. It pays off far more under `format = "json"`, where + the codec sees many small near-identical messages, than under `"arrow-batch"` +- `KafkaProducer` and `KafkaConsumer` now agree on + `kafka_config: dict[str, str | int | bool]` +- No defaults changed: compression stays off and `enable.idempotence` stays + false unless `config` says otherwise. Audit finding F5's configurability half + is closed, the reordering half is still open and is the next change +- New `design-docs/2026-10-01-kafka-config-passthrough.md`; F5 marked partly + fixed in the audit + ## 0.8.2 ### tzwvropv — ClickHouse insert failures: data errors versus outages [tkati-core, tkati-node-el, repo] diff --git a/design-docs/2026-09-27-tkati-core-audit.md b/design-docs/2026-09-27-tkati-core-audit.md index 48fa1cd..677929d 100644 --- a/design-docs/2026-09-27-tkati-core-audit.md +++ b/design-docs/2026-09-27-tkati-core-audit.md @@ -39,7 +39,7 @@ Severity: | F2 | Critical | A failed write to a Kafka DLQ loses rows without an error | Open | | F3 | High | A ClickHouse outage sends the whole batch to the DLQ | Fixed 0.8.2 | | F4 | Medium | Commit errors are never reported | Open | -| F5 | Medium | Kafka settings can't be configured; producer defaults can reorder retried messages | Open | +| F5 | Medium | Kafka settings can't be configured; producer defaults can reorder retried messages | Partly fixed 0.8.3 | | F6 | Low | No handling of revoked partitions: more duplicates after a rebalance | Open | | F7 | Low | Offset ranges are wrong if a partition is re-assigned mid-poll | Open | | F8 | Low | Large tables in the `arrow-batch` format fail at enqueue | Open | @@ -198,6 +198,20 @@ requests and keep order across retries. Also default to separate change: it is excluded in `Cargo.toml` for build reasons that are documented there. +**Status.** The configurability half is fixed in 0.8.3 — +`design-docs/2026-10-01-kafka-config-passthrough.md`. It differs from the +recommendation above in two ways, both argued there: the dict sits on +`KafkaInputSettings` / `KafkaOutputSettings` rather than +`KafkaConnectionSettings`, because a producer-only property under a shared +`connection` block would be silently ignored on an input; and it is *not* +merged last — the properties tkati sets itself are rejected at parse time, +because librdkafka accepts `enable.auto.commit=true` silently and that would +turn a node's rewind-on-failure path into data loss. + +The defaults are unchanged, so **the reordering half of this finding is still +open**: `enable.idempotence` is still false unless an operator sets it. That is +the next change. + ### F6. No handling of revoked partitions: more duplicates after a rebalance **Severity:** Low diff --git a/design-docs/2026-10-01-kafka-config-passthrough.md b/design-docs/2026-10-01-kafka-config-passthrough.md new file mode 100644 index 0000000..1c221da --- /dev/null +++ b/design-docs/2026-10-01-kafka-config-passthrough.md @@ -0,0 +1,382 @@ +--- +status: IMPLEMENTED +--- + +# Kafka client config: a passthrough with reserved properties + +## Context + +Every Kafka client this workspace builds is configured from a handful of typed +fields and nothing else. + +`KafkaProducer.from_topic_settings` +(`packages/tkati-core/tkati_core/kafka/producer.py`) sets exactly one property: + +```python +kafka_config={"bootstrap.servers": connection.broker} +``` + +`KafkaConsumer.from_input_settings` +(`packages/tkati-core/tkati_core/kafka/consumer.py:65`) sets four: +`bootstrap.servers`, `group.id`, `auto.offset.reset` and +`enable.auto.commit=False`. Everything else librdkafka exposes — several hundred +properties — is unreachable from `settings.toml`. No compression, no +`linger.ms`, no `acks`, no `client.id`, no idempotence, no authentication. + +This is audit finding **F5** (`design-docs/2026-09-27-tkati-core-audit.md`), +severity Medium, and the configurability half of **E5** ("throughput is left on +the table by configuration"). Both are blocked on it: today every knob anyone +wants means editing `from_topic_settings` and shipping a release. + +Compression is the concrete case that prompted this. In the default `"json"` +format `produce_arrow` emits **one message per row**, each a JSON object +repeating every column name (`encode_arrow`, +`packages/tkati-core/src/lib.rs:203`). A 1 000-row batch is 1 000 messages with +identical keys — close to the best case for a block compressor — and all of it +is currently paid for in full at the broker's disk, its page cache, and every +hop between the nodes of a dataflow. `compression.type` is one librdkafka +property, and there is no way to set it. + +Each client wrapper takes a `kafka_config` dict already, and `config_entries` +(`packages/tkati-core/src/lib.rs:48`) accepts any Python value, stringifying it +and lowering bools to librdkafka's `true`/`false`. **The plumbing exists end to +end; what is missing is a way into it from settings.** + +### What librdkafka does and does not catch + +Probed against the built extension, because the design depends on it: + +| Config passed to `NativeProducer` | Result | +| --- | --- | +| `{"compresion.type": "zstd"}` (typo'd key) | `KafkaError: No such configuration property: "compresion.type"` | +| `{"compression.type": "brotli"}` (bad value) | `KafkaError: Invalid value "brotli" for configuration property "compression.codec"` | +| `{"linger.ms": 50}` (int) | accepted | +| `{"enable.idempotence": True}` (bool) | accepted | + +So a passthrough is **not** the unvalidated option it is usually taken for: +librdkafka rejects unknown properties *and* bad values at client construction, +naming the property in both cases. In a node that is `build_producer` during +`_NodeBase.from_settings`, milliseconds after settings parse — the same startup +in which a pydantic error would have fired. + +Two things it does not catch, both load-bearing for this design: + +| Config passed to `NativeConsumer` | Result | +| --- | --- | +| `{"linger.ms": 50}` (producer-only property) | accepted; `%4\|CONFWARN\| ... queue.buffering.max.ms is a producer property and will be ignored by this consumer instance` | +| `{"enable.auto.commit": True}` | accepted, silently | + +The CONFWARN goes to librdkafka's own log, not loguru, so in a running node it is +effectively invisible. And `enable.auto.commit` is the property tkati's entire +commit protocol rests on. + +## Goal + +Done when: + +- Any librdkafka property can be set per client from `settings.toml`, without a + code change in `tkati-core`. Compression included, as the first user of it. +- A property tkati itself sets cannot be silently overridden from settings. + Attempting it is an error that names the property and says why. +- Values may be strings, ints or bools in TOML, and reach librdkafka in the form + it wants. +- The reserved set is legible: a reader can see which properties tkati owns and + which of those are owned for correctness rather than convenience. +- `settings.toml` examples exist for the three things operators will reach for + first: compression, producer throughput tuning, and `SASL_PLAINTEXT`/`PLAIN` + auth. + +Non-goals: + +- **Changing any default.** In particular `enable.idempotence` stays at + librdkafka's `false`. That default is the actual bug inside F5 — with unlimited + retries and in-flight requests, a retried send can land after a later one and + can duplicate, which breaks the per-key ordering `key_column` exists to + provide — and a passthrough does not fix it, because it only helps operators + who already know to set it. It is a behaviour change with its own throughput + consequences and its own verification, and it should be bisectable on its own. + Separate change, landing straight after this one. The same goes for defaulting + compression to `lz4` or `zstd`, which F5 also recommends: it would re-encode + every existing deployment's traffic on upgrade and raise producer CPU without + anyone asking. +- **A typed field for compression**, or for any other property. See + *Why compression is not a typed field*. +- **TLS and the stronger SASL mechanisms.** Not a settings problem: the + extension is built with `rdkafka = { default-features = false, features = + ["libz-static", "zstd"] }` (`packages/tkati-core/Cargo.toml:28`). Probed: + `SASL_PLAINTEXT` + `PLAIN` works today; `SCRAM-SHA-256`, `GSSAPI` and + `OAUTHBEARER` fail with *"No provider for SASL mechanism … recompile + librdkafka with libsasl2"*, and any `_SSL` protocol with *"OpenSSL not + available at build time"*. Re-enabling needs `ssl-vendored` plus + `perl-IPC-Cmd` in the publish workflow, as the comment above that dependency + already documents. This change makes those properties *settable*; it does not + make them *work*. +- Per-topic or per-partition config. librdkafka's topic-level properties are + accepted in the same flat namespace, so they come along for free, but nothing + is designed for them here. +- Validating property names against a vendored list of librdkafka properties. + librdkafka does it, better, and a list in Python would go stale. +- Surfacing the dict in `tkati-dashboard`. It renders a dataflow graph, not a + config audit. +- Secret handling. `sasl.password` in a `settings.toml` is as exposed as the rest + of that file; this change does not introduce a secrets story, and the README + should not imply one. + +## Approach + +One `config` dict per client-bearing settings block, merged over the properties +tkati derives from its typed fields, with those properties held back in a +**reserved set** that the dict may not name. + +The merge is a layering with three steps, and the third is what makes it honest: + +1. Properties tkati derives from typed fields — `bootstrap.servers` from + `connection.broker`, `group.id` from `consumer.group_id`, and so on. +2. The operator's `config` dict, merged over them. +3. A parse-time validator rejecting any key in step 2 that step 1 would also + have set. + +Step 3 means steps 1 and 2 can never actually collide — the merge order is +arithmetic rather than policy. That is deliberate. "Merged last wins" is what +F5's recommendation proposes, and it is wrong here for one specific reason: +`enable.auto.commit=True` is accepted silently by librdkafka (see Context), and +the consumer's explicit-commit protocol — `commit(batch)` after a batch is +durable, rewind on failure — is built on it being `False`. A passthrough that can +override it turns a node's failure path into silent data loss, configurable by +someone who has no reason to suspect it. The same argument, more weakly, covers +`bootstrap.servers`, `group.id` and `auto.offset.reset`: they have typed homes, +and two sources for one value is a bug waiting for a reader. + +Rejecting rather than warning is for the same reason. A warning about +`enable.auto.commit` would land in the same place librdkafka's CONFWARN does — +a log nobody reads until afterwards. + +## Design + +### Shape and placement + +```toml +[output] +type = "kafka" + +[output.config] +"compression.type" = "zstd" +"linger.ms" = 50 +"batch.num.messages" = 50000 +"client.id" = "dedup-prod-1" +``` + +`config: dict[str, str | int | bool] = {}` goes on `KafkaInputSettings` and +`KafkaOutputSettings` — the per-role blocks — **not** on +`KafkaConnectionSettings`, which is where F5 recommends it. + +The reason is the CONFWARN probe. A producer-only property on a consumer is +accepted, ignored, and warned about only on librdkafka's own log. +`KafkaConnectionSettings` is shared in shape by `KafkaInputSettings` and +`KafkaOutputSettings`, so putting the dict there would create exactly one place +in the settings schema where a knob can be set, look correct, and do nothing. +The same trap rules out `KafkaTopicSettings`, which input and output share +outright: `compression.type` under a topic read by a consumer would be dead +config, and compression is a property of a producer's connection to the broker +rather than of the topic — the broker's own default `compression.type=producer` +means it stores whatever each producer sent, so two producers may legitimately +write one topic with different codecs. + +Per-role placement also lets the reserved set differ by role, which it must: +`enable.auto.commit` is meaningless on a producer and load-bearing on a consumer. + +The cost is that an operator using SASL repeats the credentials in `[input]` and +`[output]` (and `[dlq]`). That is already true of `broker`, and TOML has no +anchors, so the alternative is a shared block that would need its own merge +rules. Accepted. + +Because `[dlq]` is a full `OutputSettings` (`NodeSettings.dlq`, +`packages/tkati-core/tkati_core/settings.py:93`), a Kafka DLQ gets `config` with +no extra plumbing. + +### The reserved sets + +Two module-level frozensets in `tkati_core/kafka/settings.py`, public for the +same reason `CH_DATA_ERROR_CODES` is +(`design-docs/2026-09-28-clickhouse-error-classification.md`): the policy is the +part a reader may want to inspect. + +```python +PRODUCER_RESERVED = frozenset({"bootstrap.servers"}) +CONSUMER_RESERVED = frozenset({ + "bootstrap.servers", "group.id", "auto.offset.reset", "enable.auto.commit", +}) +``` + +Each entry is reserved for one of two distinct reasons, and the docstring should +say which, because they have different futures: + +- **Owned for correctness** — `enable.auto.commit`. tkati's commit protocol + requires it. This one stays reserved forever, and it is the reason the set + exists rather than a merge order. +- **Owned by a typed field** — `bootstrap.servers`, `group.id`, + `auto.offset.reset`. Reserved only because something else already sets them; + the field is the way to set them. If a typed field is ever removed in favour of + the passthrough, its property leaves the set with it. + +Exactly one property librdkafka would let you set is *dangerous* rather than +merely *duplicated*, which is worth stating plainly: a short reserved set is a +feature, and the instinct to pre-emptively reserve `acks`, `retries`, +`enable.idempotence` or `max.in.flight.requests.per.connection` should be +resisted. Those are the knobs this change exists to open. + +### Why compression is not a typed field + +A `compression: Literal["none", "gzip", "snappy", "lz4", "zstd"]` field on +`KafkaOutputSettings` is the obvious alternative, and it was the first plan. It +is not worth it, for a reason the probe table settles: librdkafka answers +`compression.type = "brotli"` with *`Invalid value "brotli" for configuration +property "compression.codec"`* at client construction, which is the same startup +as the pydantic error, with the same information in it. The `Literal` buys a +slightly better message and a settings schema that enumerates the five codecs. + +Against that, a typed field costs a second mechanism for one property. Both would +be able to set `compression.type`, so either a precedence rule exists for a +reader to remember, or `compression.type` joins the reserved set and the two +spellings become a startup error — machinery to explain either way. And the +precedent is the real cost: every subsequent property becomes an argument about +whether it deserves a field, and the ones that lose are reachable only through a +dict that the typed ones imply is second-class. + +So there is one mechanism. What was going to be the field's docstring becomes +README guidance instead, which is where the non-obvious part lives anyway: **the +codec earns much more under `format = "json"`**, where it sees many small, +near-identical messages in one message set, than under `"arrow-batch"`, a single +already-compact Arrow IPC message per table with no cross-message redundancy to +exploit. Both are compressed; only one is transformed. + +### Validation and errors + +A pydantic model validator on each of the two settings classes, raising with the +offending key and its reason: + +``` +output.config: "enable.auto.commit" is managed by tkati and cannot be set here; +the node commits each batch explicitly after it is durable. +input.config: "group.id" is set from `input.consumer.group_id`; set it there. +``` + +Two messages, one per reason, so the reader learns which kind of reservation they +hit. Validation is per-block and names the block, because a node with +`[input]`, `[output]` and `[dlq]` can trip it in three places. + +Everything else is librdkafka's to validate, at client construction, with the +messages in the Context table. This is the one place the design accepts a +less-good error message than pydantic would give, and it buys the entire point of +the change: we never have to know the property list. + +### Types + +`dict[str, str | int | bool]` matches what TOML yields for the properties people +actually set (`"linger.ms" = 50`, `"enable.idempotence" = true`, +`"client.id" = "x"`), and `config_entries` already lowers all three correctly — +bools via `PyBool` to `true`/`false`, the rest via `str()`. Floats are excluded: +librdkafka has no float-valued property, and allowing them would only let +`"linger.ms" = 50.0` through as `"50"`. + +`KafkaProducer.__init__`'s `kafka_config: dict[str, str]` must widen to +`dict[str, str | int | bool]`. `KafkaConsumer.__init__` is already +`dict[str, str | bool]` and widens to match, so the long-standing asymmetry +between the two wrappers goes away. `_native.pyi` already types the parameter as +`Mapping[str, object]` and needs no change. + +### The typed fields that remain + +`broker`, `group_id`, `auto_offset_reset`, `batch_size` and `batch_timeout_sec` +stay typed. They are the shape of a node — what it connects to and how it +batches — not tuning, and `batch_size` / `batch_timeout_sec` are read by tkati's +own poll loop rather than passed to librdkafka at all. + +Nothing new gets a field. `compression.type`, `linger.ms`, `compression.level`, +`batch.num.messages`, `batch.size`, `acks`, `client.id` and `fetch.*` are +passthrough keys. + +## Implementation Steps + +1. `packages/tkati-core/tkati_core/kafka/settings.py` + - Add `KafkaConfigOverrides = dict[str, str | int | bool]`, + `PRODUCER_RESERVED` and `CONSUMER_RESERVED` frozensets, each with the + two-reasons docstring. + - Add `config: KafkaConfigOverrides = Field(default_factory=dict)` to + `KafkaInputSettings` and `KafkaOutputSettings`. + - Add a shared `_reject_reserved(...)` helper and a + `@model_validator(mode="after")` on each class calling it. +2. `packages/tkati-core/tkati_core/kafka/producer.py` + - Widen `__init__`'s `kafka_config` to `dict[str, str | int | bool]`. + - `from_topic_settings` takes `config: KafkaConfigOverrides | None = None` + and merges it over the derived properties; `from_output_settings` passes + `settings.config`. +3. `packages/tkati-core/tkati_core/kafka/consumer.py` + - Widen `__init__`'s `kafka_config` the same way. + - `from_input_settings` merges `settings.config` over the four derived + properties. +4. Tests, in `packages/tkati-core/tests/test_producer.py` and + `test_consumer.py`: the merge reaches librdkafka, asserted by equality on the + whole config dict so an accidental extra or renamed property fails; a reserved + key raises at settings parse, once per reason. The unit tests use a capturing + stub in place of `NativeProducer` / `NativeConsumer` so they need no broker; + one test builds a real client with a str, an int and a bool property to + confirm librdkafka accepts what we send, since the stub only checks our own + merge. `[dlq]` needs no test of its own — it is an `OutputSettings` built by + the same `build_producer` path as `[output]`. +5. Docs: a **Client configuration** subsection in + `packages/tkati-core/README.md` next to **Formats** and **Message keys**, + with the reserved sets, the two error messages, and the three worked examples + from the Goal — the compression one carrying the `json` / `arrow-batch` + guidance, the SASL one carrying the build caveat so nobody debugs + `SCRAM-SHA-256` against the current wheel. Note in `tkati-node-el` and + `tkati-node-dedup` READMEs that `[output.config]` and `[input.config]` exist, + pointing at `tkati-core`'s README rather than duplicating the list. CHANGELOG + entries for all three. +6. Mark F5's configurability half addressed in + `design-docs/2026-09-27-tkati-core-audit.md`, leaving the row Open for the + idempotence default, and cross-reference this doc from it. + +## Implementation notes (as built) + +- The two error messages are assembled from a module-level `_RESERVED_REASONS` + dict keyed by property, not from the two frozensets, because the *reason* is + per-property while membership is per-role: `bootstrap.servers` is in both sets + and has one reason. The sets stay the public statement of policy; + `_RESERVED_REASONS` is private prose. +- The message is `config: "" ` and does **not** name the block. + Pydantic already prefixes the location — a bad `[output.config]` inside a + node's settings reports as `output` → `Value error, config: "bootstrap.servers" + is set from ...` — so hardcoding `output.` would have printed it twice, and + `KafkaOutputSettings` cannot know whether it was loaded as `output` or `dlq`. +- `_reject_reserved` is a module-level function called from a + `@model_validator(mode="after")` on each settings class, rather than an + `Annotated` validator on the field type, because the reserved set depends on + which class the field is in. +- No Rust change, as expected: `config_entries` + (`packages/tkati-core/src/lib.rs:48`) already stringifies values and lowers + bools, so `_native.pyi`'s `Mapping[str, object]` was already right. + +## Verification + +187 `tkati-core` tests and 41 across `tkati-node-el` / `tkati-node-dedup` pass, +with `ruff check packages/` and `ty check packages/` clean. + +The new tests pin: the producer's config dict by equality with and without a +passthrough; the consumer's by equality including all four derived properties, +so a passthrough that displaced one would fail there as well as at parse time; a +reserved property per reason (`enable.auto.commit`, `group.id`); and librdkafka's +own validation, both directions — it accepts a str, an int and a bool, and +rejects `compresion.type` and `compression.type = "brotli"` with the messages +quoted in Context, which is the test that stands behind not validating property +names ourselves. + +Not asserted, because it is not observable through librdkafka: that a Python +`True` reaches it as `true` rather than `True`. librdkafka parses both (it +rejects `yes`, so the parsing is real but case-insensitive), so the lowering in +`config_entries` is covered only by the bool surviving at all. Its one existing +in-tree user, `enable.auto.commit=False`, has worked since the native port. + +Also not measured: the compression ratio or throughput effect of any codec on +real traffic. `benchmarks/bench_kafka_json.py` is broker-free and measures the +JSON encode, so it cannot see it. diff --git a/packages/tkati-core/CHANGELOG.md b/packages/tkati-core/CHANGELOG.md index a627f52..e1b6b76 100644 --- a/packages/tkati-core/CHANGELOG.md +++ b/packages/tkati-core/CHANGELOG.md @@ -1,3 +1,17 @@ +# 0.8.3 + +* `[input.config]` and `[output.config]` pass arbitrary librdkafka properties + through to the consumer and producer — compression, `linger.ms`, `acks`, + `client.id`, `SASL_PLAINTEXT`/`PLAIN` auth. Values may be strings, ints or + bools, and librdkafka validates names and values when the client is built. +* The properties `tkati-core` sets itself (`PRODUCER_RESERVED`, + `CONSUMER_RESERVED`) are rejected in `config` at settings-parse time instead + of being silently overridden. `enable.auto.commit` is the one that matters: + librdkafka accepts `true` silently, and it would turn a node's rewind-on- + failure path into data loss. +* No defaults changed: compression is still off and `enable.idempotence` still + false unless `config` says otherwise. + # 0.8.2 * Fix: a ClickHouse outage no longer empties a batch into the DLQ. diff --git a/packages/tkati-core/Cargo.lock b/packages/tkati-core/Cargo.lock index a09b7b1..55814e7 100644 --- a/packages/tkati-core/Cargo.lock +++ b/packages/tkati-core/Cargo.lock @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "tkati_core_native" -version = "0.8.2" +version = "0.8.3" dependencies = [ "arrow-array", "arrow-buffer", diff --git a/packages/tkati-core/Cargo.toml b/packages/tkati-core/Cargo.toml index 5fdbc41..919858b 100644 --- a/packages/tkati-core/Cargo.toml +++ b/packages/tkati-core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tkati_core_native" # Kept equal to the workspace's shared Python version (see CLAUDE.md). -version = "0.8.2" +version = "0.8.3" edition = "2024" publish = false diff --git a/packages/tkati-core/README.md b/packages/tkati-core/README.md index 73a2367..e8e514d 100644 --- a/packages/tkati-core/README.md +++ b/packages/tkati-core/README.md @@ -530,6 +530,51 @@ same way against `ClickHouseOutputSettings`. - `"json"` *(default)*: each row becomes a separate Kafka message serialized with orjson. - `"arrow-batch"`: the entire table is serialized as a single Arrow IPC stream message. +**Client configuration** — any librdkafka property, via `[output.config]` for a producer +or `[input.config]` for a consumer: + +```toml +[output.config] +"compression.type" = "zstd" # librdkafka's default is "none" +"linger.ms" = 50 +"batch.num.messages" = 50000 +"client.id" = "node-el-prod-1" +``` + +Values may be strings, ints or bools; each reaches librdkafka in the string form it +expects. The dict is merged over the properties derived from the typed settings, and +**property names and values are validated by librdkafka when the client is built** — a +typo'd `compresion.type` fails node startup with `No such configuration property`, and +`compression.type = "brotli"` with `Invalid value "brotli" for configuration property +"compression.codec"`. There is no list of properties here to go stale. + +`[dlq.config]` works the same way when the DLQ sink is Kafka. + +Properties `tkati-core` sets itself are rejected at settings-parse time rather than +silently overridden — `PRODUCER_RESERVED` and `CONSUMER_RESERVED` in +`tkati_core.kafka.settings`: + +| Property | Role | Why | +| --- | --- | --- | +| `enable.auto.commit` | consumer | Must stay false: a node commits each batch explicitly once it is durable, and rewinds it on failure. | +| `bootstrap.servers` | both | Set from `connection.broker`. | +| `group.id` | consumer | Set from `consumer.group_id`. | +| `auto.offset.reset` | consumer | Set from `consumer.auto_offset_reset`. | + +Two notes on properties worth setting: + +- **Compression** pays off far more under `format = "json"`, where the codec sees many + small, near-identical messages in one message set, than under `"arrow-batch"`, which + is a single already-compact Arrow IPC message per table with no cross-message + redundancy to exploit. Both are compressed; only one is transformed. Consumers need + no setting — decompression is automatic. +- **Authentication** is settable but only partly usable in the published wheel: + `security.protocol = "SASL_PLAINTEXT"` with `sasl.mechanism = "PLAIN"` works, while + `SCRAM-SHA-256`, `GSSAPI` and `OAUTHBEARER` fail with *"No provider for SASL + mechanism … recompile librdkafka with libsasl2"*, and any `_SSL` protocol with + *"OpenSSL not available at build time"*. See the `rdkafka` dependency comment in + `Cargo.toml` for what re-enabling them takes. + **Message keys** — controlled by `output.topic.key_column` in `settings.toml`: ```toml diff --git a/packages/tkati-core/pyproject.toml b/packages/tkati-core/pyproject.toml index aef55c9..e0db7f4 100644 --- a/packages/tkati-core/pyproject.toml +++ b/packages/tkati-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tkati-core" -version = "0.8.2" +version = "0.8.3" description = "Core abstractions for Kafka/ClickHouse streaming pipeline nodes" readme = "README.md" requires-python = ">=3.13" diff --git a/packages/tkati-core/tests/test_consumer.py b/packages/tkati-core/tests/test_consumer.py index 9cab02b..0b0cf55 100644 --- a/packages/tkati-core/tests/test_consumer.py +++ b/packages/tkati-core/tests/test_consumer.py @@ -9,6 +9,7 @@ from confluent_kafka import Consumer as RawConsumer from confluent_kafka import Producer, TopicPartition from confluent_kafka.admin import AdminClient, NewTopic +from pydantic import ValidationError from tkati_core import ( CONSUMER_PHASES, Batch, @@ -19,7 +20,16 @@ ) from tkati_core.kafka.consumer import KafkaConsumer from tkati_core.kafka.producer import KafkaProducer -from tkati_core.kafka.settings import KafkaInputSettings, KafkaOutputSettings +from tkati_core.kafka.settings import ( + _RESERVED_REASONS, + CONSUMER_RESERVED, + PRODUCER_RESERVED, + KafkaConnectionSettings, + KafkaConsumerSettings, + KafkaInputSettings, + KafkaOutputSettings, + KafkaTopicSettings, +) def test_from_input_settings_sets_attributes(input_settings: KafkaInputSettings): @@ -31,6 +41,57 @@ def test_from_input_settings_sets_attributes(input_settings: KafkaInputSettings) consumer.close() +def test_input_config_is_merged_into_the_client_config( + input_settings: KafkaInputSettings, monkeypatch: pytest.MonkeyPatch +): + captured_config: dict[str, str | int | bool] = {} + + class CapturingNativeConsumer: + def __init__(self, config: dict[str, str | int | bool], topic: str) -> None: + captured_config.update(config) + + monkeypatch.setattr( + "tkati_core.kafka.consumer.NativeConsumer", CapturingNativeConsumer + ) + input_settings.config = {"fetch.min.bytes": 65536, "client.id": "reader-1"} + KafkaConsumer.from_input_settings(input_settings) + # Equality, not containment: the four derived properties are pinned too, so + # a passthrough that displaced one would fail here as well as at parse time. + assert captured_config == { + "bootstrap.servers": input_settings.connection.broker, + "group.id": input_settings.consumer.group_id, + "auto.offset.reset": input_settings.consumer.auto_offset_reset, + "enable.auto.commit": False, + "fetch.min.bytes": 65536, + "client.id": "reader-1", + } + + +@pytest.mark.parametrize( + ("key", "message"), + [ + ("enable.auto.commit", "is managed by tkati"), + ("group.id", r"is set from `consumer\.group_id`"), + ], +) +def test_input_config_rejects_a_reserved_property(key: str, message: str): + """One case per reason the set exists: a property owned for correctness, and + one owned by a typed field.""" + with pytest.raises(ValidationError, match=message): + KafkaInputSettings( + connection=KafkaConnectionSettings(broker="broker:9092"), + topic=KafkaTopicSettings(name="t"), + consumer=KafkaConsumerSettings(group_id="g"), + config={key: True}, + ) + + +def test_every_reserved_property_has_a_reason(): + """The sets are the policy; the reasons are what an operator reads. A key + added to one without the other would raise `KeyError` from the validator.""" + assert (PRODUCER_RESERVED | CONSUMER_RESERVED) <= _RESERVED_REASONS.keys() + + def test_from_input_settings_builds_arrow_schemas(input_settings: KafkaInputSettings): consumer = KafkaConsumer.from_input_settings(input_settings) try: diff --git a/packages/tkati-core/tests/test_producer.py b/packages/tkati-core/tests/test_producer.py index 3cc8f8e..0ebd134 100644 --- a/packages/tkati-core/tests/test_producer.py +++ b/packages/tkati-core/tests/test_producer.py @@ -5,7 +5,9 @@ import pyarrow as pa import pytest from confluent_kafka import Consumer +from pydantic import ValidationError from tkati_core import PRODUCER_PHASES, DeliveryError, LoopStats +from tkati_core._native import NativeProducer from tkati_core.kafka.producer import KafkaProducer from tkati_core.kafka.settings import ( KafkaConnectionSettings, @@ -30,14 +32,87 @@ def _consume_all( return messages -def test_from_output_settings_sets_attributes(output_settings: KafkaOutputSettings): +def _capture_config(monkeypatch: pytest.MonkeyPatch) -> dict[str, str | int | bool]: + """Replace `NativeProducer` with a stub recording the config it is built + with, so a test can assert on it without a broker.""" + captured: dict[str, str | int | bool] = {} + + class CapturingNativeProducer: + def __init__(self, config: dict[str, str | int | bool], topic: str) -> None: + captured.update(config) + + monkeypatch.setattr( + "tkati_core.kafka.producer.NativeProducer", CapturingNativeProducer + ) + return captured + + +def test_from_output_settings_sets_attributes( + output_settings: KafkaOutputSettings, monkeypatch: pytest.MonkeyPatch +): + captured_config = _capture_config(monkeypatch) producer = KafkaProducer.from_output_settings(output_settings) - try: - assert producer.topic_name == output_settings.topic.name - assert producer.format == output_settings.topic.format - assert producer.key_column == output_settings.topic.key_column - finally: - producer.close() + assert producer.topic_name == output_settings.topic.name + assert producer.format == output_settings.topic.format + assert producer.key_column == output_settings.topic.key_column + assert captured_config == {"bootstrap.servers": output_settings.connection.broker} + + +def test_output_config_is_merged_into_the_client_config( + output_settings: KafkaOutputSettings, monkeypatch: pytest.MonkeyPatch +): + captured_config = _capture_config(monkeypatch) + output_settings.config = { + "compression.type": "zstd", + "linger.ms": 50, + "enable.idempotence": True, + } + KafkaProducer.from_output_settings(output_settings) + # Equality, not containment: an accidental extra or renamed property fails. + assert captured_config == { + "bootstrap.servers": output_settings.connection.broker, + "compression.type": "zstd", + "linger.ms": 50, + "enable.idempotence": True, + } + + +def test_output_config_rejects_a_reserved_property(): + with pytest.raises(ValidationError, match=r"is set from `connection\.broker`"): + KafkaOutputSettings( + connection=KafkaConnectionSettings(broker="broker:9092"), + topic=KafkaTopicSettings(name="t"), + config={"bootstrap.servers": "elsewhere:9092"}, + ) + + +def test_librdkafka_accepts_a_passthrough_of_each_value_type( + output_settings: KafkaOutputSettings, +): + """The stub above only checks our own merge. This builds a real client, so + the three properties are validated by librdkafka, and a str, an int and a + bool are all shown to survive the native layer's lowering.""" + NativeProducer( + { + "bootstrap.servers": output_settings.connection.broker, + "compression.type": "zstd", + "linger.ms": 50, + "enable.idempotence": True, + }, + "config-passthrough-probe", + ) + + +def test_librdkafka_rejects_an_unknown_or_invalid_property( + output_settings: KafkaOutputSettings, +): + """Why no property-name validation in pydantic: librdkafka does it, at + client construction, naming the property.""" + broker = output_settings.connection.broker + with pytest.raises(Exception, match="No such configuration property"): + NativeProducer({"bootstrap.servers": broker, "compresion.type": "zstd"}, "t") + with pytest.raises(Exception, match='Invalid value "brotli"'): + NativeProducer({"bootstrap.servers": broker, "compression.type": "brotli"}, "t") def test_produce_json_format( @@ -72,7 +147,9 @@ def test_produce_json_format_preserves_timestamp( before JSON serialization, instead of orjson emitting an ISO-8601 string.""" settings = KafkaOutputSettings( connection=KafkaConnectionSettings(broker="localhost:9092"), - topic=KafkaTopicSettings(name=kafka_output_topic, schema={"ts": "timestamp[ms]"}), + topic=KafkaTopicSettings( + name=kafka_output_topic, schema={"ts": "timestamp[ms]"} + ), ) table = pa.table({"ts": pa.array([1_700_000_000_000], type=pa.int64())}).cast( pa.schema([pa.field("ts", pa.timestamp("ms"))]) diff --git a/packages/tkati-core/tkati_core/kafka/consumer.py b/packages/tkati-core/tkati_core/kafka/consumer.py index d193737..f982a04 100644 --- a/packages/tkati-core/tkati_core/kafka/consumer.py +++ b/packages/tkati-core/tkati_core/kafka/consumer.py @@ -61,12 +61,17 @@ def from_input_settings(cls, settings: "KafkaInputSettings") -> "KafkaConsumer": Sets enable.auto.commit=False — each batch's offsets are committed explicitly via .commit(batch). + + `settings.config` holds extra librdkafka properties, merged over the + four derived here. All four are in `CONSUMER_RESERVED` and rejected + when the settings are parsed, so the merge cannot overwrite them. """ - kafka_config: dict[str, str | bool] = { + kafka_config: dict[str, str | int | bool] = { "bootstrap.servers": settings.connection.broker, "group.id": settings.consumer.group_id, "auto.offset.reset": settings.consumer.auto_offset_reset, "enable.auto.commit": False, + **settings.config, } return cls( kafka_config=kafka_config, @@ -76,7 +81,7 @@ def from_input_settings(cls, settings: "KafkaInputSettings") -> "KafkaConsumer": def __init__( self, - kafka_config: dict[str, str | bool], + kafka_config: dict[str, str | int | bool], topic_name: str, input_schema: dict[str, str], ) -> None: diff --git a/packages/tkati-core/tkati_core/kafka/producer.py b/packages/tkati-core/tkati_core/kafka/producer.py index 5d05432..1b073a4 100644 --- a/packages/tkati-core/tkati_core/kafka/producer.py +++ b/packages/tkati-core/tkati_core/kafka/producer.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from tkati_core.kafka.settings import ( + KafkaConfigOverrides, KafkaConnectionSettings, KafkaOutputSettings, KafkaTopicSettings, @@ -66,7 +67,7 @@ class KafkaProducer(ProducerBase): def __init__( self, - kafka_config: dict[str, str], + kafka_config: dict[str, str | int | bool], topic_name: str, format: Literal["json", "arrow-batch"] = "json", key_column: str | None = None, @@ -92,10 +93,23 @@ def __init__( @classmethod def from_topic_settings( - cls, connection: "KafkaConnectionSettings", topic: "KafkaTopicSettings" + cls, + connection: "KafkaConnectionSettings", + topic: "KafkaTopicSettings", + config: "KafkaConfigOverrides | None" = None, ) -> "KafkaProducer": + """Build a producer for one topic. + + `config` holds extra librdkafka properties, merged over the ones + derived from `connection`. Properties in `PRODUCER_RESERVED` are + rejected when the settings are parsed, so the merge cannot overwrite + one of them here. + """ return cls( - kafka_config={"bootstrap.servers": connection.broker}, + kafka_config={ + "bootstrap.servers": connection.broker, + **(config or {}), + }, topic_name=topic.name, format=topic.format, key_column=topic.key_column, @@ -104,7 +118,9 @@ def from_topic_settings( @classmethod def from_output_settings(cls, settings: "KafkaOutputSettings") -> "KafkaProducer": - return cls.from_topic_settings(settings.connection, settings.topic) + return cls.from_topic_settings( + settings.connection, settings.topic, settings.config + ) def produce_arrow( self, diff --git a/packages/tkati-core/tkati_core/kafka/settings.py b/packages/tkati-core/tkati_core/kafka/settings.py index 8087331..89eb6e1 100644 --- a/packages/tkati-core/tkati_core/kafka/settings.py +++ b/packages/tkati-core/tkati_core/kafka/settings.py @@ -1,6 +1,63 @@ from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator + +KafkaConfigOverrides = dict[str, str | int | bool] +"""Extra librdkafka properties, passed through to the client verbatim. + +Values may be strings, ints or bools; the native layer lowers each to the +string form librdkafka wants (bools become ``true`` / ``false``). Property +names and values are validated by librdkafka itself, when the client is +constructed. +""" + +PRODUCER_RESERVED = frozenset({"bootstrap.servers"}) +"""Producer properties `KafkaOutputSettings.config` may not set. + +Owned by a typed field: ``bootstrap.servers`` comes from +``connection.broker``. Reserved only because something else already sets it — +were the field removed in favour of the passthrough, the property would leave +this set with it. +""" + +CONSUMER_RESERVED = frozenset( + { + "bootstrap.servers", + "group.id", + "auto.offset.reset", + "enable.auto.commit", + } +) +"""Consumer properties `KafkaInputSettings.config` may not set. + +Owned for correctness: ``enable.auto.commit`` must stay false, because a node +commits each batch explicitly once it is durable and rewinds it on failure. +librdkafka accepts ``enable.auto.commit=true`` silently, so overriding it here +would turn the failure path into silent data loss. + +Owned by a typed field, as in `PRODUCER_RESERVED`: ``bootstrap.servers``, +``group.id`` and ``auto.offset.reset``. +""" + +_RESERVED_REASONS = { + "enable.auto.commit": ( + "is managed by tkati and cannot be set here; the node commits each " + "batch explicitly after it is durable" + ), + "bootstrap.servers": "is set from `connection.broker`; set it there", + "group.id": "is set from `consumer.group_id`; set it there", + "auto.offset.reset": "is set from `consumer.auto_offset_reset`; set it there", +} + + +def _reject_reserved( + config: KafkaConfigOverrides, reserved: frozenset[str] +) -> KafkaConfigOverrides: + """Raise if `config` names a property tkati sets itself.""" + for key in config: + if key in reserved: + raise ValueError(f'config: "{key}" {_RESERVED_REASONS[key]}') + return config class KafkaConnectionSettings(BaseModel): @@ -26,9 +83,21 @@ class KafkaInputSettings(BaseModel): connection: KafkaConnectionSettings topic: KafkaTopicSettings consumer: KafkaConsumerSettings + config: KafkaConfigOverrides = Field(default_factory=dict) + + @model_validator(mode="after") + def _reject_reserved_config(self) -> "KafkaInputSettings": + _reject_reserved(self.config, CONSUMER_RESERVED) + return self class KafkaOutputSettings(BaseModel): type: Literal["kafka"] = "kafka" connection: KafkaConnectionSettings topic: KafkaTopicSettings + config: KafkaConfigOverrides = Field(default_factory=dict) + + @model_validator(mode="after") + def _reject_reserved_config(self) -> "KafkaOutputSettings": + _reject_reserved(self.config, PRODUCER_RESERVED) + return self diff --git a/packages/tkati-dashboard/pyproject.toml b/packages/tkati-dashboard/pyproject.toml index c1df3f2..f8e831a 100644 --- a/packages/tkati-dashboard/pyproject.toml +++ b/packages/tkati-dashboard/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "tkati-dashboard" -version = "0.8.2" +version = "0.8.3" description = "Local web dashboard that renders a serialized tkati dataflow directory as a graph" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "tkati-core==0.8.2", + "tkati-core==0.8.3", "fastapi>=0.115.0", "uvicorn>=0.34.0", "confluent-kafka>=2.11.0", diff --git a/packages/tkati-node-dedup/CHANGELOG.md b/packages/tkati-node-dedup/CHANGELOG.md index 2f70615..dfc80c0 100644 --- a/packages/tkati-node-dedup/CHANGELOG.md +++ b/packages/tkati-node-dedup/CHANGELOG.md @@ -1,3 +1,10 @@ +# 0.8.3 + +* Kafka input and output accept `[input.config]` / `[output.config]`: any + librdkafka property, passed through to the client. Compression is set this + way — `"compression.type" = "zstd"`. See "Client configuration" in + `tkati-core`'s README. + # 0.8.1 * Fix: the perf line's rows in and out are counted for the same batches, so diff --git a/packages/tkati-node-dedup/README.md b/packages/tkati-node-dedup/README.md index 75eee9d..245debf 100644 --- a/packages/tkati-node-dedup/README.md +++ b/packages/tkati-node-dedup/README.md @@ -41,6 +41,12 @@ broker = "redpanda:29092" [output.topic] name = "raw_event_deduped" +# Optional. Any librdkafka property, passed through to the producer; see +# "Client configuration" in tkati-core's README. `[input.config]` does the same +# for the consumer. +[output.config] +"compression.type" = "zstd" + [dedup] field = "uid" # column in input.topic.schema to dedup by window_hours = 3 # rolling dedup window diff --git a/packages/tkati-node-dedup/pyproject.toml b/packages/tkati-node-dedup/pyproject.toml index 559b6cc..7423096 100644 --- a/packages/tkati-node-dedup/pyproject.toml +++ b/packages/tkati-node-dedup/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "tkati-node-dedup" -version = "0.8.2" +version = "0.8.3" description = "Kafka-to-Kafka streaming node that deduplicates events by a configurable field within a rolling processing-time window" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "tkati-core==0.8.2", + "tkati-core==0.8.3", "prometheus-client>=0.21", "loguru>=0.7.0", "pydantic-settings>=2.11.0", diff --git a/packages/tkati-node-el/CHANGELOG.md b/packages/tkati-node-el/CHANGELOG.md index da05a6a..9e14153 100644 --- a/packages/tkati-node-el/CHANGELOG.md +++ b/packages/tkati-node-el/CHANGELOG.md @@ -1,3 +1,10 @@ +# 0.8.3 + +* Kafka input and output accept `[input.config]` / `[output.config]`: any + librdkafka property, passed through to the client. Compression is set this + way — `"compression.type" = "zstd"`. See "Client configuration" in + `tkati-core`'s README. + # 0.8.1 * Fix: the perf line's rows in and out are counted for the same batches, so diff --git a/packages/tkati-node-el/README.md b/packages/tkati-node-el/README.md index 86bbb1f..15cbdfe 100644 --- a/packages/tkati-node-el/README.md +++ b/packages/tkati-node-el/README.md @@ -80,6 +80,12 @@ broker = "redpanda:29092" name = "some-other-topic" format = "json" # or "arrow-batch" key_column = "uid" # optional + +# Optional. Any librdkafka property, passed through to the producer; see +# "Client configuration" in tkati-core's README. `[input.config]` and +# `[dlq.config]` do the same for the consumer and a Kafka DLQ. +[output.config] +"compression.type" = "zstd" ``` A ClickHouse DLQ instead looks like: diff --git a/packages/tkati-node-el/pyproject.toml b/packages/tkati-node-el/pyproject.toml index ec65431..cf93d6a 100644 --- a/packages/tkati-node-el/pyproject.toml +++ b/packages/tkati-node-el/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "tkati-node-el" -version = "0.8.2" +version = "0.8.3" description = "Generic extract/load node: reads from a configurable input and writes to a configurable output" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "tkati-core==0.8.2", + "tkati-core==0.8.3", "loguru>=0.7.0", "pydantic-settings>=2.11.0", ] diff --git a/pyproject.toml b/pyproject.toml index 5e9606c..6081d8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tkati" -version = "0.8.2" +version = "0.8.3" description = "Add your description here" readme = "README.md" requires-python = ">=3.13"