flechtwerk 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 (49) hide show
  1. flechtwerk-0.1.0/.github/workflows/ci.yaml +9 -0
  2. flechtwerk-0.1.0/.github/workflows/publish.yaml +30 -0
  3. flechtwerk-0.1.0/.github/workflows/test.yaml +35 -0
  4. flechtwerk-0.1.0/.gitignore +16 -0
  5. flechtwerk-0.1.0/CLAUDE.md +192 -0
  6. flechtwerk-0.1.0/LICENSE +21 -0
  7. flechtwerk-0.1.0/PKG-INFO +292 -0
  8. flechtwerk-0.1.0/README.md +263 -0
  9. flechtwerk-0.1.0/pyproject.toml +93 -0
  10. flechtwerk-0.1.0/src/flechtwerk/__init__.py +20 -0
  11. flechtwerk-0.1.0/src/flechtwerk/attribute/__init__.py +50 -0
  12. flechtwerk-0.1.0/src/flechtwerk/attribute/attribute.py +173 -0
  13. flechtwerk-0.1.0/src/flechtwerk/attribute/codec.py +31 -0
  14. flechtwerk-0.1.0/src/flechtwerk/attribute/codecs.py +140 -0
  15. flechtwerk-0.1.0/src/flechtwerk/attribute/record.py +265 -0
  16. flechtwerk-0.1.0/src/flechtwerk/configs.py +156 -0
  17. flechtwerk-0.1.0/src/flechtwerk/extractor.py +311 -0
  18. flechtwerk-0.1.0/src/flechtwerk/kafka.py +212 -0
  19. flechtwerk-0.1.0/src/flechtwerk/metrics.py +192 -0
  20. flechtwerk-0.1.0/src/flechtwerk/module.py +466 -0
  21. flechtwerk-0.1.0/src/flechtwerk/mqtt.py +486 -0
  22. flechtwerk-0.1.0/src/flechtwerk/observer.py +105 -0
  23. flechtwerk-0.1.0/src/flechtwerk/py.typed +0 -0
  24. flechtwerk-0.1.0/src/flechtwerk/state.py +249 -0
  25. flechtwerk-0.1.0/src/flechtwerk/testing.py +371 -0
  26. flechtwerk-0.1.0/src/flechtwerk/transformer.py +460 -0
  27. flechtwerk-0.1.0/src/flechtwerk/types.py +72 -0
  28. flechtwerk-0.1.0/tests/attribute/attribute_test.py +139 -0
  29. flechtwerk-0.1.0/tests/attribute/codecs_test.py +166 -0
  30. flechtwerk-0.1.0/tests/attribute/record_test.py +423 -0
  31. flechtwerk-0.1.0/tests/configs_test.py +232 -0
  32. flechtwerk-0.1.0/tests/extractor_test.py +675 -0
  33. flechtwerk-0.1.0/tests/integration/__init__.py +0 -0
  34. flechtwerk-0.1.0/tests/integration/config_topics_test.py +261 -0
  35. flechtwerk-0.1.0/tests/integration/conftest.py +66 -0
  36. flechtwerk-0.1.0/tests/integration/exactly_once_test.py +195 -0
  37. flechtwerk-0.1.0/tests/integration/load_balancing_test.py +309 -0
  38. flechtwerk-0.1.0/tests/integration/mqtt_delivery_test.py +228 -0
  39. flechtwerk-0.1.0/tests/integration/restore_changelog_test.py +168 -0
  40. flechtwerk-0.1.0/tests/kafka_test.py +450 -0
  41. flechtwerk-0.1.0/tests/module_test.py +103 -0
  42. flechtwerk-0.1.0/tests/mqtt_test.py +656 -0
  43. flechtwerk-0.1.0/tests/observer_test.py +196 -0
  44. flechtwerk-0.1.0/tests/producer_test.py +42 -0
  45. flechtwerk-0.1.0/tests/serialize_test.py +74 -0
  46. flechtwerk-0.1.0/tests/state_test.py +490 -0
  47. flechtwerk-0.1.0/tests/transformer_test.py +1064 -0
  48. flechtwerk-0.1.0/tests/types_test.py +33 -0
  49. flechtwerk-0.1.0/uv.lock +495 -0
@@ -0,0 +1,9 @@
1
+ jobs:
2
+ test:
3
+ uses: ./.github/workflows/test.yaml
4
+ name: CI
5
+ on:
6
+ pull_request:
7
+ branches: [ main ]
8
+ push:
9
+ branches: [ '**' ]
@@ -0,0 +1,30 @@
1
+ jobs:
2
+ test:
3
+ uses: ./.github/workflows/test.yaml
4
+ with:
5
+ run-coverage: false
6
+ publish:
7
+ environment: pypi
8
+ name: Publish to PyPI
9
+ needs: test
10
+ permissions:
11
+ contents: read # job-level permissions reset all unlisted scopes to none
12
+ id-token: write # required for trusted publishing
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v5
16
+ with:
17
+ fetch-depth: 0 # hatch-vcs derives the version from the tag
18
+ - uses: astral-sh/setup-uv@v8.3.2
19
+ with:
20
+ python-version: '3.14'
21
+ - name: Install dependencies
22
+ run: uv sync
23
+ - name: Build package
24
+ run: uv build
25
+ - name: Publish to PyPI
26
+ run: uv publish
27
+ name: Publish to PyPI
28
+ on:
29
+ push:
30
+ tags: [ 'v*.*.*' ]
@@ -0,0 +1,35 @@
1
+ jobs:
2
+ test:
3
+ name: Test Python ${{ matrix.python-version }}
4
+ runs-on: ubuntu-latest
5
+ steps:
6
+ - uses: actions/checkout@v5
7
+ with:
8
+ fetch-depth: 0 # hatch-vcs derives the version from tags
9
+ - uses: astral-sh/setup-uv@v8.3.2
10
+ with:
11
+ python-version: ${{ matrix.python-version }}
12
+ - name: Install dependencies
13
+ run: uv sync
14
+ - name: Run tests without coverage
15
+ if: '!inputs.run-coverage'
16
+ run: uv run pytest -m 'integration or not integration'
17
+ - name: Run tests with coverage
18
+ if: inputs.run-coverage
19
+ run: |
20
+ uv run coverage run -m pytest -m 'integration or not integration'
21
+ uv run coverage report
22
+ strategy:
23
+ fail-fast: false
24
+ matrix:
25
+ python-version: ${{ fromJson(inputs.python-versions) }}
26
+ name: Reusable Test Workflow
27
+ on:
28
+ workflow_call:
29
+ inputs:
30
+ python-versions:
31
+ type: string
32
+ default: '["3.14"]'
33
+ run-coverage:
34
+ type: boolean
35
+ default: true
@@ -0,0 +1,16 @@
1
+ *.egg-info
2
+ .coverage
3
+ .coverage.*
4
+ .hypothesis/
5
+ .idea/
6
+ .pytest_cache/
7
+ .venv
8
+ .vscode/
9
+ /.claude/settings.local.json
10
+ /CLAUDE.local.md
11
+ /graphify-out/
12
+ __pycache__/
13
+ build/
14
+ coverage.xml
15
+ dist/
16
+ htmlcov/
@@ -0,0 +1,192 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Development Practices
6
+
7
+ ### Git Operations
8
+
9
+ - **Never push without permission**: Only push commits when explicitly asked to do so
10
+ - Commits can be made freely, but pushing requires explicit user request
11
+ - **Don't commit code automatically**
12
+ - Always use PGP signatures when committing (`git commit -S`)
13
+
14
+ ### Code Style
15
+
16
+ - **Alphabetical ordering**: Keep dictionary keys, constants, imports, and other collections in alphabetical order where possible
17
+ - **Comprehensions over accumulator loops**: Build lists, dicts, and sets with comprehensions or generator expressions — not by starting with an empty container and mutating it in a `for` loop. Exception: loop bodies with real side effects (logging, I/O, external mutation)
18
+ - **Markdown formatting**: Always include empty lines after headings in Markdown files
19
+ - **File endings**: All text files must end with a newline
20
+ - **Typing**: Use `X | None` instead of `Optional[X]` for typing
21
+
22
+ ## Commands
23
+
24
+ ```bash
25
+ uv sync # venv + all dependencies
26
+ uv run pytest # unit tier — Docker-free
27
+ uv run pytest -m integration # integration tier — ephemeral Kafka/Mosquitto via testcontainers; skips when Docker is unreachable
28
+ uv run coverage run -m pytest -m "integration or not integration" && uv run coverage report # both tiers, matching CI
29
+ uv build # sdist + wheel; version derived from the latest vX.Y.Z tag via hatch-vcs
30
+ ```
31
+
32
+ ### Releasing
33
+
34
+ Tag `vX.Y.Z` and push the tag; `.github/workflows/publish.yaml` runs the test suite, builds the package with the tag-derived version (hatch-vcs), and publishes it to PyPI via trusted publishing. Nothing else sets the version.
35
+
36
+ ## Architecture
37
+
38
+ Flechtwerk is an async stream processing framework for Kafka with hexagonal architecture (ports and adapters), packaged under `src/flechtwerk/`. Applications build stages in two shapes:
39
+
40
+ 1. **Extractor** (abstract — construct via `Extractor.of(...)` or subclass): Async polls external sources based on Kafka configuration, maintains state in RocksDB for incremental processing
41
+ 2. **Transformer** (abstract — construct via `Transformer.of(...)` or subclass): Consumes partitioned input topics and publishes to Kafka with exactly-once delivery
42
+
43
+ Module by module:
44
+
45
+ - **`attribute/`**: `Attribute[V]` is a single, type-safe handle on a dict key carrying an explicit `Codec[V]` — `Attribute(name, codec)` declares a required field, `Attribute(name, codec, optional=True)` (keyword-only) one that may be absent or `None`. Codecs are compositional: atoms (`STR`, `INT`, `BOOL`, `DATE`, `FLOAT`, `DATETIME`, `TIME`, `RECORD`, `ANY`) and constructors (`LIST(V)`, `SET(V)`, `TUPLE(V)`, `DICT(V)`). `Record` wraps a `dict[str, Any]` (`.raw`) and accepts only `Attribute` keys for indexing — codecs run on every write to enforce that `.raw` stays JSON-native. **Two constructor paths:** `Record(source)` accepts another `Record` (copy via `raw.copy()`) or `dict[Attribute, Any]` (typed literal — runs each Attribute's `write_to`); `Record.wrap(raw_dict)` is the wire-format entry point that accepts `dict[str, Any]` (raw JSON, `.raw` payloads, pickle restore) and runs every value through the recursive `_encode_any` walker. Pick the path that matches the input shape — typed literals and Record-spread go through `__init__`, raw JSON goes through `.wrap`. Subclasses (`Event`, `State`, `Config`) inherit both. A required attribute (the default) rejects `None` writes so a missing value can't land silently as JSON `null`; `optional=True` accepts `None` (stored as JSON `null`, encoder bypassed). This write-side guard is the *only* behavior the flag drives — the read distinction (`V` vs `V | None`) is carried by the method, not the attribute (`[]` reads-or-raises, `.get` / `.pop` tolerate absence), and `required` is a derived bool property (the inverse of `optional`). `ANY` is the escape hatch: a recursive walker over JSON-native primitives + `Record` + `datetime` + `date` + `time` + `set` + `tuple` that raises `TypeError` on anything else. Dict-spread (`Record({**other, NEW_ATTR: value})`) is supported — `Record.keys()` yields `ViewAttribute` handles whose overridden `read_from` / `write_to` round-trip raw wire values through the standard Record paths, so the spread itself stays in the typed-`__init__` path. **Polymorphic dispatch over isinstance branches:** every `Record` dict-op (`__getitem__`, `__setitem__`, `__contains__`, `__delitem__`, `get`, `pop`) is a one-line delegation to a method on the Attribute (`read_from`, `write_to`, `present_in`, `delete_from`, `get_from`, `pop_from`) — new Attribute kinds (e.g. `ViewAttribute`, declared in `attribute.py` but not re-exported from the package) override the primitives and inherit the derived ops via default impls on the base. The hierarchy is sealed (`__init_subclass__` rejects subclasses outside `attribute.py`), so kind dispatch is exhaustive and cross-method invariants only need policing in that one module. `IDENTITY` codec lives in `codecs.py` for `ViewAttribute`'s wire-form pass-through but is similarly non-exported. Pickle reconstructs via `__reduce__` = `(cls.wrap, (raw,))`, landing back in `wrap`; `copy.copy`/`copy.deepcopy` use the dedicated `__copy__`/`__deepcopy__` instead.
46
+ - **`types.py`**: `Config`, `Event`, `State` are `Record` subclasses + `IncomingMessage`/`Message` dataclasses (timestamps are `datetime`, not millis). `Stage` (non-exported) is the common base of `Extractor`/`Transformer`: it owns the `config_topics: list[str]` declaration (default `[]`) and the `enrich(config)` hook — one-time enrichment applied by the config machinery once per config record (bootstrap compacts first: once per surviving entry), never per poll tick or lookup. Partitioned `input_topics: list[str]` is declared by `Transformer` alone — a plain `Extractor` consumes only config topics.
47
+ - **`extractor.py`**: `Extractor` (ABC, `poll` is `@abstractmethod`) + `ExtractorRunner`. Async polling with `asyncio.gather` for concurrent config processing. Build via `Extractor.of(config_topics=..., poll=...)` (returns a private `_FunctionalExtractor` concrete subclass) or subclass directly for lifecycle needs. Config handling rides the config machinery (`configs.py`): the `group_id=None` consumer is assigned all partitions of every config topic, bootstrapped to the end offsets captured at startup (compacted by wire key, empty values are tombstones, `enrich` once per surviving entry), then drained non-blocking each poll cycle; the runner's per-key config entries are read back from the (already enriched) store. `poll(config, state)` yields `Message | State`; the runner only persists `State` when it differs from current. Yielding an empty/falsy `State` deletes the entry. **Re-entry contract** (pinned by test): for a given config, `poll()` is re-entered only after the previous invocation's messages were sent and the producer flushed — push sources that defer upstream ACKs until Kafka durability depend on this ordering. **Wakeup**: `Extractor.wakeup` (an optional `asyncio.Event`, default `None`) lets a push-driven stage end the runner's between-cycles wait early; `poll_interval_seconds` then degrades to the idle/config-drain cadence.
48
+ - **`transformer.py`**: `Transformer` (ABC, `transform` is `@abstractmethod`) + `TransformerRunner` + `Task` + `TaskRebalanceListener`. Build via `Transformer.of(input_topics=..., transform=...)` (returns a private `_FunctionalTransformer` concrete subclass) or subclass directly. Work is partitioned into per-input-partition **tasks**, each owning a transactional producer (static ID `{application_id}-{partition}` — EOS-v1 fencing) and a partition-scoped `ChangelogStateStore`. Exactly-once delivery: one Kafka transaction per task per `getmany()` batch covering that task's output messages, state changes (deduped to one final write per key), and offset commits; task transactions commit concurrently and independently. Within a batch, records are bucketed by *(task, state key)*; same-bucket records run serially (so each one sees the previous one's yielded state), and buckets run concurrently via `asyncio.gather` so I/O-bound `transform()` calls overlap. Cross-bucket ordering is not preserved. Within a bucket, records appear in `input_topics` order then Kafka offset order. Each `transform` call receives a defensive deepcopy of the running state so in-place mutation without a yield can't leak. Stateless transformers simply never yield `State`. Rebalances (eager only in aiokafka) tear down all tasks under the batch lock and rebuild assigned ones in the main loop (producer start = fencing point → changelog restore → resume); the listener records failures on `runner.fatal` because aiokafka swallows callback exceptions. A transformer may additionally declare `config_topics` and look entries up via `self.configs.get(wire_key)`: the runner bootstraps the store before the subscribe joins the group, injects `configs` onto the transformer before `__aenter__`, and drains updates once per loop iteration outside the batch lock — every record of a batch sees one consistent config snapshot, and rebalances never touch the instance-level store. Lookups are eventually consistent and NOT part of any task transaction (Kafka Streams' GlobalKTable caveat). `Transformer.of(...)` accepts `enrich` (machinery-applied, works without `self`); store lookups need a subclass.
49
+ - **`kafka.py`**: `parse_message()` (JSON → `Event`), `encode_json()` (`Record` → UTF-8 bytes), `restore_changelog()` (rebuilds state from a compacted topic, optionally restricted to a partition subset for task restore; reads to the end offsets captured at entry — the LSO under `read_committed` — instead of treating an empty poll as end-of-log; primes aiokafka's metadata cache via `consumer._client.set_topics()` when discovering partitions — no fully public API exists for this, integration tests lock down the coupling). `read_to_end()` (the assign/seek/read-until-captured-end loop), `is_tombstone()`, `decode_key()`/`decode_event()` are the shared building blocks of `restore_changelog` and the config machinery. Runners type-hint `aiokafka.AIOKafkaConsumer`/`AIOKafkaProducer` directly — no wrapper classes.
50
+ - **`configs.py`**: `ConfigStore` (exported) + `bootstrap_config_store` + `drain_config_updates` — Kafka Streams' **GlobalKTable** pattern, specialized to configuration. Config topics are read in full (`group_id=None`, all partitions) into ONE per-process latest-value store keyed by **wire key** — a single key namespace merged across all of a stage's config topics (a tombstone on any topic deletes the key). The source topics are their own changelog (must be compacted, should stay small — the store lives in RAM per instance), no offsets are committed, and everything is re-read on every startup. `Stage.enrich` is applied here, once per record (bootstrap: once per surviving entry); Kafka Streams forbids transforming records into global stores (KIP-813) because a checkpoint restore would bypass the transformation — Flechtwerk has no checkpoints, every boot re-reads through the same enrich path, so the store can't diverge. Values are kept as wire bytes and parsed lazily on `get()` — each call returns a fresh `Config`; malformed values decode to an empty `Config` with a warning so they hit the caller's validation instead of masquerading as missing. Tests seed via `ConfigStore.of({key: config})`.
51
+ - **`state.py`**: `StateStore` port + `RocksDBStateStore` and `ChangelogStateStore` adapters. Storage primitive is `put_bytes(key, raw)`; `put(key, state)` is `serialize`-then-`put_bytes`. `deserialize` is JSON-only — undecodable bytes are an unrecoverable data error (crash, then reset the affected state). `ChangelogStateStore` writes the same wire bytes to both inner store and Kafka; its optional `partition` pins changelog writes to one explicit partition (transformer tasks) while `None` (extractors) uses key hashing; the producer is shared with the owning runner/task via DI so transformer `put()` calls join the task's open transaction. `restore` replays the changelog (optionally one partition) through `inner.put_bytes` (raw bytes pass through; deserialization is deferred to first `get()` per key). `get()` returns a protective copy. `partition_counts()` reads per-topic partition counts for the startup co-partitioning validation. `rocksdict` is imported lazily on first RocksDB open — stateless stages never load it.
52
+ - **`module.py`**: `Flechtwerk` — the narrow application-facing handle (an ABC exposing only `of(...)` / `run()` / the async context manager), plus the private `_FlechtwerkModule` reactor-di DI container that lazily creates and shares all Kafka resources. `Flechtwerk.of(...)` returns a `_FlechtwerkModule` typed as `Flechtwerk`, so an application never sees the wiring (same idiom as `Extractor.of` / `Transformer.of`). To embed as a child of a larger reactor-di module, declare `make[Flechtwerk, _FlechtwerkModule]` and let the parent wire every `lookup` field. The `Flechtwerk` base stays annotation-free — `@module` walks `get_type_hints` over the MRO, so any annotated attribute on it would leak onto the public type; `test_public_handle_exposes_no_container_internals` pins this. Extractors get a single shared non-transactional producer and a global `ChangelogStateStore`; transformers get factory methods (`create_task_producer`, `create_task_store`, `create_restore_consumer`) from which the runner builds per-task resources. The runners consume the caller's stage through the `configured_stage` factory, which completes an MQTT-sourced stage with the broker settings and the observer — the module stays pure factory + mediation, no behavioral methods. All consumers run `read_committed`; the transformer input consumer uses the Range assignor. Transformers with config topics get a dedicated group-less `config_consumer` (client id suffix `-config`); extractors reuse their main consumer (already group-less); both get the `config_store`. Async context manager — Prometheus scrape HTTP server is the outermost layer; startup runs `validate_topics` (transformer needs ≥1 input topic, extractor needs ≥1 config topic, the lists must be disjoint), validates input/changelog partition counts for transformers (config topics are exempt — their counts are unconstrained), and existence-checks config topics (a missing one must fail fast: the assign-based bootstrap would never discover a topic created later). `compression_type` defaults to `"zstd"` (JSON compresses ~13×), which is why the package depends on `aiokafka[zstd]`.
53
+ - **`mqtt.py`**: the MQTT→Kafka bridge for push-driven extractors — the only framework module importing paho-mqtt eagerly (`module.py` reaches it only via a lazy import; the dependency ships as the `flechtwerk[mqtt]` extra). `MqttConnection` owns ONE paho client per process, driven entirely by the asyncio event loop via socket callbacks (no threads); `clean_session=False` + a stable client_id (the module-wide `client_id`) + `manual_ack=True` give at-least-once for QoS ≥ 1 publishers. Inbound messages route by topic filter to per-topic `MqttSubscription` views (buffer + pending-ACK list each); unmatched QoS 0 messages are warned and dropped, unmatched QoS ≥ 1 messages are **held un-ACKed** and re-routed the moment a matching subscription registers — the persistent session replays its backlog right after CONNACK, *before* the Kafka config bootstrap has subscribed anything, so ACKing (or dropping) there would silently destroy the very backlog `clean_session=False` protects. Held messages for a genuinely stale session subscription stall the session's inflight window — the documented cost of deferring unsubscribe-on-config-removal (remediation: a fresh `client_id` or broker-side session cleanup). No in-process reconnect: an unexpected disconnect surfaces as a `ConnectionError` from `drain()` once the buffer empties → crash → orchestrator restart. `MqttExtractor` opens the connection eagerly in `__aenter__`, sets the runner `wakeup` event (`on_message` fires it after buffering → sub-second delivery), and owns a template `poll()`: ACK-previous-batch (safe by the runner's re-entry contract) → `drain(drain_limit)` → per message `relay(config, topic, payload)` with JSON decoding. The `relay` hook returns a `Message` (forward + ACK with next batch), `None` (drop + immediate ACK), or raises (poison-drop: warn + ACK + counter — crashing would redeliver the poison forever at QoS 1). Build via `MqttExtractor.of(config_topics=..., relay=...)` or subclass; the `topic` config attribute (`flechtwerk.mqtt.TOPIC`) is framework-owned (one config record = one MQTT topic filter = one subscription). Sources that don't fit (stateful, 1:N, non-JSON) override `poll()` — the connection layer works without the template. Broker settings are injected via `Flechtwerk.of(mqtt=MqttBrokerConfig(...))`; the dataclass lives in `module.py` (reactor-di resolves annotations at decoration time, and the container must stay paho-free).
54
+ - **`metrics.py` / `observer.py`**: Runners emit observer events (`message_in`, `message_out`, `transaction_committed`, `active_configs`, `config_message_in`, `config_store_entries` — the "did my config arrive?" gauge — `config_store_restored`, `state_restored`, `tasks_assigned`, `dispatch_scope`, `batch_scope`, `poll_cycle_scope`; MQTT: `mqtt_connected`, `mqtt_disconnected`, `mqtt_message_in`, `mqtt_message_dropped` with reason `filtered`/`poison`, `mqtt_buffered` — the MQTT `topic` label is always the subscription filter, bounded cardinality); `PrometheusObserver` translates these into prometheus-client metrics. Label *names* are caller-provided via `metrics_labels` — the framework itself is application-agnostic and knows nothing about what the labels are called. `metrics_port == 0` disables Prometheus and uses the no-op `Observer`.
55
+ - **`testing.py`**: `FakeKafkaConsumer`/`FakeKafkaProducer` (duck-typed aiokafka subset), `make_record()` factory (real `aiokafka.ConsumerRecord` instances), `RecordingObserver`, `InMemoryStateStore`, and MQTT doubles (`FakeMqttConnection`/`FakeMqttSubscription` with an `acked` record, `make_mqtt_message()`; paho imports deferred so importing `testing` never loads paho). Pre-set `extractor.connection = FakeMqttConnection()` to test relays without a broker.
56
+
57
+ The framework has no CLI, no module-level `os.getenv`, and no `load_dotenv` — all configuration is injected by the caller.
58
+
59
+ ## Key Architectural Patterns
60
+
61
+ **Stateful Processing**: State lives in ephemeral RocksDB instances backed by a compacted Kafka changelog topic (Kafka Streams pattern — no PVC, pods are ephemeral). Generators yield `State` to persist; the runner only writes when state differs from current. Yielding empty/falsy `State` deletes the entry (Kafka tombstone). Stateless stages never open a RocksDB file. For transformers, state identity is *(input partition, extract_key)*: each task owns its own RocksDB store, writes its changelog entries to its own changelog partition (explicit-partition produce, not key hashing), and restores exactly that partition on assignment. The framework makes no assumptions about what `extract_key()` returns — a key produced from several partitions yields independent per-task entries (with the default `extract_key = msg.key`, Kafka's key partitioning makes this indistinguishable from a global key). Extractors keep a single global store restored once at startup.
62
+
63
+ **Co-Partitioning Trap**: If one logical state entry must see records from *several* input topics, those topics must be co-partitioned by key — same key bytes, same partitioner, same partition count. Only the partition count is validated at startup; key/partitioner alignment cannot be checked and is the application's responsibility (exactly as in Kafka Streams). Get it wrong and the same `extract_key` arrives on different partition numbers, yielding independent state shards owned by different tasks — possibly on different instances. This is a silent split, not an error. Kafka Streams' DSL protects against the related case (key changed inside the topology) by auto-inserting a repartition topic; Flechtwerk is a Processor-API-level framework and has no equivalent — a mid-pipeline key change requires an explicit intermediate topic, i.e. another transformer hop. Output keys and changelog keys impose no constraints: output partitioning is decoupled from task identity, and changelog placement is explicit-partition, ignoring keys entirely.
64
+
65
+ **The escape hatch for config lookups is a config topic**: when one side of the "join" is a config table rather than a keyed stream, declare it in `config_topics` instead of `input_topics` (Kafka Streams' GlobalKTable, specialized to configuration). Every instance reads it in full into the per-process `ConfigStore`; lookups go through `self.configs.get(wire_key)`; partition placement and count are irrelevant, so any producer — Kafka UI included — can write to it. The trade: the source topic must be compacted and stay small, the wire key is authoritative (tombstones carry no body), and lookups are eventually consistent, outside the task transaction.
66
+
67
+ **Exactly-Once Delivery & Load Balancing**: Transformer work is split into **tasks** — one per input partition number, spanning that partition of every input topic (the consumer uses the Range assignor, which co-assigns same-numbered partitions). Each task owns a transactional producer with the static transactional ID `{application_id}-{partition}`; one Kafka transaction per task per batch covers that task's output messages, state changelog writes (the task's `ChangelogStateStore` shares its producer), and offset commits. Multiple instances are safe: when a partition moves, the new owner's `InitProducerId` fences the previous owner's producer and aborts its in-flight transaction (Kafka Streams EOS-v1 — aiokafka has no KIP-447 generation fencing), and state is re-restored from the changelog's last stable offset before processing resumes. On rebalance, all tasks are torn down and rebuilt — never retained, since a missed rebalance would make retained producers/stores silently stale. All framework consumers run `read_committed`. Constraints: all input topics of a transformer must have equal partition counts (validated at startup, matching changelog created); the partition count is frozen once state exists (repartitioning requires a state migration); instances beyond the partition count sit idle.
68
+
69
+ **Extractors Are Single-Instance**: The multi-instance safety story above is transformer-only. Nothing fences concurrent extractor instances: each one reads *all* configs (`group_id=None`, no partition assignment), polls every external API redundantly, and writes the same state keys to a key-hashed changelog — last writer wins, so a slow instance can overwrite an advanced cursor with a stale one (silent re-import, not just duplicates). Extractor output is deliberately non-transactional (at-least-once — polling an external API can't be atomic with anything), so the transformer's fencing primitive doesn't exist here. Run exactly one replica per extractor; orchestrator restart latency is invisible at poll-interval timescales, so replicas buy no availability either. Sharding extractors along config-topic partitions was considered and rejected: extractor concurrency is already handled by `asyncio.gather` on one event loop, throughput is bounded by external API rate limits, and a single config's epoch backfill is a sequential cursor walk that partitioning can't split. The signal that would reopen this decision is a poll *cycle* duration approaching the poll *interval* (watch `poll_cycle_scope`); the cheap fix at that point is config-level sharding across deployments, not framework machinery.
70
+
71
+ **"Let It Crash" Error Strategy**: No framework-level retry logic. Errors propagate; recovery is infrastructure: orchestrator restarts (e.g. Kubernetes `CrashLoopBackOff`), changelog replay restores state, transformer transactions catch any duplicates from partial writes. The key distinction is recoverable vs non-recoverable: only use try/except when the catch block can actually *remedy* the problem (e.g. refresh an expired token, skip a 400 on an endpoint that doesn't exist for this tenant). For transient errors like timeouts or 5xx, crash — sleeping and retrying in-process is reimplementing `CrashLoopBackOff` poorly. Never catch-and-skip data errors (silent data loss).
72
+
73
+ ## Invariant: config topics never participate in a Kafka transaction
74
+
75
+ In a transformer, config topics must have no contact with any task
76
+ transaction. This holds by construction, through three independent
77
+ mechanisms — keep all of them intact:
78
+
79
+ - **Separate, group-less consumer.** A transformer's config topics are read
80
+ by a dedicated `config_consumer` with `group_id=None` (`module.py`). No
81
+ consumer group means no committed offsets — config-topic offsets can never
82
+ appear in `send_offsets_to_transaction`. The offsets that DO enter a task
83
+ transaction are built exclusively from the main consumer's input-topic
84
+ batch, and `validate_topics` keeps `config_topics` disjoint from
85
+ `input_topics`, closing that path too.
86
+ - **Updates land outside the transaction boundary.** `check_config_updates`
87
+ runs once per loop iteration, outside the batch lock, and only mutates the
88
+ in-memory `ConfigStore` — no task, producer, or transaction involved. The
89
+ fetch-then-drain-then-process sequencing gives every record of a batch one
90
+ consistent config snapshot; that is a scheduling courtesy, not
91
+ transactional coupling.
92
+ - **No write path through the task producers.** The framework never produces
93
+ to a config topic; the store is fed only by `bootstrap_config_store` and
94
+ `drain_config_updates`.
95
+
96
+ Lookups via `self.configs.get(...)` are therefore eventually consistent —
97
+ Kafka Streams' GlobalKTable caveat, stated on `Transformer.configs` and in
98
+ `configs.py`.
99
+
100
+ ### Why the config consumer still runs read_committed
101
+
102
+ The isolation level is a consumption-side filter; it does not enroll config
103
+ reads in any transaction, so it cannot violate the invariant above. For the
104
+ normal case — non-transactional producers (ops tooling, Kafka UI) writing
105
+ config — it makes no difference at all: records are visible immediately
106
+ either way. It matters only when a *transactional* producer writes to a
107
+ config topic (nothing forbids a transformer emitting an output `Message`
108
+ onto one): `read_uncommitted` would apply records from aborted transactions
109
+ to the store — and a startup bootstrap would compact them in until the next
110
+ boot — while `read_committed` merely delays visibility until commit, which
111
+ the eventually-consistent contract already absorbs. `read_committed` also
112
+ gives `bootstrap_config_store` / `read_to_end` a well-defined end offset
113
+ (the LSO). Switching to `read_uncommitted` buys nothing and opens the
114
+ aborted-write hole — keep `read_committed`, matching every other framework
115
+ consumer.
116
+
117
+ ## Invariant: the extractor runner's re-entry contract
118
+
119
+ For any given config, `ExtractorRunner` re-enters `poll()` only after the
120
+ previous invocation's yielded messages were sent to Kafka and the producer
121
+ was flushed (`poll_one` awaits `send_batch` before returning; a send failure
122
+ crashes the process). The MQTT template's ACK-the-previous-batch-at-the-top-
123
+ of-the-next-poll pattern is correct *only* because of this ordering — do not
124
+ weaken it. `test_reentry_contract_flush_strictly_precedes_next_poll` pins it.
125
+
126
+ ## Invariant: paho-mqtt stays confined to flechtwerk.mqtt
127
+
128
+ - `flechtwerk.mqtt` is the only framework module that imports paho eagerly.
129
+ `module.py` must never import `.mqtt` at module level — the lazy import
130
+ inside the `configured_stage` factory is both what keeps `mqtt → module`
131
+ acyclic and what makes the `flechtwerk[mqtt]` optional extra work (an
132
+ application that never configures MQTT never loads paho).
133
+ `testing.py`'s MQTT doubles defer their paho imports for the same reason.
134
+ - `MqttBrokerConfig` lives in `module.py`, not `mqtt.py`: reactor-di's
135
+ `@module` decorator resolves all class annotations at decoration time, so
136
+ the `mqtt: lookup[MqttBrokerConfig | None]` slot needs a runtime-importable,
137
+ paho-free name.
138
+ - The framework reads no environment and does no identity defaulting: broker
139
+ settings arrive fully resolved through `Flechtwerk.of(mqtt=...)` (or
140
+ parent-module wiring), and the session identity is the module-wide
141
+ `client_id` (injected onto the stage by `configured_stage`; applications
142
+ typically pass a per-instance stable identity — e.g. the pod name in
143
+ Kubernetes). `MqttExtractor` rejects an empty `client_id` at startup
144
+ (MQTT 3.1.1 forbids one with a persistent session).
145
+
146
+ ## Boundary rule: which transport adapters belong in the framework
147
+
148
+ Flechtwerk may own a transport adapter when its *correctness depends on runner
149
+ delivery semantics* — MQTT qualifies because manual-ACK-after-Kafka-durable
150
+ leans on the re-entry contract above. It must never own payload semantics,
151
+ source-specific parsing, or per-source config schemas (those stay in
152
+ application code); an adapter that would work identically as application code
153
+ stays application code. Outbound MQTT (Kafka→MQTT command publishing) is
154
+ explicitly out of scope for now: an MQTT publish can never join a Kafka
155
+ transaction, so any future sink is at-least-once by construction and needs
156
+ its own design. `MqttConnection` is deliberately direction-neutral so a sink
157
+ can ride the same connection later.
158
+
159
+ ## Known limitation: no subscription lifecycle for removed configs
160
+
161
+ Removing (tombstoning) or suspending a config deletes its runner entry, but
162
+ nothing unsubscribes the MQTT topic: the broker session keeps the
163
+ subscription, the in-process view keeps buffering, and — at QoS ≥ 1 — its
164
+ un-ACKed messages occupy the shared session's inflight window until every
165
+ slot is taken and the broker pauses delivery for ALL topics of this client.
166
+ Messages held for a genuinely stale session subscription (a topic subscribed
167
+ under this client_id in an earlier deployment and never unsubscribed) stall
168
+ the window the same way. This is a known, deliberately deferred limitation:
169
+ a correct fix needs a runner→stage config-removal hook, a broker
170
+ UNSUBSCRIBE, and a decision about un-ACKed buffered messages (they were
171
+ never written to Kafka). Until then: stop the publisher before removing a
172
+ config, and recover a wedged session with a fresh `client_id` (a new broker
173
+ session) or broker-side session cleanup.
174
+
175
+ ## graphify (optional, local)
176
+
177
+ [graphify](https://pypi.org/project/graphifyy/) builds a queryable knowledge graph of this codebase (god nodes, community structure, cross-file relationships). It is a per-developer convenience, not part of the project: `graphify-out/` is gitignored, nothing in the build or CI depends on it, and its Claude Code hooks live in the machine-local `.claude/settings.local.json`. Skip this section entirely if `graphify-out/graph.json` does not exist.
178
+
179
+ Initialization (once per clone, opt-in):
180
+
181
+ ```bash
182
+ uv tool install graphifyy # or: pip install graphifyy
183
+ ```
184
+
185
+ Then run `/graphify .` in a Claude Code session to build the graph, and optionally `graphify hook install` to add git post-commit/post-checkout hooks that rebuild it automatically (AST-only, no API cost).
186
+
187
+ Rules (only when graphify-out/graph.json exists):
188
+
189
+ - For codebase questions, first run `graphify query "<question>"`. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
190
+ - If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
191
+ - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
192
+ - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 b.sure GmbH
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.
@@ -0,0 +1,292 @@
1
+ Metadata-Version: 2.4
2
+ Name: flechtwerk
3
+ Version: 0.1.0
4
+ Summary: Truly async Python stream processing with real Kafka transactions for exactly-once delivery, and an MQTT→Kafka bridge that ACKs only after Kafka has the data
5
+ Project-URL: Homepage, https://github.com/bsure-analytics/flechtwerk
6
+ Project-URL: Issues, https://github.com/bsure-analytics/flechtwerk/issues
7
+ Project-URL: Repository, https://github.com/bsure-analytics/flechtwerk
8
+ Author: Christian Schlichtherle, Viktor Penev
9
+ Maintainer: b.sure GmbH
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: aiokafka,asyncio,exactly-once,kafka,kafka-streams,mqtt,stream-processing
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: System :: Distributed Computing
21
+ Requires-Python: >=3.14
22
+ Requires-Dist: aiokafka[zstd]~=0.13
23
+ Requires-Dist: prometheus-client~=0.21
24
+ Requires-Dist: reactor-di~=0.5
25
+ Requires-Dist: rocksdict~=0.3
26
+ Provides-Extra: mqtt
27
+ Requires-Dist: paho-mqtt~=2.1; extra == 'mqtt'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # Flechtwerk
31
+
32
+ [![CI](https://github.com/bsure-analytics/flechtwerk/actions/workflows/ci.yaml/badge.svg)](https://github.com/bsure-analytics/flechtwerk/actions/workflows/ci.yaml)
33
+ [![PyPI version](https://img.shields.io/pypi/v/flechtwerk.svg)](https://pypi.org/project/flechtwerk/)
34
+ [![Python versions](https://img.shields.io/pypi/pyversions/flechtwerk.svg)](https://pypi.org/project/flechtwerk/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
36
+
37
+ Truly async Python stream processing with real Kafka transactions for exactly-once delivery, and an MQTT→Kafka bridge that ACKs only after Kafka has the data
38
+
39
+ ## What it is
40
+
41
+ Flechtwerk is a small async stream processing framework for Kafka. It takes the operational design that Kafka Streams nailed a decade ago — consumer groups for partition assignment, compacted changelog topics as the durable state of record, Kafka transactions tying state writes, output messages, and offset commits into a single atomic unit — and ports it to modern async Python.
42
+
43
+ If you've run Kafka Streams in production, the model is immediately familiar: stateful operators backed by RocksDB, recovery via changelog replay, exactly-once delivery via transactions, ephemeral compute that can be killed and rescheduled freely because all durable state lives in Kafka.
44
+
45
+ ## Why it exists
46
+
47
+ Existing Python options each fail one of the constraints that matter for I/O-bound, transactional, multi-instance stream processing:
48
+
49
+ - **Faust**: stateful but RocksDB + multi-instance recovery is fragile, and "exactly-once" is idempotent-producer-plus-careful-offsets rather than real Kafka transactions spanning state and output.
50
+ - **Quix Streams**: pleasant API, but the core loop is synchronous — fatal for workloads driven by concurrent async I/O (HTTP polling, MQTT subscriptions, etc.).
51
+ - **Bytewax**: a Rust dataflow engine with Python bindings; excellent for CPU-bound partitioned dataflow, awkward for async I/O and heavier than the operational model needs.
52
+ - **Apache Beam (on Flink)**: the Python SDK runs in a separate worker process and shuttles data to JVM operators over gRPC via the Beam portability framework. Setup is a maze of portable runners and SDK harnesses; failures span two runtimes and produce errors that are hard to localize. If Flink is the right answer, Java or Scala is a saner way to reach it.
53
+
54
+ Flechtwerk assumes Python 3.14, `asyncio`, `aiokafka`, and `uvloop` are the right primitives and builds directly on them.
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install flechtwerk # or: uv add flechtwerk
60
+ pip install "flechtwerk[mqtt]" # with the MQTT→Kafka bridge (paho-mqtt)
61
+ ```
62
+
63
+ Python 3.14+. Runtime dependencies: `aiokafka[zstd]`, `prometheus-client`, `reactor-di`, and `rocksdict`. Run it on `uvloop` for best throughput — the framework works on stock `asyncio` (and therefore on Windows), but the event loop is the application's choice.
64
+
65
+ ## The API
66
+
67
+ The whole contract is two `yield` statements inside an async generator:
68
+
69
+ - `yield Message(...)` to emit an output record.
70
+ - `yield State(...)` to persist state for the current key.
71
+ - `yield <falsy State>` to tombstone the key.
72
+
73
+ That's it. There are no agents, no tables, no DSL, no `@app.topic` decorators, no fluent builders. An async generator is already the right shape — pull-based, backpressure-friendly, naturally composable — and every Python developer already knows how to read one.
74
+
75
+ ```python
76
+ from collections.abc import AsyncIterator
77
+
78
+ from flechtwerk import Event, IncomingMessage, Message, State, Transformer
79
+ from flechtwerk.attribute import Attribute, DATETIME, INT
80
+
81
+ SEEN = Attribute("seen", INT)
82
+ """How many events this key has produced so far."""
83
+ TIMESTAMP = Attribute("timestamp", DATETIME)
84
+ """When the event happened at the source."""
85
+
86
+
87
+ async def transform(msg: IncomingMessage, state: State) -> AsyncIterator[Message | State]:
88
+ seen = (state.get(SEEN) or 0) + 1
89
+ yield Message(key=msg.key, topic="my-output", value=Event({**msg.value, SEEN: seen}))
90
+ yield State({SEEN: seen, TIMESTAMP: msg.value[TIMESTAMP]})
91
+
92
+
93
+ stage = Transformer.of(input_topics=["my-input"], transform=transform)
94
+ ```
95
+
96
+ The typed `Attribute` handles indexing those records are explained in [Typed records, not bare dicts](#typed-records-not-bare-dicts) below.
97
+
98
+ An `Extractor` is the same two-yield contract driven from the other end: `poll(config, state)` runs once per config record per poll cycle, pulls from the external source, and uses `State` as its resume cursor:
99
+
100
+ ```python
101
+ from collections.abc import AsyncIterator
102
+ from datetime import datetime, timezone
103
+
104
+ from flechtwerk import Config, Event, Extractor, Message, State
105
+ from flechtwerk.attribute import Attribute, DATETIME, INT, STR
106
+
107
+ CYCLE = Attribute("cycle", INT)
108
+ """Resume cursor — stands in for whatever your source pages by."""
109
+ NAME = Attribute("name", STR)
110
+ POLLED_AT = Attribute("polled_at", DATETIME)
111
+
112
+
113
+ async def poll(config: Config, state: State) -> AsyncIterator[Message | State]:
114
+ cycle = (state.get(CYCLE) or 0) + 1 # your API call goes here
115
+ yield Message(
116
+ key=config[NAME],
117
+ topic="my-extract",
118
+ value=Event({CYCLE: cycle, POLLED_AT: datetime.now(timezone.utc)}),
119
+ )
120
+ yield State({CYCLE: cycle})
121
+
122
+
123
+ stage = Extractor.of(config_topics=["my-config"], poll=poll)
124
+ ```
125
+
126
+ `Extractor` and `Transformer` are ABCs. Use the `.of(...)` factory for stateless or simply-stateful stages, or subclass directly when you need lifecycle management (HTTP clients, dedup instances, etc.) via `__aenter__` / `__aexit__`. Stateless stages simply never yield `State` and never open a RocksDB file.
127
+
128
+ Running a stage is one call — all configuration is injected, nothing is read from the environment:
129
+
130
+ ```python
131
+ import asyncio
132
+
133
+ from flechtwerk import Flechtwerk
134
+
135
+
136
+ async def main() -> None:
137
+ await Flechtwerk.of(
138
+ application_id="my-transformer",
139
+ bootstrap_servers="localhost:9092",
140
+ client_id="my-transformer-0", # process identity: unique per instance, stable across restarts
141
+ poll_interval_seconds=60,
142
+ stage=stage, # from above
143
+ ).run()
144
+
145
+
146
+ if __name__ == "__main__":
147
+ asyncio.run(main())
148
+ ```
149
+
150
+ This plus one stage definition from above is the whole program — point it at any Kafka broker.
151
+
152
+ ### Typed records, not bare dicts
153
+
154
+ You met `Attribute` above. It exists because a stream processor lives on the JSON boundary: every input is a dict decoded from the wire, and every output and every state write goes back through `json.dumps`. Handled as bare dicts, that boundary leaks into everything — each read re-checks presence and re-parses timestamps, a `datetime` assigned three hops earlier blows up only when the record is finally serialized, and a field that silently became `null` surfaces as a `KeyError` in some consumer far from the code that dropped it.
155
+
156
+ The `flechtwerk.attribute` library moves all of that to the write site. Each field is declared exactly once, as a typed handle pairing a wire name with an explicit `Codec[V]`:
157
+
158
+ ```python
159
+ from datetime import datetime, timezone
160
+
161
+ from flechtwerk import Event
162
+ from flechtwerk.attribute import Attribute, DATETIME, LIST, STR
163
+
164
+ DEVICE = Attribute("device", STR)
165
+ LAST_SEEN = Attribute("last_seen", DATETIME)
166
+ TAGS = Attribute("tags", LIST(STR), optional=True)
167
+
168
+ event = Event({
169
+ DEVICE: "sensor-1",
170
+ LAST_SEEN: datetime(2026, 7, 12, 9, 30, tzinfo=timezone.utc),
171
+ TAGS: ["a", "b"],
172
+ })
173
+
174
+ event[LAST_SEEN] # datetime(2026, 7, 12, 9, 30, tzinfo=timezone.utc) — a real datetime
175
+ event.raw # {'device': 'sensor-1', 'last_seen': '2026-07-12T09:30:00Z', 'tags': ['a', 'b']}
176
+
177
+ # Both of these raise at the write site, not at serialization time:
178
+ # event[DEVICE] = 42 # expected str, got int
179
+ # event[DEVICE] = None # cannot assign None to required Attribute('device')
180
+ ```
181
+
182
+ `Event`, `State`, and `Config` are `Record` subclasses — dict-like containers indexed by these handles rather than string keys. (`Message` is a frozen dataclass envelope carrying a key, topic, `Event` value, and optional timestamp.) The codec runs on **every write**, so the underlying `.raw` payload stays JSON-native by construction: wire encoding is a straight `json.dumps(event.raw)`, decoding is a straight `Event.wrap(raw)`, and nothing in between ever needs to re-validate. A required attribute (the default) rejects `None` so a dropped value can't silently land as JSON `null`; declare fields where absence is legal with `optional=True`. The read distinction is carried by the method, not the declaration — `event[LAST_SEEN]` reads-or-raises, `event.get(TAGS)` tolerates absence and returns `V | None`.
183
+
184
+ Codecs compose: atoms (`STR`, `INT`, `BOOL`, `DATE`, `FLOAT`, `DATETIME`, `TIME`, `RECORD`, `ANY`) plus constructors (`LIST(V)`, `SET(V)`, `TUPLE(V)`, `DICT(V)`). And records spread like dicts — `Event({**event, LAST_SEEN: later})` — so enrichment never has to mutate its input.
185
+
186
+ The point isn't ceremony; it's that the boundary between "Python object graph" and "JSON on the wire" is enforced at assignment time, once per Attribute declaration, rather than re-derived on every serialize/deserialize.
187
+
188
+ ### Config topics — shared lookup tables
189
+
190
+ A stage declares two kinds of topics. `input_topics` (transformers only) are partitioned: their records drive `transform()` and define the task model. `config_topics` are read **in full by every instance** into one per-process `ConfigStore` keyed by wire key — Kafka Streams' GlobalKTable, specialized to configuration:
191
+
192
+ ```python
193
+ from collections.abc import AsyncIterator
194
+
195
+ from flechtwerk import Extractor, IncomingMessage, Message, State, Transformer
196
+
197
+
198
+ class MyExtractor(Extractor):
199
+ config_topics = ["my-config"] # an extractor's inputs ARE config topics
200
+ ... # plus your poll()
201
+
202
+
203
+ class RequestDriven(Transformer):
204
+ input_topics = ["my-requests"] # partitioned, keyed stream
205
+ config_topics = ["my-config"] # config table, joined by key
206
+
207
+ async def transform(self, msg: IncomingMessage, state: State) -> AsyncIterator[Message | State]:
208
+ config = self.configs.get(msg.key) # eventually consistent lookup
209
+ if config is None:
210
+ return # no config for this key (yet)
211
+ yield Message(key=msg.key, topic="my-results", value=msg.value)
212
+
213
+
214
+ stage = RequestDriven()
215
+ ```
216
+
217
+ For extractors this is not an extra mechanism but the baseline: config topics are the only Kafka input an extractor has. For transformers it is the escape hatch from the co-partitioning requirement: a config topic's partition placement and count are irrelevant, so any producer (Kafka UI included) can write configs without routing them to the "right" partition. The source topics are their own changelog — compacted, small, re-read on every startup — and lookups are eventually consistent, outside the task transaction (the GlobalKTable caveat).
218
+
219
+ `Stage.enrich(config)` hooks one-time derivation (e.g. an API lookup) into the config path: the framework applies it once per config record — never per poll tick or lookup — and both stage kinds inherit it. Kafka Streams forbids transforming records on their way into a global store (KIP-813) because a checkpoint-based restore would bypass the transformation; Flechtwerk re-reads the topics through the same enrich path on every startup, so the enriched store cannot diverge.
220
+
221
+ ### MQTT sources — push into the poll loop
222
+
223
+ `flechtwerk.mqtt` bridges a push-driven MQTT source into the extractor model out of the box. The framework owns everything protocol-shaped — one shared paho connection per process driven by the asyncio event loop (no threads), persistent sessions with a stable client id, manual-ACK at-least-once (a batch is ACKed to the broker only once it is provably durable in Kafka — at the top of the next poll, per the runner's re-entry contract), per-topic subscriptions fed by config records, an arrival wakeup so delivery latency is sub-second rather than poll-interval-bound, and Prometheus metrics. An application writes one pure function:
224
+
225
+ ```python
226
+ from datetime import datetime, timezone
227
+
228
+ from flechtwerk import Config, Event, Message
229
+ from flechtwerk.attribute import Attribute, DATETIME, RECORD, Record, STR
230
+ from flechtwerk.mqtt import MqttExtractor
231
+
232
+ DATA = Attribute("data", RECORD)
233
+ DEVICE_ID = Attribute("device_id", STR)
234
+ PROCESSING_TIME = Attribute("processing_time", DATETIME)
235
+
236
+
237
+ def relay(config: Config, topic: str, payload: Record) -> Message | None:
238
+ return Message(
239
+ key=payload[DEVICE_ID], # missing → the framework poison-drops
240
+ topic="my-extract",
241
+ value=Event({DATA: payload, PROCESSING_TIME: datetime.now(timezone.utc)}),
242
+ )
243
+
244
+
245
+ stage = MqttExtractor.of(config_topics=["my-config"], relay=relay)
246
+ ```
247
+
248
+ Return a `Message` to forward, `None` to drop (ACKed immediately), or raise to poison-drop (logged, ACKed, counted — never a crash loop on a broken payload). Sources that don't fit the one-in-at-most-one-out shape override `poll()`; the connection layer works without the template. Broker settings are injected via `Flechtwerk.of(mqtt=MqttBrokerConfig(...))`, and paho stays confined to `flechtwerk.mqtt` — `import flechtwerk` never loads it, and the dependency ships as the optional `flechtwerk[mqtt]` extra.
249
+
250
+ ## Operational model
251
+
252
+ - **Consumer groups** drive partition assignment and rebalancing — standard Kafka semantics, no custom coordination.
253
+ - **Changelog topics** (compacted) are the durable state of record. RocksDB is a local cache rebuilt by replay on startup. Pods are ephemeral; no PVCs.
254
+ - **Kafka transactions** span all output messages, all state changelog writes, and offset commits for a single processing batch. Transformer work is split into per-input-partition tasks; each task owns a transactional producer (static transactional ID — EOS-v1 fencing) shared with its changelog state store via DI — closing the gap that lets duplicates leak in frameworks that treat these as separate concerns.
255
+ - **Per-batch parallelism by state key.** Within a `getmany()` batch, records are bucketed by state key within each task. Buckets run concurrently via `asyncio.gather` so I/O-bound `transform` calls overlap, while records sharing a key run serially inside their bucket — each one sees the previous one's yielded state. Each `transform` call receives a defensive deepcopy of the running state, so in-place mutation without a `yield State(...)` can't leak. Cross-key ordering is not preserved; within a bucket, records appear in `input_topics` order then Kafka offset order.
256
+ - **"Let it crash"** error strategy. The line is recoverable vs non-recoverable, not transient vs persistent: catch only when the handler can actually *remedy* the problem (refresh an expired token, skip a 400 on an endpoint that doesn't exist for this tenant). Timeouts and 5xx crash — sleeping and retrying in-process is reimplementing `CrashLoopBackOff` poorly. Never catch-and-skip data errors; that's silent data loss. Recovery is infrastructure: Kubernetes restart, changelog replay, transaction abort.
257
+
258
+ ## Architecture
259
+
260
+ Hexagonal: ports and adapters. The core (`Extractor`, `Transformer`, their runners) depends only on abstract ports (`StateStore`, `Observer`, `aiokafka` consumer/producer interfaces). Adapters (`RocksDBStateStore`, `ChangelogStateStore`, `PrometheusObserver`, the `flechtwerk.mqtt` transport, `FakeKafkaConsumer`/`FakeKafkaProducer`/`FakeMqttConnection` for tests) plug in via DI through `reactor-di`. Transport adapters earn a place in the framework only when their correctness depends on runner delivery semantics — MQTT's manual-ACK protocol does; a plain HTTP poller does not.
261
+
262
+ The framework has no CLI, no module-level `os.getenv`, no `load_dotenv`, and no opinions about how applications are packaged or deployed. All configuration is injected by the caller.
263
+
264
+ ## Local development falls out for free
265
+
266
+ Because all durable state lives in Kafka — input topics, output topics, and compacted changelog topics — replicating production state on a developer laptop is just a matter of mirroring the relevant topics and committed consumer-group offsets into a local Kafka cluster. A locally-run stage then starts up against the local cluster, replays the changelog into a fresh RocksDB, and resumes processing exactly where its production counterpart left off.
267
+
268
+ No PVCs to snapshot, no opaque local state directories to copy, no VPN into a shared dev cluster. The same property that makes Kubernetes pods disposable in production makes them reproducible on a laptop. Frameworks that hide state in framework-managed local stores cannot offer this cleanly; the Kafka Streams model can, and Flechtwerk inherits it.
269
+
270
+ This is a property of the model, not a feature of the framework — building a small mirror script is straightforward and lives in application code.
271
+
272
+ ## What's deliberately not here
273
+
274
+ - **Event-time windowing with watermarks** — if you need this, use Flink. Flechtwerk targets the much larger class of problems where processing-time semantics are sufficient.
275
+ - **Stream-stream joins, complex topologies** — operators compose by writing to and reading from intermediate Kafka topics, not by chaining method calls.
276
+ - **Savepoints / state migrations** — recovery is changelog replay; schema evolution is the application's responsibility.
277
+ - **A DSL** — the stream-processing vocabulary is `Extractor`, `Transformer`, `Message`, `State`, `Event`, `Config`, `ConfigStore`, plus the typed-record handles of `flechtwerk.attribute`. That's it.
278
+
279
+ ## Development
280
+
281
+ ```bash
282
+ uv sync # venv + all dependencies
283
+ uv run pytest # unit tier — Docker-free
284
+ uv run pytest -m integration # integration tier — ephemeral Kafka/Mosquitto via testcontainers
285
+ uv run coverage run -m pytest -m "integration or not integration" && uv run coverage report
286
+ ```
287
+
288
+ Releases are cut by tagging: pushing a `vX.Y.Z` tag runs the test suite, builds the package with the tag-derived version (hatch-vcs), and publishes it to PyPI via trusted publishing.
289
+
290
+ ## Status
291
+
292
+ Flechtwerk was extracted from the data integration platform it was developed in, where it runs every stage in production. The API is small and settled in shape, but pre-1.0 — minor releases may still move things around. One design rule carries over from its origin as an embedded framework: framework code references no application paths, modules, or environment variables; all configuration is injected by the caller.