Skip to main content

krabka_client_streams/
lib.rs

1//! Kafka Streams-compatible client runtime for Krabka.
2//!
3//! `krabka-client-streams` has three layers. You can use each layer on its own:
4//!
5//! - [`StreamsBuilder`] builds JVM-compatible KStream/KTable topologies for
6//!   common application code: map/filter chains, aggregations, joins, windows,
7//!   suppression, global tables, and custom Processor-API nodes.
8//! - [`Topology`] is the typed Processor API for applications that want explicit
9//!   source, processor, sink, and state-store wiring.
10//! - [`KafkaStreams`] runs a built topology against a Kafka-compatible broker. It
11//!   joins a KIP-1071 streams group, processes the assigned input partitions,
12//!   produces sink records, restores changelog-backed stores, and serves local
13//!   interactive queries.
14//!
15//! For broker-free tests, [`TopologyTestDriver`] runs the same built topology
16//! in process. The driver is the fastest way to exercise business logic and state
17//! stores before you run them with [`KafkaStreams`].
18//!
19//! ## Quick start
20//!
21//! ```no_run
22//! use std::time::Duration;
23//!
24//! use krabka_client_streams::{NodeHandle, StreamsEvent, StreamsMembership, Topology};
25//!
26//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
27//! let mut topo = Topology::new();
28//! let src: NodeHandle<String, String> = topo.add_source("src", ["input-topic"]);
29//! topo.add_sink("snk", "output-topic", [&src]);
30//! let built = topo.build("my-application-id")?;
31//!
32//! let mut membership = StreamsMembership::builder()
33//!     .bootstrap("localhost:9092")
34//!     .group_id("my-application-id")
35//!     .topology(std::sync::Arc::new(built))
36//!     .build()
37//!     .await?;
38//!
39//! loop {
40//!     match membership.next_event().await? {
41//!         StreamsEvent::Assigned(a) => {
42//!             for task in &a.active {
43//!                 println!(
44//!                     "active task {} → {:?}",
45//!                     task.subtopology_id, task.source_topic_partitions
46//!                 );
47//!             }
48//!         }
49//!         StreamsEvent::NotReady(statuses) => println!("not ready: {statuses:?}"),
50//!         StreamsEvent::Fenced => println!("rejoined after fence"),
51//!     }
52//! }
53//! # }
54//! ```
55//! ## Processor API
56//!
57//! Define a typed topology, then test it with the broker-free [`TopologyTestDriver`]:
58//!
59//! ```
60//! use krabka_client_streams::{
61//!     NodeHandle, Record, StringSerde, Topology, TopologyTestDriver, impl_processor,
62//! };
63//!
64//! struct Upper;
65//! impl_processor! {
66//!     impl Upper: (String, String) -> (String, String) {
67//!         async fn process(&mut self, ctx, r) {
68//!             ctx.forward(Record::new(r.key, r.value.to_uppercase(), r.timestamp));
69//!         }
70//!     }
71//! }
72//!
73//! let mut topo = Topology::new();
74//! let src: NodeHandle<String, String> = topo.add_source("src", ["in"]);
75//! let up = topo.add_processor("up", || Upper, [&src]);
76//! topo.add_sink("out", "out", [&up]);
77//! let built = topo.build("my-app").unwrap();
78//!
79//! let mut driver = TopologyTestDriver::new(&built).unwrap();
80//! driver.pipe_input(
81//!     "in",
82//!     (StringSerde, StringSerde),
83//!     Some("k".to_string()),
84//!     "hello".to_string(),
85//!     0,
86//! );
87//! assert_eq!(
88//!     driver.read_output("out", (StringSerde, StringSerde)),
89//!     Some((Some("k".to_string()), "HELLO".to_string())),
90//! );
91//! ```
92//!
93//! You wire nodes by handle, not by string name, so a mis-typed edge is a
94//! **compile error**, not a `build()`-time failure:
95//!
96//! ```compile_fail
97//! use krabka_client_streams::{NodeHandle, Topology};
98//!
99//! let mut topo = Topology::new();
100//! // `src` produces Record<String, String>:
101//! let src: NodeHandle<String, String> = topo.add_source("src", ["in"]);
102//! // but this sink expects Record<String, i64> — won't compile:
103//! topo.add_sink::<String, i64>("out", "out", [&src]);
104//! ```
105//!
106//! ## State stores
107//!
108//! Processors can persist and restore keyed state with a named [`KeyValueStore`].
109//! Attach the store to the topology with `add_state_store`. Read and write it
110//! inside `process` with [`ProcessorContext::get_state_store`].
111//!
112//! ```
113//! use krabka_client_streams::{
114//!     I64Serde, NodeHandle, Record, StringSerde, Topology, TopologyTestDriver, impl_processor,
115//! };
116//!
117//! struct Counter;
118//! impl_processor! {
119//!     impl Counter: (String, String) -> (String, i64) {
120//!         async fn process(&mut self, ctx, r) {
121//!             let n = {
122//!                 let s = ctx.get_state_store::<String, i64>("counts").unwrap();
123//!                 let n = s.get(&r.value).await.unwrap_or(0) + 1;
124//!                 s.put(r.value.clone(), n).await;
125//!                 n
126//!             };
127//!             ctx.forward(Record::new(Some(r.value), n, r.timestamp));
128//!         }
129//!     }
130//! }
131//!
132//! let mut topo = Topology::new();
133//! let src: NodeHandle<String, String> = topo.add_source("src", ["in"]);
134//! let c = topo.add_processor("c", || Counter, [&src]);
135//! topo.add_state_store("counts", StringSerde, I64Serde, [c.name()]);
136//! topo.add_sink("out", "out", [&c]);
137//! let built = topo.build("app").unwrap();
138//!
139//! let mut driver = TopologyTestDriver::new(&built).unwrap();
140//! driver.pipe_input("in", (StringSerde, StringSerde), None, "a".to_string(), 0);
141//! driver.pipe_input("in", (StringSerde, StringSerde), None, "a".to_string(), 1);
142//! assert_eq!(
143//!     driver.read_output("out", (StringSerde, I64Serde)),
144//!     Some((Some("a".to_string()), 1_i64)),
145//! );
146//! assert_eq!(
147//!     driver.read_output("out", (StringSerde, I64Serde)),
148//!     Some((Some("a".to_string()), 2_i64)),
149//! );
150//! assert_eq!(
151//!     driver.store_get::<String, i64>("counts", &"a".to_string()),
152//!     Some(2_i64)
153//! );
154//! ```
155//!
156//! ## DSL (KStream/KTable)
157//!
158//! [`StreamsBuilder`] is the high-level DSL entry point. It wires a topology
159//! from source streams through stateless transforms, aggregations, and sinks,
160//! and you write no explicit [`Processor`] implementations. The resulting
161//! [`BuiltTopology`] is interchangeable with the Processor-API variant. Run it
162//! with [`TopologyTestDriver`] for broker-free tests, or with [`KafkaStreams`]
163//! in production.
164//!
165//! A [`KTable`] is internally a *change stream*. Each record carries a
166//! `Change { old_value, new_value }`, and `filter` emits tombstones. When a row's
167//! key no longer matches, the downstream side deletes that row with
168//! `new_value = None`. [`KStream::to_table`] materializes a stream into a
169//! [`KTable`] backed by a named [`Materialized`] store. [`KTable::to_stream`]
170//! forwards update records and drops tombstones from the output stream.
171//!
172//! [`KStream::join_table`] and [`KStream::left_join_table`] join a stream against
173//! a **materialized** `KTable`. The stream side drives the join, and each record
174//! looks up the table store by key. An inner join emits only when the table holds
175//! a matching entry. A left join always emits, and it supplies `None` as the
176//! table value when the entry is absent.
177//!
178//! The stream must be **copartitioned** with the table, which means the same key
179//! serde and the same partition count. Call `.repartition(..)` on a key-changing
180//! stream before you join it, because the join itself inserts no implicit
181//! repartition. The plain [`KStream::join`] and [`KStream::left_join`] names are
182//! the windowed *stream-stream* join below. Rust cannot overload by argument type
183//! as the JVM does.
184//!
185//! [`KTable::join`], [`KTable::left_join`], and [`KTable::outer_join`] join two
186//! **materialized** `KTables`. A change on *either* side recomputes the join,
187//! which is different from the stream-table join. The changed side re-reads the
188//! other side's current value from its store and forwards a `Change`. That
189//! `Change` is a tombstone when the joined row no longer exists.
190//!
191//! Inner emits only when both sides hold a value. Left emits whenever the left
192//! side is present, and outer emits whenever either side is present. The DSL
193//! declares the two source topics as a **copartition group**. The result is an
194//! unmaterialized `KTable` with no result store and no changelog. Materialize a
195//! downstream op to persist it.
196//!
197//! ## Foreign-key joins
198//!
199//! [`KTable::join_on_foreign_key`] and [`KTable::left_join_on_foreign_key`]
200//! (KIP-213) join two `KTable`s on a **foreign key** instead of the primary key.
201//! For each left row, an `fk_extractor(&leftValue)` selects the foreign key, and
202//! that foreign key looks up a row in the right table. The relationship is
203//! **many-to-one**: many left rows can reference the same right row.
204//!
205//! A change on *either* side re-evaluates every affected pair. A left-value
206//! change re-selects the foreign key. A right-row change re-emits for every left
207//! row that currently subscribes to that foreign key. **Inner** emits
208//! `joiner(&left, &right)` only when the foreign row exists, and a foreign key
209//! with no match retracts with a tombstone. **Left** emits for every left row,
210//! and it supplies `None` for the foreign value on a miss.
211//!
212//! Both input tables must be **materialized source tables** built with
213//! [`StreamsBuilder::table`], because the join reads each side's store and
214//! serdes. The result is an **unmaterialized** `KTable` with no result store and
215//! no changelog. Materialize a downstream op to persist it.
216//!
217//! The foreign key differs from the primary key, so the join cannot be
218//! copartitioned directly. It lowers to the KIP-213 two-subtopology graph: a
219//! *subscription registration* repartition topic keyed by foreign key, a
220//! *subscription response* repartition topic keyed back by primary key, and a
221//! subscription state store that tracks which primary keys subscribe to each
222//! foreign key. The DSL creates and copartitions all three automatically.
223//!
224//! ```no_run
225//! use krabka_client_streams::{StreamsBuilder, StringSerde};
226//!
227//! let builder = StreamsBuilder::new();
228//! // `a`: primaryKey -> foreignKey ("A"); `b`: foreignKey -> value ("X").
229//! let a = builder.table::<String, String>("a", "sa");
230//! let b = builder.table::<String, String>("b", "sb");
231//! a.join_on_foreign_key(
232//!     &b,
233//!     |left: &String| left.clone(), // foreign-key extractor
234//!     |left: &String, right: &String| format!("{left}{right}"), // joiner -> "AX"
235//!     StringSerde,                  // foreign-key serde
236//! )
237//! .to_stream()
238//! .to("out");
239//! drop(a);
240//! drop(b);
241//! let topology = builder.build("fk-app").unwrap();
242//! # let _ = topology;
243//! ```
244//!
245//! [`KGroupedStream::windowed_by`] turns a grouped stream into time-windowed
246//! aggregations. `windowed_by(TimeWindows::of_size(..))` followed by
247//! `count`/`reduce`/`aggregate` yields a [`KTable`]`<`[`Windowed`]`<K>, V>`.
248//! [`TimeWindows`] are tumbling with `of_size`, or hopping with
249//! `.advance_by(..)`. The aggregator adds each record to every window the record
250//! falls into, and it emits a result on **every update**. Add
251//! [`KTable::suppress`] with [`Suppressed::until_window_closes`] when the
252//! application wants one final result after the window closes.
253//!
254//! The windowed store is a [`Window`]-keyed store over the same pluggable
255//! backend. Its changelog is `compact,delete` with
256//! `retention.ms = size + grace + 1 day`. Read the windowed output with
257//! [`TimeWindowedSerde`]. The key carries the window start.
258//!
259//! [`KGroupedStream::windowed_by_session`] groups records into data-driven
260//! **session windows**. Records for a key form one session `[start, end]` while
261//! they stay within an inactivity [`SessionWindows`] gap. The terminal `count`,
262//! `reduce`, and `aggregate` operators yield a
263//! [`KTable`]`<`[`Windowed`]`<K>, V>`, and `aggregate` also takes a session
264//! merger. Each record merges every session within the gap into one
265//! `[minStart, maxEnd]` session. The operator then emits a tombstone for each
266//! merged-away session, plus the new merged session. This is the KIP session
267//! semantics, emit-on-update.
268//!
269//! The session store keys by `key‖end‖start`, and it is a third typed store over
270//! the pluggable backend. Read the output with [`SessionWindowedSerde`].
271//!
272//! [`KGroupedStream::windowed_by_sliding`] produces a
273//! [`SlidingWindowedKGroupedStream`] with `count`/`reduce`/`aggregate`. Sliding
274//! windows are **data-defined** inclusive windows of fixed size
275//! `time_difference`. A record at time `t` falls into every window
276//! `[ws, ws + time_difference]` with `ws ∈ [t - time_difference, t]`. There is no
277//! epoch alignment, which is different from tumbling and hopping windows. The
278//! aggregator finds the affected windows when it scans the window store, and it
279//! emits on update.
280//!
281//! The aggregator folds an out-of-order record within `time_difference + grace`
282//! into the windows the record belongs to. The output is a
283//! `KTable<Windowed<K>, _>`. It reuses the [`TimeWindowedSerde`] output-key
284//! layout, `key‖windowStart:8B-BE`.
285//!
286//! [`KTable::suppress`]`(`[`Suppressed`]`::until_window_closes(`[`BufferConfig`]`::unbounded()))`
287//! turns a windowed table's emit-on-update change-stream into **final results**.
288//! It buffers each window's updates. It then forwards the window's final value
289//! exactly once, when stream-time passes `window.end + grace`. The grace comes
290//! from the upstream windowed or session aggregation.
291//! [`Suppressed::until_time_limit`] is the rate-limiter variant for *any* table.
292//! It emits at most one update per key per wait in stream-time, and a newer
293//! record resets the timer.
294//!
295//! The buffer is a **registered, durable state store**: a time-ordered
296//! `SuppressBytesStore` keyed by the serialized record key. Logging is on by
297//! default, and the buffer then writes a **JVM-byte-exact** changelog. That
298//! changelog holds a `BufferValue` plus a `ProcessorRecordContext` value, in a
299//! plain `cleanup.policy=compact` topic named
300//! `app-KTABLE-SUPPRESS-STATE-STORE-<n>-changelog`. The buffer restores its
301//! records on restart through the same machinery as every other store, so a
302//! window that was still buffered re-emits on close after a restart.
303//!
304//! [`Suppressed::with_logging_disabled`] keeps the buffer in memory only, with no
305//! changelog topic. The serdes reach the store from the op that produces the
306//! table: the windowed or session aggregation, or [`StreamsBuilder::table`].
307//!
308//! [`BufferConfig`] bounds the buffer.
309//! [`BufferConfig::unbounded`]`().with_max_records(n)` and
310//! [`BufferConfig::with_max_bytes`]`(n)` cap it, where bytes is the sum of the
311//! serialized key and value. A buffer that exceeds a cap shuts the task down
312//! (`shutDownWhenFull`), which is the `until_window_closes` default. The eager
313//! configs `BufferConfig::max_records(n)`, [`BufferConfig::max_bytes`], and
314//! [`BufferConfig::emit_early_when_full`] instead evict and emit the oldest
315//! buffered record (`emitEarlyWhenFull`).
316//!
317//! [`KStream::join`], [`KStream::left_join`], and [`KStream::outer_join`] are the
318//! windowed **stream-stream** joins. Two streams join over a [`JoinWindows`] time
319//! window, and [`StreamJoined`] serdes configure them. Each side buffers its
320//! records in its own `retainDuplicates` window store, so two records at the same
321//! time both survive. A record from one side joins every record on the other side
322//! within `[t - before, t + after]`, and it emits `joiner(a, b)` at `max(ts)`.
323//!
324//! The two window-store changelogs use `cleanup.policy=delete` with
325//! `retention.ms = before + after + grace + 1 day`, and the two source topics
326//! form a copartition group. An inner join emits only on a match. **Left** and
327//! **outer** also emit the null-padded result for a record that finds no match,
328//! once that record's window has closed. This is the KIP-633 stream-time-driven
329//! emission, and there is no wall-clock throttle.
330//!
331//! Left and outer buffer the records that are not yet matched in a shared
332//! `KSTREAM-OUTERSHARED-` KV store with a compact changelog. They also rename
333//! their per-side processors to `KSTREAM-OUTERTHIS-` and `KSTREAM-OUTEROTHER-` to
334//! match the JVM. A key-changing stream must call `.repartition(..)` before it
335//! joins, as with the other joins.
336//!
337//! [`StreamsBuilder::global_table`] sources a [`GlobalKTable`], a
338//! **fully-replicated** lookup table. Every application instance reads *all*
339//! partitions of the source topic into one shared global store, so the source
340//! topic itself is the truth. There is **no copartitioning, no repartition, and
341//! no changelog**, and the runtime rebuilds the global store from the source at
342//! startup.
343//!
344//! The store is *invisible in the wire topology* and has no subtopology of its
345//! own. Its global source node still consumes a node-group index during the
346//! grouping step, so a `global_table` declared before `stream` shifts the stream
347//! subtopology id.
348//!
349//! [`KStream::join_global`] and [`KStream::left_join_global`] join a stream to the
350//! global table by a **per-record-derived key**.
351//! `key_mapper(&streamKey, &streamValue)` selects the global key, which may
352//! differ from the stream key. The join emits
353//! `joiner(&streamValue, &globalValue)` keyed by the *stream* key. An inner
354//! `join_global` skips a record on a store miss. A `left_join_global` always
355//! emits, and it supplies `None` for the global side.
356//!
357//! The store is fully replicated, so any record can look up any key on every
358//! instance. The runtime's global consumer **bootstraps** the store *before* any
359//! task starts to process records. The consumer drains every partition of the
360//! source topic to end-of-log, so the first joined record already sees the
361//! complete global table.
362//!
363//! [`KStream::process`] and [`KStream::process_values`] (KIP-820) drop a custom
364//! Processor-API node into a DSL pipeline. You write a [`Processor`] for
365//! `process`, or a [`FixedKeyProcessor`] for `process_values`, and it reads and
366//! writes state stores connected by name. Register the store first with
367//! [`StreamsBuilder::add_state_store`], which gives a [`KeyValueStore`] with a
368//! compact changelog. Then pass the store name to the `process` or
369//! `process_values` call that uses it. The builder attaches the named store to
370//! that node, and the store's `app-<store>-changelog` topic appears in the wire.
371//!
372//! `process` may rewrite the record key, so its result is **key-changing**, and a
373//! downstream `group_by_key` or join inserts a repartition. `process_values` is
374//! **fixed-key**: it can change the value but not the key. It carries the
375//! upstream key lineage and forces **no** repartition.
376//!
377//! That guarantee is structural. A [`FixedKeyProcessor`] only ever receives and
378//! forwards a [`FixedKeyRecord`]. The input fixes the key of that record, and
379//! [`FixedKeyRecord::with_value`] preserves the key. The context's only `forward`
380//! re-attaches that key, so the processor cannot emit a different one. An
381//! `add_state_store` store that no `process` or `process_values` connects is never
382//! instantiated, and it gets no changelog and no runtime store.
383//!
384//! ### Enriching a stream with a fully replicated table
385//!
386//! `GlobalKTable` is useful for reference data such as customer profiles,
387//! product catalogs, or fraud watchlists. Every app instance can then look up any
388//! key, and the stream needs no repartition:
389//!
390//! ```
391//! use krabka_client_streams::{StreamsBuilder, StringSerde};
392//!
393//! let b = StreamsBuilder::new();
394//! let customers = b.global_table::<String, String>("customers", "customers-by-id");
395//!
396//! b.stream::<String, String>(["orders"])
397//!     .left_join_global(
398//!         &customers,
399//!         |_order_id, order| order.split(':').next().unwrap_or("").to_string(),
400//!         |order, customer| format!("{order}|customer={}", customer.map_or("unknown", |v| v)),
401//!     )
402//!     .to("enriched-orders");
403//!
404//! drop(customers);
405//! let built = b.build("orders-enricher").unwrap();
406//! assert_eq!(built.list_source_topics(), vec!["orders".to_string()]);
407//! ```
408//!
409//! The same enrichment works with **Avro** payloads and compound types. Declare
410//! each type's default serde once. The DSL then reads and writes Confluent-framed
411//! records that it resolves against the schema registry, with no per-call serde
412//! wiring:
413//!
414//! ```
415//! use apache_avro::AvroSchema;
416//! use krabka_client_streams::{DefaultSerde, SchemaSerde, StreamsBuilder};
417//! use krabka_schema_serde::{
418//!     RegistryClient,
419//!     cache::{CacheConfig, SchemaCache},
420//!     format::avro::AvroSerde,
421//!     set_default_registry,
422//! };
423//! use serde::{Deserialize, Serialize};
424//!
425//! #[derive(Clone, Serialize, Deserialize, AvroSchema)]
426//! struct Order {
427//!     order_id: String,
428//!     customer_id: String,
429//!     amount_cents: i64,
430//! }
431//! #[derive(Clone, Serialize, Deserialize, AvroSchema)]
432//! struct Customer {
433//!     customer_id: String,
434//!     name: String,
435//!     tier: Tier,
436//!     region: String,
437//! }
438//! #[derive(Clone, Copy, Serialize, Deserialize, AvroSchema)]
439//! enum Tier {
440//!     Standard,
441//!     Gold,
442//!     Platinum,
443//! }
444//! #[derive(Clone, Serialize, Deserialize, AvroSchema)]
445//! struct EnrichedOrder {
446//!     order_id: String,
447//!     customer: String,
448//!     tier: Tier,
449//!     amount_cents: i64,
450//! }
451//!
452//! impl DefaultSerde for Order {
453//!     type Serde = SchemaSerde<Order, AvroSerde<Order>>;
454//! }
455//! impl DefaultSerde for Customer {
456//!     type Serde = SchemaSerde<Customer, AvroSerde<Customer>>;
457//! }
458//! impl DefaultSerde for EnrichedOrder {
459//!     type Serde = SchemaSerde<EnrichedOrder, AvroSerde<EnrichedOrder>>;
460//! }
461//!
462//! // Point the default serdes at a registry (not contacted until the app runs).
463//! set_default_registry(SchemaCache::new(
464//!     RegistryClient::new("http://localhost:8081"),
465//!     CacheConfig::default(),
466//! ));
467//!
468//! let b = StreamsBuilder::new();
469//! let customers = b.global_table::<String, Customer>("customers", "customers-by-id");
470//! b.stream::<String, Order>(["orders"])
471//!     .left_join_global(
472//!         &customers,
473//!         |_order_key, order| order.customer_id.clone(),
474//!         |order, customer| EnrichedOrder {
475//!             order_id: order.order_id.clone(),
476//!             customer: customer.map_or_else(|| "unknown".into(), |c| c.name.clone()),
477//!             tier: customer.map_or(Tier::Standard, |c| c.tier),
478//!             amount_cents: order.amount_cents,
479//!         },
480//!     )
481//!     .to("enriched-orders");
482//! drop(customers);
483//! let built = b.build("orders-enricher-avro").unwrap();
484//! assert_eq!(built.list_source_topics(), vec!["orders".to_string()]);
485//! ```
486//!
487//! ### Final windowed counts
488//!
489//! Windowed aggregations emit on every update by default. Add `suppress` when
490//! downstream systems should receive only the final value after the window grace
491//! has passed:
492//!
493//! ```
494//! use krabka_client_streams::{BufferConfig, StreamsBuilder, Suppressed, TimeWindows};
495//! use krabka_units::prelude::*;
496//!
497//! let b = StreamsBuilder::new();
498//! b.stream::<String, String>(["clicks"])
499//!     .group_by_key()
500//!     .windowed_by(TimeWindows::of_size(minutes(1)).grace(secs(10)))
501//!     .count("click-counts")
502//!     .suppress(Suppressed::until_window_closes(BufferConfig::unbounded()))
503//!     .to_stream()
504//!     .to("click-counts-final");
505//!
506//! let built = b.build("click-analytics").unwrap();
507//! assert_eq!(
508//!     built.list_sink_topics(),
509//!     vec!["click-counts-final".to_string()]
510//! );
511//! ```
512//!
513//! The same windowed aggregation over **Avro** orders builds a compound
514//! per-window revenue record. The aggregation state is itself an Avro record in
515//! the windowed store:
516//!
517//! ```
518//! use apache_avro::AvroSchema;
519//! use krabka_client_streams::{
520//!     BufferConfig, DefaultSerde, SchemaSerde, StreamsBuilder, Suppressed, TimeWindows,
521//! };
522//! use krabka_schema_serde::{
523//!     RegistryClient,
524//!     cache::{CacheConfig, SchemaCache},
525//!     format::avro::AvroSerde,
526//!     set_default_registry,
527//! };
528//! use krabka_units::prelude::*;
529//! use serde::{Deserialize, Serialize};
530//!
531//! #[derive(Clone, Serialize, Deserialize, AvroSchema)]
532//! struct Order {
533//!     order_id: String,
534//!     region: String,
535//!     amount_cents: i64,
536//! }
537//! #[derive(Clone, Serialize, Deserialize, AvroSchema)]
538//! struct Revenue {
539//!     order_count: i64,
540//!     gross_cents: i64,
541//! }
542//!
543//! impl DefaultSerde for Order {
544//!     type Serde = SchemaSerde<Order, AvroSerde<Order>>;
545//! }
546//! impl DefaultSerde for Revenue {
547//!     type Serde = SchemaSerde<Revenue, AvroSerde<Revenue>>;
548//! }
549//!
550//! set_default_registry(SchemaCache::new(
551//!     RegistryClient::new("http://localhost:8081"),
552//!     CacheConfig::default(),
553//! ));
554//!
555//! let b = StreamsBuilder::new();
556//! b.stream::<String, Order>(["orders"]) // keyed by region
557//!     .group_by_key()
558//!     .windowed_by(TimeWindows::of_size(minutes(1)).grace(secs(10)))
559//!     .aggregate(
560//!         || Revenue {
561//!             order_count: 0,
562//!             gross_cents: 0,
563//!         },
564//!         |_region, order, acc| Revenue {
565//!             order_count: acc.order_count + 1,
566//!             gross_cents: acc.gross_cents + order.amount_cents,
567//!         },
568//!         "revenue-by-window",
569//!     )
570//!     .suppress(Suppressed::until_window_closes(BufferConfig::unbounded()))
571//!     .to_stream()
572//!     .to("revenue-per-window");
573//! let built = b.build("revenue-analytics").unwrap();
574//! assert_eq!(
575//!     built.list_sink_topics(),
576//!     vec!["revenue-per-window".to_string()]
577//! );
578//! ```
579//!
580//! ```
581//! use krabka_client_streams::{I64Serde, StreamsBuilder, StringSerde, TopologyTestDriver};
582//!
583//! // Build a word-count topology: group by key, count, forward to "out".
584//! let b = StreamsBuilder::new();
585//! b.stream::<String, String>(["in"])
586//!     .group_by_key()
587//!     .count("counts")
588//!     .to_stream()
589//!     .to("out");
590//! let built = b.build("word-count").unwrap();
591//!
592//! // Drive it broker-free with TopologyTestDriver.
593//! let mut driver = TopologyTestDriver::new(&built).unwrap();
594//! for word in ["a", "a", "b"] {
595//!     driver.pipe_input(
596//!         "in",
597//!         (StringSerde, StringSerde),
598//!         Some(word.to_string()),
599//!         word.to_string(),
600//!         0,
601//!     );
602//! }
603//!
604//! // The stream output carries the running count per key.
605//! assert_eq!(
606//!     driver.read_output("out", (StringSerde, I64Serde)),
607//!     Some((Some("a".to_string()), 1)),
608//! );
609//! assert_eq!(
610//!     driver.read_output("out", (StringSerde, I64Serde)),
611//!     Some((Some("a".to_string()), 2)),
612//! );
613//! assert_eq!(
614//!     driver.read_output("out", (StringSerde, I64Serde)),
615//!     Some((Some("b".to_string()), 1)),
616//! );
617//!
618//! // The materialized store holds the final count per key.
619//! assert_eq!(
620//!     driver.store_get::<String, i64>("counts", &"a".to_string()),
621//!     Some(2)
622//! );
623//! assert_eq!(
624//!     driver.store_get::<String, i64>("counts", &"b".to_string()),
625//!     Some(1)
626//! );
627//! ```
628//!
629//! ### Applied: projecting Avro records
630//!
631//! A stateless projection over compound **Avro** types keeps the paid orders and
632//! bills each one into a summary. One Avro record per topic carries a nested
633//! `Vec` of line items, an `Option`, and an enum:
634//!
635//! ```
636//! use apache_avro::AvroSchema;
637//! use krabka_client_streams::{DefaultSerde, SchemaSerde, StreamsBuilder};
638//! use krabka_schema_serde::{
639//!     RegistryClient,
640//!     cache::{CacheConfig, SchemaCache},
641//!     format::avro::AvroSerde,
642//!     set_default_registry,
643//! };
644//! use serde::{Deserialize, Serialize};
645//!
646//! #[derive(Clone, Serialize, Deserialize, AvroSchema)]
647//! struct Order {
648//!     order_id: String,
649//!     status: OrderStatus,
650//!     lines: Vec<LineItem>,
651//!     coupon: Option<String>,
652//! }
653//! #[derive(Clone, Serialize, Deserialize, AvroSchema)]
654//! struct LineItem {
655//!     sku: String,
656//!     quantity: i32,
657//!     unit_price_cents: i64,
658//! }
659//! #[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, AvroSchema)]
660//! enum OrderStatus {
661//!     Placed,
662//!     Paid,
663//!     Shipped,
664//!     Cancelled,
665//! }
666//! #[derive(Clone, Serialize, Deserialize, AvroSchema)]
667//! struct OrderSummary {
668//!     order_id: String,
669//!     item_count: i64,
670//!     total_cents: i64,
671//! }
672//!
673//! impl DefaultSerde for Order {
674//!     type Serde = SchemaSerde<Order, AvroSerde<Order>>;
675//! }
676//! impl DefaultSerde for OrderSummary {
677//!     type Serde = SchemaSerde<OrderSummary, AvroSerde<OrderSummary>>;
678//! }
679//!
680//! set_default_registry(SchemaCache::new(
681//!     RegistryClient::new("http://localhost:8081"),
682//!     CacheConfig::default(),
683//! ));
684//!
685//! let b = StreamsBuilder::new();
686//! b.stream::<String, Order>(["orders"])
687//!     .filter(|_id, o| o.status == OrderStatus::Paid)
688//!     .map_values(|o: &Order| OrderSummary {
689//!         order_id: o.order_id.clone(),
690//!         item_count: i64::try_from(o.lines.len()).unwrap_or(i64::MAX),
691//!         total_cents: o
692//!             .lines
693//!             .iter()
694//!             .map(|l| i64::from(l.quantity) * l.unit_price_cents)
695//!             .sum(),
696//!     })
697//!     .to("order-summaries");
698//! let built = b.build("order-billing").unwrap();
699//! assert_eq!(
700//!     built.list_sink_topics(),
701//!     vec!["order-summaries".to_string()]
702//! );
703//! ```
704//!
705//! ## Punctuation (timers)
706//!
707//! A Processor-API node can register **punctuators**, which are periodic
708//! callbacks. Register them with
709//! [`ProcessorContext::schedule`]`(interval, `[`PunctuationType`]`, `[`Punctuator`]`)`,
710//! usually from `init`. A [`Punctuator`] is a trait object like [`Processor`]. On
711//! each fire it receives a `ProcessorContext` positioned at the node that
712//! scheduled it, so it can `forward(...)` records downstream and read and write
713//! state stores. Share mutable state with the owning processor through
714//! `Arc<Mutex<_>>`. `schedule` returns a [`Cancellable`], and `.cancel()` stops
715//! the schedule.
716//!
717//! Two clocks drive the firing:
718//!
719//! - [`PunctuationType::StreamTime`] uses the task's observed max record
720//!   timestamp. It is deterministic and advances as you pipe records.
721//! - [`PunctuationType::WallClockTime`] uses the system clock between polls. In
722//!   tests, [`TopologyTestDriver::advance_wall_clock_time`] drives it.
723//!
724//! Both clocks fire **at most once per driving action**, and they pass the
725//! **current** stream-time or wall-clock time to `punctuate`. A schedule that has
726//! fallen more than one interval behind resyncs ahead. It does not replay every
727//! missed boundary. A stream-time schedule first-fires on the first record. A
728//! wall-clock schedule first-fires one interval after you scheduled it.
729//!
730//! Punctuation is invisible in the wire topology, because it is purely runtime
731//! behavior. These semantics match the JVM `TopologyTestDriver`.
732//!
733//! ```
734//! use std::time::Duration;
735//!
736//! use async_trait::async_trait;
737//! use krabka_client_streams::{
738//!     I64Serde, NodeHandle, Processor, ProcessorContext, PunctuationType, Punctuator, Record,
739//!     StringSerde, Topology, TopologyTestDriver,
740//! };
741//!
742//! // A punctuator that forwards the fire timestamp downstream.
743//! struct Emit;
744//! #[async_trait]
745//! impl Punctuator<String, i64> for Emit {
746//!     async fn punctuate(&mut self, ctx: &mut ProcessorContext<'_, '_, String, i64>, ts: i64) {
747//!         ctx.forward(Record::new(None, ts, ts));
748//!     }
749//! }
750//! // A processor that schedules `Emit` every 10ms of stream-time (and drops records).
751//! struct Scheduler;
752//! #[async_trait]
753//! impl Processor<String, String, String, i64> for Scheduler {
754//!     async fn init(&mut self, ctx: &mut ProcessorContext<'_, '_, String, i64>) {
755//!         ctx.schedule(Duration::from_millis(10), PunctuationType::StreamTime, Emit);
756//!     }
757//!     async fn process(
758//!         &mut self,
759//!         _ctx: &mut ProcessorContext<'_, '_, String, i64>,
760//!         _r: Record<String, String>,
761//!     ) {
762//!     }
763//! }
764//!
765//! let mut topo = Topology::new();
766//! let src: NodeHandle<String, String> = topo.add_source("src", ["in"]);
767//! let p = topo.add_processor("p", || Scheduler, [&src]);
768//! topo.add_sink("out", "out", [&p]);
769//! let built = topo.build("app").unwrap();
770//!
771//! let mut driver = TopologyTestDriver::new(&built).unwrap();
772//! // Stream-time advances with each record's timestamp; the punctuator fires once per
773//! // crossed 10ms boundary, stamped with the CURRENT stream-time (5 is skipped).
774//! for ts in [0_i64, 5, 10] {
775//!     driver.pipe_input(
776//!         "in",
777//!         (StringSerde, StringSerde),
778//!         Some("k".to_string()),
779//!         "v".to_string(),
780//!         ts,
781//!     );
782//! }
783//! assert_eq!(
784//!     driver.read_output("out", (StringSerde, I64Serde)),
785//!     Some((None, 0_i64))
786//! );
787//! assert_eq!(
788//!     driver.read_output("out", (StringSerde, I64Serde)),
789//!     Some((None, 10_i64))
790//! );
791//! assert_eq!(driver.read_output("out", (StringSerde, I64Serde)), None);
792//! ```
793//!
794//! ## Running an app (`KafkaStreams`)
795//!
796//! Run a built topology against a broker with the managed runtime. The runtime
797//! joins the streams group, fetches its assigned partitions, processes records,
798//! produces to sink topics, and commits offsets at-least-once:
799//!
800//! ```no_run
801//! use async_trait::async_trait;
802//! use krabka_client_streams::{
803//!     KafkaStreams, NodeHandle, Processor, ProcessorContext, Record, Topology,
804//! };
805//!
806//! struct Upper;
807//! #[async_trait]
808//! impl Processor<String, String, String, String> for Upper {
809//!     async fn process(
810//!         &mut self,
811//!         ctx: &mut ProcessorContext<'_, '_, String, String>,
812//!         r: Record<String, String>,
813//!     ) {
814//!         ctx.forward(Record::new(r.key, r.value.to_uppercase(), r.timestamp));
815//!     }
816//! }
817//!
818//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
819//! let mut topo = Topology::new();
820//! let src: NodeHandle<String, String> = topo.add_source("src", ["input-topic"]);
821//! let up = topo.add_processor("up", || Upper, [&src]);
822//! topo.add_sink("out", "output-topic", [&up]);
823//! let built = topo.build("my-app")?;
824//!
825//! let streams = KafkaStreams::builder()
826//!     .bootstrap("localhost:9092")
827//!     .application_id("my-app")
828//!     .topology(built)
829//!     .build()
830//!     .await?;
831//! // The app runs in the background until it is closed.
832//! streams.close().await?;
833//! # Ok(())
834//! # }
835//! ```
836//!
837//! ## State stores & backends
838//!
839//! The execution path is **async**. [`Processor::process`](processor::Processor)
840//! is an `async fn`, and a processor reads and writes its connected state store
841//! with `ctx.get_state_store::<K, V>(name).get(&k).await` and `.put(k, v).await`.
842//!
843//! State stores are **pluggable** through a byte-level backend. A
844//! [`KeyValueStore`] is a typed view, [`KeyValueBytesStore`], over a backend that
845//! [`StoreBackend`] selects. `InMemory` is a `BTreeMap`, and it is both the
846//! default and the test backend. `Turso` is a pure-Rust `SQLite` engine that
847//! persists under a state dir, and the managed runtime uses it.
848//!
849//! The backend is a *materialized cache*, and the changelog topic is the source
850//! of truth. On assignment the runtime rebuilds the store from the changelog, a
851//! clean-slate replay. A missing or corrupt local store is recovered by replay,
852//! so there is no data loss. Select the backend on the builder:
853//! `KafkaStreams::builder().store_backend(StoreBackend::Turso { state_dir })`.
854//!
855//! ## Interactive Queries
856//!
857//! Read a running instance's local state stores from outside the topology with
858//! [`KafkaStreams::key_value_store`], [`KafkaStreams::window_store`], and
859//! [`KafkaStreams::session_store`]. Each one returns a typed, read-only view:
860//! [`ReadOnlyKeyValueStore`], [`ReadOnlyWindowStore`], or
861//! [`ReadOnlySessionStore`]. The accessors of these views round-trip through the
862//! running supervisor:
863//!
864//! ```no_run
865//! # use krabka_client_streams::{KafkaStreams, StringSerde, I64Serde};
866//! # async fn example(streams: KafkaStreams) -> Result<(), Box<dyn std::error::Error>> {
867//! let counts = streams
868//!     .key_value_store("counts", StringSerde, I64Serde)
869//!     .await?;
870//! let n: Option<i64> = counts.get(&"alice".to_string()).await?;
871//! let top = counts.range(&"a".to_string(), &"m".to_string()).await?;
872//! let total = counts.approximate_num_entries().await?;
873//! # let _ = (n, top, total);
874//! # Ok(())
875//! # }
876//! ```
877//!
878//! Queries reach only the **local active** stores. Each query is a composite read
879//! across every partition this instance owns, which matches the JVM default
880//! `StoreQueryParameters`. [`ReadOnlyKeyValueStore`] exposes `get`, `range`
881//! (inclusive), `all`, and `approximate_num_entries`. [`ReadOnlyWindowStore`]
882//! exposes `fetch_single` and `fetch`. [`ReadOnlySessionStore`] exposes `fetch`.
883//!
884//! A failure surfaces as [`StreamsClientError::InteractiveQuery`] that wraps an
885//! [`IqError`]. [`IqError::StoreNotFound`] means no such store is assigned here.
886//! [`IqError::WrongStoreKind`] means the caller queried the wrong store kind.
887//! [`IqError::RebalanceInProgress`] means no tasks are assigned yet, so retry.
888//!
889//! ## Exactly-once (EOS v2)
890//!
891//! [`ProcessingGuarantee`] sets the runtime's delivery guarantee.
892//! [`AtLeastOnce`](ProcessingGuarantee::AtLeastOnce) is the default: it produces,
893//! then commits the source offsets, and a crash mid-cycle may replay.
894//! [`ExactlyOnceV2`](ProcessingGuarantee::ExactlyOnceV2) is the KIP-447
895//! `exactly_once_v2` guarantee.
896//!
897//! Under EOS-v2 the [`StreamThread`] runs **one Kafka transaction per commit
898//! interval** over a single transactional producer with
899//! `transactional.id = <application.id>-<thread>`. The thread `begin`s the txn,
900//! produces sink **and** changelog records into it, commits the consumed source
901//! offsets *inside* the same transaction with `send_offsets_to_transaction`, and
902//! then `commit`s. So output, changelog, and offsets land atomically.
903//!
904//! On any error in the cycle the thread `abort`s, rewinds the source offsets, and
905//! rolls back the state stores. It wipes each store and re-restores it from the
906//! **committed** changelog (`read_committed`). State-store restore under EOS
907//! reads `read_committed`, so the runtime never replays aborted changelog writes.
908//!
909//! `OffsetFetch` surfaces the committed source offsets once the transaction's
910//! COMMIT marker lands. A restarted instance then resumes from the committed
911//! offset and rebuilds its stores from the committed changelog. The runtime
912//! processes the committed input **exactly once across the restart**. It does not
913//! re-read or double-count that input.
914//!
915//! [`StreamThread`]: runtime
916//!
917//! ```no_run
918//! use krabka_client_streams::{KafkaStreams, NodeHandle, ProcessingGuarantee, Topology};
919//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
920//! let mut topo = Topology::new();
921//! let src: NodeHandle<String, String> = topo.add_source("src", ["in"]);
922//! topo.add_sink("out", "out", [&src]);
923//! let built = topo.build("my-app")?;
924//!
925//! // Opt into exactly-once: output + changelog + source offsets commit atomically.
926//! let streams = KafkaStreams::builder()
927//!     .bootstrap("localhost:9092")
928//!     .application_id("my-app")
929//!     .topology(built)
930//!     .processing_guarantee(ProcessingGuarantee::ExactlyOnceV2)
931//!     .build()
932//!     .await?;
933//! streams.close().await?;
934//! # Ok(())
935//! # }
936//! ```
937//!
938//! ## Versioned tables (KIP-889)
939//!
940//! `builder.table(..., Materialized::as_versioned(name, history_retention))`
941//! materializes a table into a versioned key-value store. The store records
942//! out-of-order records as historical versions and keeps the latest version
943//! intact. `get_as_of` gives point-in-time reads.
944//!
945//! ## WebAssembly
946//!
947//! The topology, processor, DSL, store, barrier and test-driver layers build for
948//! `wasm32-unknown-unknown`, and [`EmbeddedTask`] drives one task's processor
949//! graph there with bytes an embedder fetches itself. The membership and
950//! runtime layers are Kafka network clients, so [`StreamsMembership`],
951//! [`KafkaStreams`], [`StreamsApp`], the Turso store backend, the file-backed
952//! snapshot store and the schema-registry serde bridge exist only on native
953//! targets.
954#![doc(html_root_url = "https://docs.rs/krabka-client-streams/0.4.1")]
955
956pub mod barrier;
957pub mod columnar;
958pub mod dsl;
959pub mod embedded;
960mod error;
961pub mod membership;
962pub mod processor;
963pub mod runtime;
964pub mod store;
965#[cfg(not(target_family = "wasm"))]
966pub mod streams_app;
967pub mod test_driver;
968pub mod topology;
969
970#[doc(hidden)]
971pub use async_trait::async_trait as __async_trait;
972pub use barrier::{
973    BARRIER_STATE_TOPIC, Barrier, BarrierAlignment, BarrierCut, BarrierListener, CutReader,
974    CutStatus, decode_barrier_cut,
975};
976pub use dsl::{
977    BranchedStream, BufferConfig, CogroupedKStream, GlobalKTable, Grouped, JoinWindows, Joined,
978    KGroupedStream, KStream, KTable, Materialized, Repartitioned, SessionWindowedCogroupedStream,
979    SessionWindowedKGroupedStream, SessionWindowedSerde, SessionWindows,
980    SlidingWindowedCogroupedStream, SlidingWindowedKGroupedStream, SlidingWindows, StreamJoined,
981    StreamsBuilder, Suppressed, TimeWindowedCogroupedStream, TimeWindowedKGroupedStream,
982    TimeWindowedSerde, TimeWindows, VersionedConfig, Window, Windowed,
983};
984pub use embedded::EmbeddedTask;
985pub use error::StreamsClientError;
986#[cfg(not(target_family = "wasm"))]
987pub use krabka_client_core::ClientDnsTimeout;
988#[cfg(not(target_family = "wasm"))]
989pub use membership::{
990    DEFAULT_STREAMS_JOIN_RETRY_BACKOFF, DEFAULT_STREAMS_LEAVE_HEARTBEAT_TIMEOUT,
991    DEFAULT_STREAMS_REBALANCE_TIMEOUT, SchemaPrewarm, StreamsJoinRetryBackoff,
992    StreamsLeaveHeartbeatTimeout, StreamsMembership, StreamsRebalanceTimeout,
993};
994pub use membership::{
995    StreamsAssignment, StreamsEvent, StreamsStatus, TaskAssignment, TaskOffsetTracker,
996    TopicPartition,
997};
998#[cfg(not(target_family = "wasm"))]
999pub use processor::schema_serde::SchemaSerde;
1000pub use processor::{
1001    BytesSerde, Cancellable, Consumed, DefaultSerde, FixedKeyProcessor, FixedKeyProcessorContext,
1002    FixedKeyProcessorSupplier, FixedKeyRecord, I64Serde, Processor, ProcessorContext,
1003    ProcessorError, ProcessorSupplier, Produced, PunctuationType, Punctuator, Record,
1004    RecordContext, Serde, SerdeError, StringSerde,
1005};
1006pub use runtime::iqv2::{
1007    FailureReason, KeyQuery, MultiVersionedKeyQuery, Position, PositionBound, Query, QueryResult,
1008    RangeQuery, StateQuery, StateQueryRequest, StateQueryResult, VersionedKeyQuery, WindowKeyQuery,
1009    WindowRangeQuery,
1010};
1011#[cfg(not(target_family = "wasm"))]
1012pub use runtime::{
1013    DEFAULT_STREAMS_COMMIT_INTERVAL, DEFAULT_STREAMS_INTERACTIVE_QUERY_QUEUE_CAPACITY,
1014    DEFAULT_STREAMS_POLL_INTERVAL, DEFAULT_STREAMS_STATE_STORE_CACHE_MAX_BYTES, KafkaStreams,
1015    MAX_STREAMS_STATE_STORE_CACHE_MAX_BYTES, ReadOnlyKeyValueStore, ReadOnlySessionStore,
1016    ReadOnlyWindowStore, StreamsCommitInterval, StreamsInteractiveQueryQueueCapacity,
1017    StreamsPollInterval, StreamsStateStoreCacheMaxBytes, eos::ProcessingGuarantee, iq::IqError,
1018};
1019#[cfg(not(target_family = "wasm"))]
1020pub use store::snapshot::FileSnapshotStore;
1021pub use store::{
1022    KeyValueBytesStore, KeyValueStore, StateStore, StoreBackend,
1023    iq::StoreKind,
1024    snapshot::{NoSnapshotStore, SnapshotKey, SnapshotStore, TaskSnapshot},
1025    versioned::VersionedRecord,
1026};
1027#[cfg(not(target_family = "wasm"))]
1028pub use streams_app::StreamsApp;
1029pub use test_driver::TopologyTestDriver;
1030pub use topology::{BuiltTopology, NodeHandle, Topology, TopologyError};
1031
1032/// A path under `tests/testdata`, for the unit tests that compare against a
1033/// captured JVM fixture.
1034///
1035/// Resolved while the test runs rather than through `env!`, which would bake
1036/// the absolute build directory into the compiled test binary. Cargo sets the
1037/// variable and runs from the crate; a build system that runs from a workspace
1038/// root instead needs the crate prefix.
1039#[cfg(test)]
1040pub(crate) fn testdata(relative: &str) -> std::path::PathBuf {
1041    let base =
1042        std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| "crates/client-streams".to_owned());
1043    std::path::Path::new(&base)
1044        .join("tests/testdata")
1045        .join(relative)
1046}