Skip to main content

krabka_client_streams/dsl/
kstream.rs

1//! `KStream<K,V>` handle and its stateless DSL ops.
2//!
3//! Each op does three things. It mints a JVM-matching node name. It adds a
4//! type-erased `StatelessProcessor` node to the logical graph with the right
5//! `key_changing_operation` flag. It attaches a lowering thunk that makes the
6//! typed [`Topology::add_processor`] call and records the resulting node name.
7//! The thunk captures the op's concrete K/V types and the user closure, so the
8//! types are known statically *inside* the thunk even though the graph is
9//! type-erased.
10//!
11//! [`Topology::add_processor`]: crate::topology::Topology::add_processor
12use std::{
13    any::Any,
14    cell::RefCell,
15    marker::PhantomData,
16    rc::Rc,
17    sync::{Arc, Mutex},
18};
19
20use bytes::Bytes;
21use krabka_units::prelude::*;
22
23use crate::{
24    dsl::{
25        builder::InternalStreamsBuilder,
26        config::{Grouped, Joined, Materialized, StreamJoined},
27        graph::{GraphNodeKind, LowerState, NodeId},
28        kgrouped::KGroupedStream,
29        ktable::KTable,
30        names,
31        processors::{
32            change::Change, global_join::KStreamGlobalTableJoinProcessor,
33            join::KStreamKTableJoinProcessor, ktable_join::JoinKind, outer_join_store::TimeTracker,
34            stateless, stream_join::KStreamKStreamJoinProcessor, table::KStreamToTableProcessor,
35            tuple_forwarder::TupleForwarder,
36        },
37        windows::JoinWindows,
38    },
39    processor::serde::{BytesSerde, DefaultSerde, Produced, Serde, SerdeAssociate},
40    topology::NodeHandle,
41};
42
43/// The shared outer-form joiner for a windowed stream-stream join.
44///
45/// `join`, `left_join`, and `outer_join` all lift their user joiner to this
46/// shape. Each per-side processor wraps it, so a match passes the present sides.
47type SharedOuterJoiner<V, V2, VO> = Arc<dyn Fn(Option<&V>, Option<&V2>) -> VO + Send + Sync>;
48
49struct StreamJoinGraph {
50    join_this: String,
51    join_other: String,
52    this_store: String,
53    other_store: String,
54    outer_store: Option<String>,
55    this_id: NodeId,
56    other_id: NodeId,
57    merge: String,
58}
59
60fn allocate_stream_join_graph(
61    builder: &mut InternalStreamsBuilder,
62    parents: (NodeId, NodeId),
63    required: (bool, bool),
64) -> StreamJoinGraph {
65    builder.new_processor_name(names::KSTREAM_WINDOWED);
66    builder.new_processor_name(names::KSTREAM_WINDOWED);
67    let this_prefix = if required.0 {
68        names::KSTREAM_JOINTHIS
69    } else {
70        names::KSTREAM_OUTERTHIS
71    };
72    let other_prefix = if required.1 {
73        names::KSTREAM_JOINOTHER
74    } else {
75        names::KSTREAM_OUTEROTHER
76    };
77    let join_this = builder.new_processor_name(this_prefix);
78    let join_other = builder.new_processor_name(other_prefix);
79    let merge = builder.new_processor_name(names::MERGE);
80    let this_store = format!("{join_this}-store");
81    let other_store = format!("{join_other}-store");
82    let outer_store = (!required.0 || !required.1).then(|| {
83        let this_index = &join_this[this_prefix.len()..];
84        format!("{}{this_index}-store", names::KSTREAM_OUTERSHARED)
85    });
86    let this_id = builder.graph.add(
87        join_this.clone(),
88        GraphNodeKind::StatelessProcessor {
89            repartition_required: false,
90        },
91        vec![parents.0],
92    );
93    let other_id = builder.graph.add(
94        join_other.clone(),
95        GraphNodeKind::StatelessProcessor {
96            repartition_required: false,
97        },
98        vec![parents.1],
99    );
100    StreamJoinGraph {
101        join_this,
102        join_other,
103        this_store,
104        other_store,
105        outer_store,
106        this_id,
107        other_id,
108        merge,
109    }
110}
111
112/// Type-erased KIP-923 grace lowering.
113///
114/// The `*_with` methods build this closure, because they hold the `Serde` and
115/// `Sync` bounds that the grace buffer store and the processor need.
116/// [`KStream::join_table_impl`] runs it once inside its lowering thunk.
117///
118/// The closure takes the live `LowerState`, the stream-side parent `NodeId`, the
119/// auto-minted join node name, and the table store name. It rebuilds the typed
120/// `NodeHandle<K, V>` itself, because `V` is in scope where the closure is built.
121/// It registers the grace processor and its `<join_name>-Buffer` store, connects
122/// the join to the buffer, and returns the join node handle. The impl then does
123/// the shared table-store connect and the copartition declaration, the same as
124/// the non-grace path.
125type GraceLowering<K, VO> =
126    Box<dyn FnOnce(&mut LowerState, NodeId, String, String) -> NodeHandle<K, VO> + Send>;
127
128pub struct KStream<K, V, KS = <K as DefaultSerde>::Serde, VS = <V as DefaultSerde>::Serde> {
129    #[allow(dead_code)]
130    pub(crate) builder: Rc<RefCell<InternalStreamsBuilder>>,
131    #[allow(dead_code)]
132    pub(crate) node: NodeId,
133    /// True when a key-changing op upstream produced the current key and no
134    /// repartition has re-grouped it since. The key-changing ops are
135    /// `select_key`, `map`, `flat_map`, and `group_by`. A downstream aggregation
136    /// reads this flag to decide whether it must insert a repartition before the
137    /// aggregate node. A source stream starts `false`. Value-only ops propagate
138    /// the parent bit.
139    pub(crate) key_changing: bool,
140    /// The single Kafka source topic this stream still reads, when known.
141    ///
142    /// [`StreamsBuilder::stream`] sets this when a stream reads exactly one
143    /// topic. Value-only ops such as `map_values`, `filter`, and `peek` propagate
144    /// it unchanged, because they do not change the key or the partitioning.
145    /// Key-changing ops, `merge`, `repartition`, `to_stream`, and a join output
146    /// all clear it to `None`. In those cases the stream no longer corresponds to
147    /// a single original source topic.
148    ///
149    /// [`join`](Self::join) reads this as the stream-side copartition group
150    /// member when the key is unchanged. If the key changed, `join` repartitions
151    /// and uses the repartition topic as the member. `group_by_key` also
152    /// propagates it, so a downstream cogroup can register its inputs'
153    /// copartition group.
154    pub(crate) source_topic: Option<String>,
155    pub(crate) key_serde: KS,
156    pub(crate) value_serde: VS,
157    pub(crate) _pd: std::marker::PhantomData<fn() -> (K, V)>,
158}
159
160impl<K, V, KS, VS> KStream<K, V, KS, VS> {
161    pub(crate) fn new(
162        builder: Rc<RefCell<InternalStreamsBuilder>>,
163        node: NodeId,
164        key_serde: KS,
165        value_serde: VS,
166    ) -> Self {
167        Self::new_with_key_changing(builder, node, false, key_serde, value_serde)
168    }
169
170    pub(crate) fn new_with_key_changing(
171        builder: Rc<RefCell<InternalStreamsBuilder>>,
172        node: NodeId,
173        key_changing: bool,
174        key_serde: KS,
175        value_serde: VS,
176    ) -> Self {
177        Self {
178            builder,
179            node,
180            key_changing,
181            source_topic: None,
182            key_serde,
183            value_serde,
184            _pd: std::marker::PhantomData,
185        }
186    }
187
188    /// Set the single source-topic lineage (see [`source_topic`](Self::source_topic)).
189    #[must_use]
190    pub(crate) fn with_source_topic(mut self, topic: Option<String>) -> Self {
191        self.source_topic = topic;
192        self
193    }
194
195    #[must_use]
196    pub fn with_key_serde<NewKS>(self, serde: NewKS) -> KStream<K, V, NewKS, VS> {
197        KStream {
198            builder: self.builder,
199            node: self.node,
200            key_changing: self.key_changing,
201            source_topic: self.source_topic,
202            key_serde: serde,
203            value_serde: self.value_serde,
204            _pd: std::marker::PhantomData,
205        }
206    }
207
208    #[must_use]
209    pub fn with_value_serde<NewVS>(self, serde: NewVS) -> KStream<K, V, KS, NewVS> {
210        KStream {
211            builder: self.builder,
212            node: self.node,
213            key_changing: self.key_changing,
214            source_topic: self.source_topic,
215            key_serde: self.key_serde,
216            value_serde: serde,
217            _pd: std::marker::PhantomData,
218        }
219    }
220}
221
222impl<K, V, KS, VS> KStream<K, V, KS, VS>
223where
224    K: Any + Send + Sync + Clone,
225    V: Any + Send + Clone,
226    KS: Clone,
227    VS: Clone,
228{
229    /// `mapValues`: transform each value, key unchanged. Not key-changing.
230    pub fn map_values<V2, F>(&self, f: F) -> KStream<K, V2, KS, <V2 as DefaultSerde>::Serde>
231    where
232        V2: DefaultSerde + Any + Send + Clone,
233        F: Fn(&V) -> V2 + Clone + Send + Sync + 'static,
234    {
235        let parent_id = self.node;
236        let mut g = self.builder.borrow_mut();
237        let name = g.new_processor_name(names::MAPVALUES);
238        let id = g.graph.add(
239            name.clone(),
240            GraphNodeKind::StatelessProcessor {
241                repartition_required: false,
242            },
243            vec![parent_id],
244        );
245        let f2 = f.clone();
246        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
247            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
248            let h = state.topology.add_processor::<K, V, K, V2, _, _, _>(
249                name.clone(),
250                move || stateless::MapValuesProcessor {
251                    f: f2.clone(),
252                    _pd: PhantomData,
253                },
254                [parent],
255            );
256            state.handle_name.insert(id, h.name().to_string());
257        }));
258        drop(g);
259        // map_values is value-only → key + source-topic lineage unchanged.
260        KStream::new_with_key_changing(
261            Rc::clone(&self.builder),
262            id,
263            self.key_changing,
264            self.key_serde.clone(),
265            <V2 as DefaultSerde>::Serde::default(),
266        )
267        .with_source_topic(self.source_topic.clone())
268    }
269
270    /// `filter`: keep records where `predicate(key, value)` is true.
271    #[must_use]
272    pub fn filter<F>(&self, predicate: F) -> KStream<K, V, KS, VS>
273    where
274        K: Default,
275        F: Fn(&K, &V) -> bool + Clone + Send + Sync + 'static,
276    {
277        self.filter_inner(predicate, false)
278    }
279
280    /// `filterNot`: keep records where `predicate(key, value)` is false.
281    #[must_use]
282    pub fn filter_not<F>(&self, predicate: F) -> KStream<K, V, KS, VS>
283    where
284        K: Default,
285        F: Fn(&K, &V) -> bool + Clone + Send + Sync + 'static,
286    {
287        self.filter_inner(predicate, true)
288    }
289
290    fn filter_inner<F>(&self, predicate: F, negate: bool) -> KStream<K, V, KS, VS>
291    where
292        K: Default,
293        F: Fn(&K, &V) -> bool + Clone + Send + Sync + 'static,
294    {
295        let parent_id = self.node;
296        let mut g = self.builder.borrow_mut();
297        let name = g.new_processor_name(names::FILTER);
298        let id = g.graph.add(
299            name.clone(),
300            GraphNodeKind::StatelessProcessor {
301                repartition_required: false,
302            },
303            vec![parent_id],
304        );
305        let p2 = predicate.clone();
306        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
307            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
308            let h = state.topology.add_processor::<K, V, K, V, _, _, _>(
309                name.clone(),
310                move || stateless::FilterProcessor {
311                    predicate: p2.clone(),
312                    negate,
313                    _pd: PhantomData,
314                },
315                [parent],
316            );
317            state.handle_name.insert(id, h.name().to_string());
318        }));
319        drop(g);
320        // filter is value-only → key + source-topic lineage unchanged.
321        KStream::new_with_key_changing(
322            Rc::clone(&self.builder),
323            id,
324            self.key_changing,
325            self.key_serde.clone(),
326            self.value_serde.clone(),
327        )
328        .with_source_topic(self.source_topic.clone())
329    }
330
331    /// `map`: transform key and value. Key-changing.
332    pub fn map<K2, V2, F>(
333        &self,
334        f: F,
335    ) -> KStream<K2, V2, <K2 as DefaultSerde>::Serde, <V2 as DefaultSerde>::Serde>
336    where
337        K: Default,
338        K2: DefaultSerde + Any + Send + Clone,
339        V2: DefaultSerde + Any + Send + Clone,
340        F: Fn(&K, &V) -> (K2, V2) + Clone + Send + Sync + 'static,
341    {
342        let parent_id = self.node;
343        let mut g = self.builder.borrow_mut();
344        let name = g.new_processor_name(names::MAP);
345        let id = g.graph.add(
346            name.clone(),
347            GraphNodeKind::StatelessProcessor {
348                repartition_required: false,
349            },
350            vec![parent_id],
351        );
352        g.graph.nodes[id].key_changing_operation = true;
353        let f2 = f.clone();
354        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
355            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
356            let h = state.topology.add_processor::<K, V, K2, V2, _, _, _>(
357                name.clone(),
358                move || stateless::MapProcessor {
359                    f: f2.clone(),
360                    _pd: PhantomData,
361                },
362                [parent],
363            );
364            state.handle_name.insert(id, h.name().to_string());
365        }));
366        drop(g);
367        // map rewrites the key → key-changing lineage.
368        KStream::new_with_key_changing(
369            Rc::clone(&self.builder),
370            id,
371            true,
372            <K2 as DefaultSerde>::Serde::default(),
373            <V2 as DefaultSerde>::Serde::default(),
374        )
375    }
376
377    /// `selectKey`: rewrite the key, value unchanged. Key-changing.
378    pub fn select_key<K2, F>(&self, f: F) -> KStream<K2, V, <K2 as DefaultSerde>::Serde, VS>
379    where
380        K: Default,
381        K2: DefaultSerde + Any + Send + Clone,
382        F: Fn(&K, &V) -> K2 + Clone + Send + Sync + 'static,
383    {
384        self.select_key_with_serde(f, <K2 as DefaultSerde>::Serde::default())
385    }
386
387    /// `selectKey` with an explicit key serde. Key-changing.
388    pub fn select_key_with_serde<K2, GKS, F>(&self, f: F, key_serde: GKS) -> KStream<K2, V, GKS, VS>
389    where
390        K: Default,
391        K2: Any + Send + Clone,
392        GKS: Serde<K2> + Clone + 'static,
393        F: Fn(&K, &V) -> K2 + Clone + Send + Sync + 'static,
394    {
395        let parent_id = self.node;
396        let mut g = self.builder.borrow_mut();
397        let name = g.new_processor_name(names::KEY_SELECT);
398        let id = g.graph.add(
399            name.clone(),
400            GraphNodeKind::StatelessProcessor {
401                repartition_required: false,
402            },
403            vec![parent_id],
404        );
405        g.graph.nodes[id].key_changing_operation = true;
406        let f2 = f.clone();
407        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
408            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
409            let h = state.topology.add_processor::<K, V, K2, V, _, _, _>(
410                name.clone(),
411                move || stateless::SelectKeyProcessor {
412                    f: f2.clone(),
413                    _pd: PhantomData,
414                },
415                [parent],
416            );
417            state.handle_name.insert(id, h.name().to_string());
418        }));
419        drop(g);
420        // select_key rewrites the key → key-changing lineage.
421        KStream::new_with_key_changing(
422            Rc::clone(&self.builder),
423            id,
424            true,
425            key_serde,
426            self.value_serde.clone(),
427        )
428    }
429
430    /// `flatMap`: one record → zero or more `(K2, V2)`. Key-changing.
431    pub fn flat_map<K2, V2, IT, F>(
432        &self,
433        f: F,
434    ) -> KStream<K2, V2, <K2 as DefaultSerde>::Serde, <V2 as DefaultSerde>::Serde>
435    where
436        K: Default,
437        K2: DefaultSerde + Any + Send + Clone,
438        V2: DefaultSerde + Any + Send + Clone,
439        IT: IntoIterator<Item = (K2, V2)> + 'static,
440        F: Fn(&K, &V) -> IT + Clone + Send + Sync + 'static,
441    {
442        let parent_id = self.node;
443        let mut g = self.builder.borrow_mut();
444        let name = g.new_processor_name(names::FLATMAP);
445        let id = g.graph.add(
446            name.clone(),
447            GraphNodeKind::StatelessProcessor {
448                repartition_required: false,
449            },
450            vec![parent_id],
451        );
452        g.graph.nodes[id].key_changing_operation = true;
453        let f2 = f.clone();
454        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
455            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
456            let h = state.topology.add_processor::<K, V, K2, V2, _, _, _>(
457                name.clone(),
458                move || stateless::FlatMapProcessor {
459                    f: f2.clone(),
460                    _pd: PhantomData,
461                },
462                [parent],
463            );
464            state.handle_name.insert(id, h.name().to_string());
465        }));
466        drop(g);
467        // flat_map can rewrite the key → key-changing lineage.
468        KStream::new_with_key_changing(
469            Rc::clone(&self.builder),
470            id,
471            true,
472            <K2 as DefaultSerde>::Serde::default(),
473            <V2 as DefaultSerde>::Serde::default(),
474        )
475    }
476
477    /// `flatMapValues`: one record → zero or more `V2`, key unchanged.
478    pub fn flat_map_values<V2, IT, F>(
479        &self,
480        f: F,
481    ) -> KStream<K, V2, KS, <V2 as DefaultSerde>::Serde>
482    where
483        V2: DefaultSerde + Any + Send + Clone,
484        IT: IntoIterator<Item = V2> + 'static,
485        F: Fn(&V) -> IT + Clone + Send + Sync + 'static,
486    {
487        let parent_id = self.node;
488        let mut g = self.builder.borrow_mut();
489        let name = g.new_processor_name(names::FLATMAPVALUES);
490        let id = g.graph.add(
491            name.clone(),
492            GraphNodeKind::StatelessProcessor {
493                repartition_required: false,
494            },
495            vec![parent_id],
496        );
497        let f2 = f.clone();
498        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
499            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
500            let h = state.topology.add_processor::<K, V, K, V2, _, _, _>(
501                name.clone(),
502                move || stateless::FlatMapValuesProcessor {
503                    f: f2.clone(),
504                    _pd: PhantomData,
505                },
506                [parent],
507            );
508            state.handle_name.insert(id, h.name().to_string());
509        }));
510        drop(g);
511        // flat_map_values is value-only → key + source-topic lineage unchanged.
512        KStream::new_with_key_changing(
513            Rc::clone(&self.builder),
514            id,
515            self.key_changing,
516            self.key_serde.clone(),
517            <V2 as DefaultSerde>::Serde::default(),
518        )
519        .with_source_topic(self.source_topic.clone())
520    }
521
522    /// `peek`: observe each record, then forward it unchanged.
523    #[must_use]
524    pub fn peek<F>(&self, f: F) -> KStream<K, V, KS, VS>
525    where
526        K: Default,
527        F: Fn(&K, &V) + Clone + Send + Sync + 'static,
528    {
529        let parent_id = self.node;
530        let mut g = self.builder.borrow_mut();
531        let name = g.new_processor_name(names::PEEK);
532        let id = g.graph.add(
533            name.clone(),
534            GraphNodeKind::StatelessProcessor {
535                repartition_required: false,
536            },
537            vec![parent_id],
538        );
539        let f2 = f.clone();
540        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
541            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
542            let h = state.topology.add_processor::<K, V, K, V, _, _, _>(
543                name.clone(),
544                move || stateless::PeekProcessor {
545                    f: f2.clone(),
546                    _pd: PhantomData,
547                },
548                [parent],
549            );
550            state.handle_name.insert(id, h.name().to_string());
551        }));
552        drop(g);
553        // peek is observe-only → key + source-topic lineage unchanged.
554        KStream::new_with_key_changing(
555            Rc::clone(&self.builder),
556            id,
557            self.key_changing,
558            self.key_serde.clone(),
559            self.value_serde.clone(),
560        )
561        .with_source_topic(self.source_topic.clone())
562    }
563
564    /// `foreach`: terminal side-effect on each record (consumes the stream).
565    pub fn foreach<F>(self, f: F)
566    where
567        K: Default,
568        F: Fn(&K, &V) + Clone + Send + Sync + 'static,
569    {
570        let parent_id = self.node;
571        let mut g = self.builder.borrow_mut();
572        let name = g.new_processor_name(names::FOREACH);
573        let id = g.graph.add(
574            name.clone(),
575            GraphNodeKind::StatelessProcessor {
576                repartition_required: false,
577            },
578            vec![parent_id],
579        );
580        let f2 = f.clone();
581        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
582            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
583            let h = state.topology.add_processor::<K, V, K, V, _, _, _>(
584                name.clone(),
585                move || stateless::ForeachProcessor {
586                    f: f2.clone(),
587                    _pd: PhantomData,
588                },
589                [parent],
590            );
591            state.handle_name.insert(id, h.name().to_string());
592        }));
593    }
594
595    /// `to`: write the stream to a topic through a sink (consumes the stream).
596    pub fn to_explicit<KS2, VS2>(
597        self,
598        topic: impl Into<String>,
599        produced: impl Into<Produced<KS2, VS2>>,
600    ) where
601        KS2: SerdeAssociate + Serde<K> + Clone,
602        VS2: SerdeAssociate + Serde<V> + Clone,
603    {
604        let produced = produced.into();
605        let topic: String = topic.into();
606        let parent_id = self.node;
607        let mut g = self.builder.borrow_mut();
608        let name = g.new_processor_name(names::SINK);
609        let id = g.graph.add(
610            name.clone(),
611            GraphNodeKind::StreamSink {
612                topic: topic.clone(),
613            },
614            vec![parent_id],
615        );
616        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
617            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
618            state.topology.add_sink_explicit::<K, V, KS2, VS2, _, _>(
619                name.clone(),
620                topic,
621                [parent],
622                produced,
623            );
624            // A sink is terminal; record its name so children (none) could find it.
625            state.handle_name.insert(id, name.clone());
626        }));
627    }
628
629    /// Write the stream to a topic using the default/carried serdes of the stream.
630    pub fn to(self, topic: impl Into<String>)
631    where
632        KS: SerdeAssociate + Serde<K> + Clone,
633        VS: SerdeAssociate + Serde<V> + Clone,
634    {
635        let key_serde = self.key_serde.clone();
636        let value_serde = self.value_serde.clone();
637        self.to_explicit(topic, Produced::with(key_serde, value_serde));
638    }
639
640    /// `merge`: union this stream with `other` (same K/V). Both feed one node.
641    #[must_use]
642    pub fn merge(&self, other: &KStream<K, V, KS, VS>) -> KStream<K, V, KS, VS> {
643        let left_id = self.node;
644        let right_id = other.node;
645        let mut g = self.builder.borrow_mut();
646        let name = g.new_processor_name(names::MERGE);
647        let id = g.graph.add(
648            name.clone(),
649            GraphNodeKind::StatelessProcessor {
650                repartition_required: false,
651            },
652            vec![left_id, right_id],
653        );
654        g.graph.nodes[id].merge_node = true;
655        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
656            let left = NodeHandle::<K, V>::from_name(state.handle_name[&left_id].clone());
657            let right = NodeHandle::<K, V>::from_name(state.handle_name[&right_id].clone());
658            let h = state.topology.add_processor::<K, V, K, V, _, _, _>(
659                name.clone(),
660                || stateless::MergeProcessor { _pd: PhantomData },
661                [left, right],
662            );
663            state.handle_name.insert(id, h.name().to_string());
664        }));
665        drop(g);
666        // merge keeps keys; conservatively key-changing if either side is.
667        KStream::new_with_key_changing(
668            Rc::clone(&self.builder),
669            id,
670            self.key_changing || other.key_changing,
671            self.key_serde.clone(),
672            self.value_serde.clone(),
673        )
674    }
675
676    /// `join` (inner stream-table join): look each stream record up in `table`.
677    ///
678    /// For each record on this stream, this method looks the record's key up in
679    /// `table`'s materialized store. **Only when a value is present**, it
680    /// forwards `joiner(&stream_value, &table_value)` keyed by the same key. It
681    /// drops records whose key is absent from the table. This is an inner join.
682    ///
683    /// `table` must be materialized, that is, sourced through
684    /// [`StreamsBuilder::table`] or any materialized op. The join reads its store
685    /// by name. The join declares the stream side and the table's source topic as
686    /// a **copartition group** (KIP-1071), so the streams-group coordinator
687    /// co-locates their partitions.
688    ///
689    /// The stream key must be unchanged relative to its source partitioning,
690    /// because a stream-table join is partition-local. If a key-changing op such
691    /// as `map`, `select_key`, `flat_map`, or `group_by` comes before the join,
692    /// call [`repartition`](Self::repartition) first to re-partition by the new
693    /// key. If you do not, `join_table` panics.
694    ///
695    /// # Naming vs the JVM
696    /// The JVM overloads `join` for both stream-table and (windowed) stream-stream
697    /// joins. Rust cannot have two inherent methods of the same name and differing
698    /// arity, so the stream-table forms are named `join_table`/`left_join_table`
699    /// here, leaving the plain `join`/`left_join` for the windowed stream-stream
700    /// join (see [`KStream::join`](Self::join)).
701    ///
702    /// [`StreamsBuilder::table`]: crate::dsl::builder::StreamsBuilder::table
703    #[must_use]
704    /// # Panics
705    /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
706    pub fn join_table<VT, VO, F, VTS>(
707        &self,
708        table: &KTable<K, VT, KS, VTS>,
709        joiner: F,
710    ) -> KStream<K, VO, KS, <VO as DefaultSerde>::Serde>
711    where
712        VT: Any + Send + Clone,
713        VO: DefaultSerde + Any + Send + Clone,
714        VTS: Clone,
715        F: Fn(&V, &VT) -> VO + Clone + Send + Sync + 'static,
716    {
717        // Wrap the inner joiner to the left form `Fn(&V, Option<&VT>) -> VO`; with
718        // `emit_on_miss = false` the closure is only ever called with `Some`.
719        let lf = move |v: &V, opt: Option<&VT>| joiner(v, opt.expect("inner join hit"));
720        self.join_table_impl::<VT, VO, _, VTS>(table, lf, false, None)
721    }
722
723    /// `leftJoin` (left stream-table join): like [`join_table`](Self::join_table)
724    /// but always forwards a record for every stream record. On a table miss the
725    /// `joiner` receives `None` for the table-side value. See
726    /// [`join_table`](Self::join_table) for the naming-vs-JVM note.
727    #[must_use]
728    pub fn left_join_table<VT, VO, F, VTS>(
729        &self,
730        table: &KTable<K, VT, KS, VTS>,
731        joiner: F,
732    ) -> KStream<K, VO, KS, <VO as DefaultSerde>::Serde>
733    where
734        VT: Any + Send + Clone,
735        VO: DefaultSerde + Any + Send + Clone,
736        VTS: Clone,
737        F: Fn(&V, Option<&VT>) -> VO + Clone + Send + Sync + 'static,
738    {
739        self.join_table_impl::<VT, VO, _, VTS>(table, joiner, true, None)
740    }
741
742    /// `join` with [`Joined`] config (KIP-923 grace path).
743    ///
744    /// This method is identical to [`join_table`](Self::join_table) when
745    /// `joined.grace` is `None`. When a grace period is set, the join buffers
746    /// each stream record into a `JoinGraceBufferStore`. It drains the buffer
747    /// as-of the record's own timestamp once the grace horizon passes.
748    /// Out-of-order stream records therefore join against the table version that
749    /// was current at the record's own timestamp.
750    ///
751    /// Grace **requires** the table to be versioned. See
752    /// [`Materialized::as_versioned`]. `grace` must be strictly less than the
753    /// table's `history_retention`.
754    ///
755    /// [`Materialized::as_versioned`]: crate::dsl::config::Materialized::as_versioned
756    #[must_use]
757    /// # Panics
758    /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
759    pub fn join_table_with<VT, VO, F, VTS>(
760        &self,
761        table: &KTable<K, VT, KS, VTS>,
762        joiner: F,
763        join_config: Joined,
764    ) -> KStream<K, VO, KS, <VO as DefaultSerde>::Serde>
765    where
766        VT: Any + Send + Sync + Clone,
767        VO: DefaultSerde + Any + Send + Clone,
768        VTS: Clone,
769        KS: Serde<K> + Clone + 'static,
770        VS: Serde<V> + Clone + 'static,
771        V: Sync,
772        F: Fn(&V, &VT) -> VO + Clone + Send + Sync + 'static,
773    {
774        let lf = move |v: &V, opt: Option<&VT>| joiner(v, opt.expect("inner join hit"));
775        let configured_grace = join_config.grace;
776        drop(join_config);
777        let grace =
778            self.build_grace_lowering::<VT, VO, _, VTS>(table, lf.clone(), false, configured_grace);
779        self.join_table_impl::<VT, VO, _, VTS>(table, lf, false, grace)
780    }
781
782    /// `leftJoin` with [`Joined`] config (KIP-923 grace path). Like
783    /// [`left_join_table`](Self::left_join_table) but, when `joined.grace` is
784    /// set, wires the grace buffer + as-of drain (see
785    /// [`join_table_with`](Self::join_table_with)). On a table miss at drain time
786    /// the joiner receives `None`.
787    #[must_use]
788    pub fn left_join_table_with<VT, VO, F, VTS>(
789        &self,
790        table: &KTable<K, VT, KS, VTS>,
791        joiner: F,
792        join_config: Joined,
793    ) -> KStream<K, VO, KS, <VO as DefaultSerde>::Serde>
794    where
795        VT: Any + Send + Sync + Clone,
796        VO: DefaultSerde + Any + Send + Clone,
797        VTS: Clone,
798        KS: Serde<K> + Clone + 'static,
799        VS: Serde<V> + Clone + 'static,
800        V: Sync,
801        F: Fn(&V, Option<&VT>) -> VO + Clone + Send + Sync + 'static,
802    {
803        let configured_grace = join_config.grace;
804        drop(join_config);
805        let grace = self.build_grace_lowering::<VT, VO, _, VTS>(
806            table,
807            joiner.clone(),
808            true,
809            configured_grace,
810        );
811        self.join_table_impl::<VT, VO, _, VTS>(table, joiner, true, grace)
812    }
813
814    /// Build the [`GraceLowering`] closure for a KIP-923 grace join.
815    ///
816    /// Returns `None` when `joined.grace` is unset. This method holds the `Serde`
817    /// and `Sync` bounds that the grace buffer store and the processor need,
818    /// which the type-erased [`join_table_impl`](Self::join_table_impl) does not
819    /// hold. It also checks the versioned-table precondition and the
820    /// `grace < history_retention` precondition up front.
821    ///
822    /// The impl's lowering thunk runs the returned closure once. The closure
823    /// rebuilds the typed stream parent handle, registers the grace processor and
824    /// its `<join_name>-Buffer` store, connects the join node to the buffer, and
825    /// returns the join node handle. The topology layer derives the changelog
826    /// name `app-<join_name>-Buffer-changelog`.
827    fn build_grace_lowering<VT, VO, LF, VTS>(
828        &self,
829        table: &KTable<K, VT, KS, VTS>,
830        left_form: LF,
831        emit_on_miss: bool,
832        grace: Option<Time>,
833    ) -> Option<GraceLowering<K, VO>>
834    where
835        VT: Any + Send + Sync + Clone,
836        VO: DefaultSerde + Any + Send + Clone,
837        VTS: Clone,
838        KS: Serde<K> + Clone + 'static,
839        VS: Serde<V> + Clone + 'static,
840        V: Sync,
841        LF: Fn(&V, Option<&VT>) -> VO + Clone + Send + Sync + 'static,
842    {
843        let grace = grace?;
844        // KIP-923 grace requires a versioned table (the drain does as-of lookups)
845        // and `grace` strictly below the table's history retention.
846        let retention = table
847            .versioned_retention
848            .expect("grace requires a versioned table");
849        assert!(grace < retention, "grace must be < history_retention");
850
851        let key_serde = self.key_serde.clone();
852        let value_serde = self.value_serde.clone();
853        Some(Box::new(
854            move |state: &mut LowerState,
855                  parent_id: NodeId,
856                  join_name: String,
857                  table_store: String| {
858                let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
859                // KIP-923: buffer store named `<join_name>-Buffer`; the join node
860                // connects to BOTH the buffer (here) and the table store (in the
861                // impl). Same KSTREAM-JOIN node as a plain stream-table join.
862                let buffer_name = format!("{join_name}-Buffer");
863                let store_for_proc = table_store.clone();
864                let buffer_for_proc = buffer_name.clone();
865                let lf = left_form.clone();
866                let h = state.topology.add_processor::<K, V, K, VO, _, _, _>(
867                    join_name.clone(),
868                    move || crate::dsl::processors::join_grace::KStreamKTableJoinGraceProcessor {
869                        table_store: store_for_proc.clone(),
870                        buffer_store: buffer_for_proc.clone(),
871                        grace,
872                        joiner: lf.clone(),
873                        emit_on_miss,
874                        observed_stream_time: i64::MIN,
875                        _pd: PhantomData,
876                    },
877                    [parent],
878                );
879                // Register the grace buffer store (holds stream (K,V)) and connect
880                // the join node to it.
881                state.topology.add_join_grace_store::<K, V, KS, VS>(
882                    buffer_name.clone(),
883                    key_serde.clone(),
884                    value_serde.clone(),
885                    true,
886                    [h.name().to_string()],
887                );
888                state
889                    .topology
890                    .connect_processor_store(h.name(), &buffer_name);
891                h
892            },
893        ))
894    }
895
896    /// Shared lowering for the inner and left stream-table join.
897    ///
898    /// `left_form` is the `Fn(&V, Option<&VT>) -> VO` joiner. `emit_on_miss` is
899    /// `false` for an inner join and `true` for a left join.
900    ///
901    /// This method records a `KSTREAM-JOIN-` processor node wired to this
902    /// stream's node. Its thunk then does three things. It builds a
903    /// [`KStreamKTableJoinProcessor`] that reads the table's store. It calls
904    /// `connect_processor_store` to connect the join to that store, and the union
905    /// pulls the join into the SAME subtopology as the table source, so both
906    /// sources land together. It declares the `(stream_member, table_source)`
907    /// copartition group when both members are single source topics.
908    fn join_table_impl<VT, VO, LF, VTS>(
909        &self,
910        table: &KTable<K, VT, KS, VTS>,
911        left_form: LF,
912        emit_on_miss: bool,
913        grace: Option<GraceLowering<K, VO>>,
914    ) -> KStream<K, VO, KS, <VO as DefaultSerde>::Serde>
915    where
916        VT: Any + Send + Clone,
917        VO: DefaultSerde + Any + Send + Clone,
918        VTS: Clone,
919        LF: Fn(&V, Option<&VT>) -> VO + Clone + Send + Sync + 'static,
920    {
921        assert!(
922            !self.key_changing,
923            "join: the stream key was changed upstream (map/select_key/flat_map/group_by); \
924             call `.repartition(..)` before joining to re-partition by the new key"
925        );
926        let table_store = table
927            .store_name()
928            .expect("join requires a materialized table (a store-backed KTable)")
929            .to_string();
930        let table_src = table.source_topic().map(str::to_string);
931        // Versioned tables (KIP-889 history retention set) route to the as-of
932        // join processor (KIP-914): the lookup is `get_as_of(key, streamRec.ts)`
933        // instead of latest `get`. The `table` handle is not available inside the
934        // lowering thunk, so capture this flag here.
935        let table_versioned = table.versioned_retention.is_some();
936        // The stream-side copartition member is this stream's single source topic
937        // (key unchanged → still copartitioned with that topic). `None` if the
938        // stream has no single source topic (multi-topic source, prior merge, …).
939        let stream_member = self.source_topic.clone();
940
941        let parent_id = self.node;
942        let mut g = self.builder.borrow_mut();
943        let join_name = g.new_processor_name(names::JOIN);
944        let join_id = g.graph.add(
945            join_name.clone(),
946            // The join lowers to a plain processor node (its store connection +
947            // copartition declaration happen in the thunk); reusing the stateless
948            // kind keeps the optimizer passes (which only inspect Repartition /
949            // TableSource) correctly skipping it.
950            GraphNodeKind::StatelessProcessor {
951                repartition_required: false,
952            },
953            vec![parent_id],
954        );
955        let store_for_thunk = table_store.clone();
956        g.graph.nodes[join_id].lower = Some(Box::new(move |state: &mut LowerState| {
957            // Three processor branches, all registering the SAME node name with the
958            // SAME parent + (below) the SAME table-store wiring + copartition group:
959            //   grace (Some)  → grace processor + its own buffer store (built by the
960            //                   boxed `GraceLowering` closure, which holds the
961            //                   stream-serde + `V: Sync` bounds the buffer needs),
962            //   versioned     → as-of `get_as_of` processor,
963            //   plain         → latest `get` processor.
964            let h = if let Some(grace_lower) = grace {
965                // The closure rebuilds the typed parent handle, registers the grace
966                // processor + `<join_name>-Buffer` store, and connects the join to
967                // the buffer. The table-store connect + copartition happen below.
968                grace_lower(state, parent_id, join_name.clone(), store_for_thunk.clone())
969            } else if table_versioned {
970                let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
971                let store_for_proc = store_for_thunk.clone();
972                let lf = left_form.clone();
973                state.topology.add_processor::<K, V, K, VO, _, _, _>(
974                    join_name.clone(),
975                    move || crate::dsl::processors::join::KStreamKTableJoinAsOfProcessor {
976                        table_store: store_for_proc.clone(),
977                        joiner: lf.clone(),
978                        emit_on_miss,
979                        _pd: PhantomData,
980                    },
981                    [parent],
982                )
983            } else {
984                let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
985                let store_for_proc = store_for_thunk.clone();
986                let lf = left_form.clone();
987                state.topology.add_processor::<K, V, K, VO, _, _, _>(
988                    join_name.clone(),
989                    move || KStreamKTableJoinProcessor {
990                        table_store: store_for_proc.clone(),
991                        joiner: lf.clone(),
992                        emit_on_miss,
993                        _pd: PhantomData,
994                    },
995                    [parent],
996                )
997            };
998            // Union the join into the table store's processor set: this pulls the
999            // join (and therefore the stream source feeding it) into the SAME
1000            // subtopology as the table source that owns the store.
1001            state
1002                .topology
1003                .connect_processor_store(h.name(), &store_for_thunk);
1004            // Declare the copartition group when both sides are single source
1005            // topics. The grouping pass assigns it to the subtopology and the wire
1006            // layer encodes the members as int16 indices into the sorted sources.
1007            if let (Some(sm), Some(ts)) = (&stream_member, &table_src) {
1008                state
1009                    .topology
1010                    .add_copartition_group([sm.clone(), ts.clone()]);
1011            }
1012            state.handle_name.insert(join_id, h.name().to_string());
1013        }));
1014        drop(g);
1015        // The joined stream keeps the key but no longer maps to a single source
1016        // topic (it is the join output), so its source-topic lineage is `None`.
1017        KStream::new_with_key_changing(
1018            Rc::clone(&self.builder),
1019            join_id,
1020            false,
1021            self.key_serde.clone(),
1022            <VO as DefaultSerde>::Serde::default(),
1023        )
1024    }
1025
1026    /// `join` (inner stream-globaltable join): look each record up in `global`.
1027    ///
1028    /// For each record on this stream, this method computes the lookup key
1029    /// `gk = key_mapper(&streamKey, &streamValue)` and looks it up in `global`'s
1030    /// fully-replicated store. **Only on a hit**, it forwards
1031    /// `joiner(&stream_value, &global_value)` keyed by the **stream key** with
1032    /// the stream timestamp. It drops records whose derived key is absent.
1033    ///
1034    /// The lookup key is *derived* from the record, unlike
1035    /// [`join_table`](Self::join_table), so it can differ from the stream key. A
1036    /// `GlobalKTable` is fully replicated, so there is **no copartitioning, no
1037    /// repartition, and no key-changing assertion**. Any record can look up any
1038    /// key on every instance.
1039    ///
1040    /// [`GlobalKTable`]: crate::dsl::global_table::GlobalKTable
1041    #[must_use]
1042    /// # Panics
1043    /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
1044    pub fn join_global<GK, VG, VR, KM, J, GKS, VGS>(
1045        &self,
1046        global: &crate::dsl::global_table::GlobalKTable<GK, VG, GKS, VGS>,
1047        key_mapper: KM,
1048        joiner: J,
1049    ) -> KStream<K, VR, KS, <VR as DefaultSerde>::Serde>
1050    where
1051        GK: Any + Send + Sync + 'static,
1052        VG: Any + Send + Clone,
1053        VR: DefaultSerde + Any + Send + Clone,
1054        GKS: Clone,
1055        VGS: Clone,
1056        KM: Fn(&K, &V) -> GK + Clone + Send + Sync + 'static,
1057        J: Fn(&V, &VG) -> VR + Clone + Send + Sync + 'static,
1058    {
1059        // Wrap the inner joiner to the left form `Fn(&V, Option<&VG>) -> VR`; with
1060        // `emit_on_miss = false` the closure is only ever called with `Some`.
1061        let jf = move |v: &V, opt: Option<&VG>| joiner(v, opt.expect("inner global join hit"));
1062        self.join_global_impl::<GK, VG, VR, KM, _, GKS, VGS>(global, key_mapper, jf, false)
1063    }
1064
1065    /// `leftJoin` (left stream-globaltable join): like
1066    /// [`join_global`](Self::join_global) but always forwards a record for every
1067    /// stream record. On a global-store miss the `joiner` receives `None` for the
1068    /// global-side value.
1069    #[must_use]
1070    pub fn left_join_global<GK, VG, VR, KM, J, GKS, VGS>(
1071        &self,
1072        global: &crate::dsl::global_table::GlobalKTable<GK, VG, GKS, VGS>,
1073        key_mapper: KM,
1074        joiner: J,
1075    ) -> KStream<K, VR, KS, <VR as DefaultSerde>::Serde>
1076    where
1077        GK: Any + Send + Sync + 'static,
1078        VG: Any + Send + Clone,
1079        VR: DefaultSerde + Any + Send + Clone,
1080        GKS: Clone,
1081        VGS: Clone,
1082        KM: Fn(&K, &V) -> GK + Clone + Send + Sync + 'static,
1083        J: Fn(&V, Option<&VG>) -> VR + Clone + Send + Sync + 'static,
1084    {
1085        self.join_global_impl::<GK, VG, VR, KM, _, GKS, VGS>(global, key_mapper, joiner, true)
1086    }
1087
1088    /// Shared lowering for the inner and left stream-globaltable join.
1089    ///
1090    /// `left_form` is the `Fn(&V, Option<&VG>) -> VR` joiner. `emit_on_miss` is
1091    /// `false` for an inner join and `true` for a left join.
1092    ///
1093    /// This method records a `KSTREAM-GLOBALTABLE-JOIN-` processor node wired to
1094    /// this stream's node. Its thunk builds a
1095    /// [`KStreamGlobalTableJoinProcessor`] that reads the global table's store by
1096    /// name through the global registry accessor. Unlike the stream-table join,
1097    /// there is **no** copartition group and **no** `connect_processor_store`,
1098    /// because the global store is fully replicated and the processor reaches it
1099    /// through the global registry, not through a copartitioned subtopology.
1100    /// There is also **no** `key_changing` assertion, because the lookup key is
1101    /// derived and is not the stream key.
1102    fn join_global_impl<GK, VG, VR, KM, LF, GKS, VGS>(
1103        &self,
1104        global: &crate::dsl::global_table::GlobalKTable<GK, VG, GKS, VGS>,
1105        key_mapper: KM,
1106        left_form: LF,
1107        emit_on_miss: bool,
1108    ) -> KStream<K, VR, KS, <VR as DefaultSerde>::Serde>
1109    where
1110        GK: Any + Send + Sync + 'static,
1111        VG: Any + Send + Clone,
1112        VR: DefaultSerde + Any + Send + Clone,
1113        GKS: Clone,
1114        VGS: Clone,
1115        KM: Fn(&K, &V) -> GK + Clone + Send + Sync + 'static,
1116        LF: Fn(&V, Option<&VG>) -> VR + Clone + Send + Sync + 'static,
1117    {
1118        let store_name = global.store_name().to_string();
1119
1120        let parent_id = self.node;
1121        let mut g = self.builder.borrow_mut();
1122        let join_name = g.new_processor_name(names::GLOBALTABLE_JOIN);
1123        let join_id = g.graph.add(
1124            join_name.clone(),
1125            // Reuse the stateless kind: the global join lowers to a plain processor
1126            // node with no repartition and no store-connection/copartition wiring,
1127            // so the optimizer passes (Repartition / TableSource) correctly skip it.
1128            GraphNodeKind::StatelessProcessor {
1129                repartition_required: false,
1130            },
1131            vec![parent_id],
1132        );
1133        g.graph.nodes[join_id].lower = Some(Box::new(move |state: &mut LowerState| {
1134            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
1135            let store_for_proc = store_name.clone();
1136            let km = key_mapper.clone();
1137            let jf = left_form.clone();
1138            let h = state.topology.add_processor::<K, V, K, VR, _, _, _>(
1139                join_name.clone(),
1140                move || KStreamGlobalTableJoinProcessor {
1141                    store_name: store_for_proc.clone(),
1142                    key_mapper: km.clone(),
1143                    joiner: jf.clone(),
1144                    emit_on_miss,
1145                    _pd: PhantomData,
1146                },
1147                [parent],
1148            );
1149            state.handle_name.insert(join_id, h.name().to_string());
1150        }));
1151        drop(g);
1152        // The joined stream keeps the stream key but is a join output, so it no
1153        // longer maps to a single source topic.
1154        KStream::new_with_key_changing(
1155            Rc::clone(&self.builder),
1156            join_id,
1157            false,
1158            self.key_serde.clone(),
1159            <VR as DefaultSerde>::Serde::default(),
1160        )
1161    }
1162
1163    /// `join` (windowed inner stream-stream join): match records over a window.
1164    ///
1165    /// For each record on either stream, the join puts the record into its own
1166    /// [`JoinWindowStore`] and fetches the OTHER stream's store over the join
1167    /// window. It forwards one output per match, keyed by the same key. A left
1168    /// record at `t` matches right records with a timestamp in
1169    /// `[t - before, t + after]`. The rule is symmetric: the per-side OTHER
1170    /// processor swaps `before` and `after`, so the rule holds whichever side
1171    /// drives the record.
1172    ///
1173    /// The join lowers to a **THIS** processor fed by this stream, an **OTHER**
1174    /// processor fed by `other`, and a **MERGE** node that unions the two
1175    /// outputs. This mirrors [`KTable::join`]'s dual+merge shape. Each side puts
1176    /// into its own `retainDuplicates` join-window store and reads the other
1177    /// side's store. Each side connects to BOTH stores, so the grouping pass
1178    /// folds A, B, both stores, and the two join nodes into one copartitioned
1179    /// subtopology. When both streams are single-source-topic streams, the join
1180    /// declares their source topics as a copartition group (KIP-1071).
1181    ///
1182    /// Both stream keys must be unchanged relative to their source partitioning.
1183    /// You must call `repartition()` after a key-changing op upstream. If you do
1184    /// not, the join panics.
1185    ///
1186    /// # Naming vs the JVM
1187    /// The JVM overloads `join` for stream-table and stream-stream. Rust cannot, so
1188    /// the windowed stream-stream join keeps `join`/`left_join` while the
1189    /// stream-table forms are [`join_table`](Self::join_table)/
1190    /// [`left_join_table`](Self::left_join_table).
1191    ///
1192    /// [`JoinWindowStore`]: crate::store::join_window::JoinWindowStore
1193    /// [`KTable::join`]: crate::dsl::ktable::KTable::join
1194    #[must_use]
1195    /// # Panics
1196    /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
1197    pub fn join<V2, VO, F, V2S>(
1198        &self,
1199        other: &KStream<K, V2, KS, V2S>,
1200        joiner: F,
1201        windows: JoinWindows,
1202        stream_joined: StreamJoined<KS, VS, V2S>,
1203    ) -> KStream<K, VO, KS, <VO as DefaultSerde>::Serde>
1204    where
1205        V2: Any + Send + Sync + Clone,
1206        VO: DefaultSerde + Any + Send + Clone,
1207        F: Fn(&V, &V2) -> VO + Clone + Send + Sync + 'static,
1208        KS: Serde<K> + Clone + 'static,
1209        VS: Serde<V> + Clone + 'static,
1210        V2S: Serde<V2> + Clone + 'static,
1211        V: Send + Sync,
1212    {
1213        // Lift the inner joiner to the shared outer form: a match passes `Some`/`Some`
1214        // (a null result never occurs for an inner join, so `expect` is unreachable).
1215        let j =
1216            move |a: Option<&V>, b: Option<&V2>| joiner(a.expect("inner a"), b.expect("inner b"));
1217        let j: SharedOuterJoiner<V, V2, VO> = Arc::new(j);
1218        self.join_impl::<V2, VO, V2S>(other, &j, windows, stream_joined, JoinKind::inner())
1219    }
1220
1221    /// `leftJoin` (windowed left stream-stream join): emit unmatched left records.
1222    ///
1223    /// Every record on THIS (left) stream that finds no match in the OTHER
1224    /// (right) window emits `joiner(&this, None)` once that record's window
1225    /// closes. Stream time drives the window close (KIP-633). Matched records
1226    /// emit `joiner(&this, Some(&other))`, as in the inner join. The right side
1227    /// never emits a non-join.
1228    #[must_use]
1229    /// # Panics
1230    /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
1231    pub fn left_join<V2, VO, F, V2S>(
1232        &self,
1233        other: &KStream<K, V2, KS, V2S>,
1234        joiner: F,
1235        windows: JoinWindows,
1236        stream_joined: StreamJoined<KS, VS, V2S>,
1237    ) -> KStream<K, VO, KS, <VO as DefaultSerde>::Serde>
1238    where
1239        V2: Any + Send + Sync + Clone,
1240        VO: DefaultSerde + Any + Send + Clone,
1241        F: Fn(&V, Option<&V2>) -> VO + Clone + Send + Sync + 'static,
1242        KS: Serde<K> + Clone + 'static,
1243        VS: Serde<V> + Clone + 'static,
1244        V2S: Serde<V2> + Clone + 'static,
1245        V: Send + Sync,
1246    {
1247        // Left form: the left (A) side may receive `None` for B; the A side is always
1248        // present (the join never fires from a non-existent A).
1249        let j = move |a: Option<&V>, b: Option<&V2>| joiner(a.expect("left a"), b);
1250        let j: SharedOuterJoiner<V, V2, VO> = Arc::new(j);
1251        self.join_impl::<V2, VO, V2S>(other, &j, windows, stream_joined, JoinKind::left())
1252    }
1253
1254    /// `outerJoin` (windowed outer stream-stream join): emit from either side.
1255    ///
1256    /// Every record on EITHER side that finds no match emits
1257    /// `joiner(Some, None)` or `joiner(None, Some)` once its window closes
1258    /// (KIP-633). Matched records emit `joiner(Some, Some)`.
1259    #[must_use]
1260    pub fn outer_join<V2, VO, F, V2S>(
1261        &self,
1262        other: &KStream<K, V2, KS, V2S>,
1263        joiner: F,
1264        windows: JoinWindows,
1265        stream_joined: StreamJoined<KS, VS, V2S>,
1266    ) -> KStream<K, VO, KS, <VO as DefaultSerde>::Serde>
1267    where
1268        V2: Any + Send + Sync + Clone,
1269        VO: DefaultSerde + Any + Send + Clone,
1270        F: Fn(Option<&V>, Option<&V2>) -> VO + Clone + Send + Sync + 'static,
1271        KS: Serde<K> + Clone + 'static,
1272        VS: Serde<V> + Clone + 'static,
1273        V2S: Serde<V2> + Clone + 'static,
1274        V: Send + Sync,
1275    {
1276        // The user joiner is already in outer form.
1277        let joiner: SharedOuterJoiner<V, V2, VO> = Arc::new(joiner);
1278        self.join_impl::<V2, VO, V2S>(other, &joiner, windows, stream_joined, JoinKind::outer())
1279    }
1280
1281    /// Shared dual+merge lowering for windowed stream-stream joins.
1282    ///
1283    /// `outer_joiner` is the shared outer-form joiner
1284    /// `Fn(Option<&V>, Option<&V2>) -> VO`. Each per-side processor wraps it, so
1285    /// a match passes the present sides. The `kind` decides which side emits
1286    /// non-joins. **THIS (A)** emits when B is not required
1287    /// (`!kind.b_required`), that is, for the left and outer joins. **OTHER (B)**
1288    /// emits when A is not required (`!kind.a_required`), that is, for the outer
1289    /// join only.
1290    ///
1291    /// The lowering mirrors [`KTable::join`]'s dual+merge shape. It creates a
1292    /// **THIS** processor fed by this stream, an **OTHER** processor fed by
1293    /// `other`, and a **MERGE** node. Each side puts into its own
1294    /// `retainDuplicates` join-window store and reads the other side's store. For
1295    /// the left and outer joins, one shared `KSTREAM-OUTERSHARED-` KV store
1296    /// buffers unmatched records, and one shared [`TimeTracker`] drives the
1297    /// window-close emission. Both supplier closures get a clone of that tracker,
1298    /// and both join processors connect to that store. For the inner join, this
1299    /// method creates no outer store and no tracker, so the wire topology is
1300    /// byte-identical to the inner-only golden.
1301    ///
1302    /// [`KTable::join`]: crate::dsl::ktable::KTable::join
1303    /// [`TimeTracker`]: crate::dsl::processors::outer_join_store::TimeTracker
1304    // dual+merge lowering: 3 nodes + shared outer store, each a typed thunk
1305    fn join_impl<V2, VO, V2S>(
1306        &self,
1307        other: &KStream<K, V2, KS, V2S>,
1308        outer_joiner: &SharedOuterJoiner<V, V2, VO>,
1309        windows: JoinWindows,
1310        stream_joined: StreamJoined<KS, VS, V2S>,
1311        kind: JoinKind,
1312    ) -> KStream<K, VO, KS, <VO as DefaultSerde>::Serde>
1313    where
1314        V2: Any + Send + Sync + Clone,
1315        VO: DefaultSerde + Any + Send + Clone,
1316        KS: Serde<K> + Clone + 'static,
1317        VS: Serde<V> + Clone + 'static,
1318        V2S: Serde<V2> + Clone + 'static,
1319        V: Send + Sync,
1320    {
1321        let a_src = self.source_topic.clone();
1322        let b_src = other.source_topic.clone();
1323
1324        let parent_id = self.node;
1325        let other_parent_id = other.node;
1326
1327        let (before, after, grace) = (windows.before, windows.after, windows.grace);
1328
1329        let mut g = self.builder.borrow_mut();
1330        let StreamJoinGraph {
1331            join_this,
1332            join_other,
1333            this_store,
1334            other_store,
1335            outer_store,
1336            this_id,
1337            other_id,
1338            merge,
1339        } = allocate_stream_join_graph(
1340            &mut g,
1341            (parent_id, other_parent_id),
1342            (kind.a_required, kind.b_required),
1343        );
1344
1345        let this_store_clone = this_store.clone();
1346        let other_store_clone = other_store.clone();
1347        let outer_store_clone = outer_store.clone();
1348
1349        // One shared TimeTracker tracks the maximum observed stream time across both
1350        // partition driving threads (synchronizing the window close).
1351        let tracker = Arc::new(Mutex::new(TimeTracker::default()));
1352
1353        // We capture both serdes by cloning the stream_joined fields; since they are
1354        // cloneable wrappers, this moves them into the lowering closures safely.
1355        let StreamJoined {
1356            key: ks,
1357            left_value: vs1,
1358            right_value: vs2,
1359        } = stream_joined;
1360
1361        // ── THIS side: fed by `self`; puts into `this_store`, reads `other_store`.
1362        {
1363            let own = this_store_clone.clone();
1364            let other_s = other_store_clone.clone();
1365            let outer_store_this = outer_store_clone.clone();
1366            let tracker_this = Arc::clone(&tracker);
1367            let ks_for_proc = ks.clone();
1368            let vs_for_proc = vs1.clone();
1369            let oj = Arc::clone(outer_joiner);
1370            let this_emit = !kind.b_required; // this side emits non-joins (left & outer)
1371
1372            g.graph.nodes[this_id].lower = Some(Box::new(move |state: &mut LowerState| {
1373                let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
1374                let own_for_proc = own.clone();
1375                let other_for_proc = other_s.clone();
1376                let outer_store_proc = outer_store_this.clone();
1377                let tracker_proc = Arc::clone(&tracker_this);
1378                let ks = ks_for_proc.clone();
1379                let vs = vs_for_proc.clone();
1380                let oj = Arc::clone(&oj);
1381
1382                let ks_for_proc_inner = ks.clone();
1383                let vs_for_proc_inner = vs.clone();
1384                let outer_store_for_proc = outer_store_proc.clone();
1385                let h = state.topology.add_processor::<K, V, K, VO, _, _, _>(
1386                    join_this.clone(),
1387                    move || KStreamKStreamJoinProcessor {
1388                        own_store: own_for_proc.clone(),
1389                        other_store: other_for_proc.clone(),
1390                        fetch_before: before,
1391                        fetch_after: after,
1392                        joiner: {
1393                            let oj = Arc::clone(&oj);
1394                            move |a: &V, b: Option<&V2>| oj(Some(a), b)
1395                        },
1396                        side_left: true,
1397                        emit_unmatched: this_emit,
1398                        outer_store: outer_store_for_proc.clone(),
1399                        tracker: outer_store_for_proc
1400                            .as_ref()
1401                            .map(|_| Arc::clone(&tracker_proc)),
1402                        key_serde: outer_store_for_proc
1403                            .as_ref()
1404                            .map(|_| Box::new(ks_for_proc_inner.clone()) as Box<dyn Serde<K>>),
1405                        value_serde: outer_store_for_proc
1406                            .as_ref()
1407                            .map(|_| Box::new(vs_for_proc_inner.clone()) as Box<dyn Serde<V>>),
1408                        before,
1409                        after,
1410                        grace,
1411                        _pd: PhantomData,
1412                    },
1413                    [parent],
1414                );
1415                // Register the THIS store (holds V) + connect to BOTH stores.
1416                state.topology.add_join_window_store::<K, V, KS, VS>(
1417                    own.clone(),
1418                    ks.clone(),
1419                    vs.clone(),
1420                    (before, after, grace),
1421                    [h.name().to_string()],
1422                );
1423                state.topology.connect_processor_store(h.name(), &own);
1424                state.topology.connect_processor_store(h.name(), &other_s);
1425                // For left/outer, register the shared outer KV store ONCE here and
1426                // connect this processor to it. (The OTHER thunk only connects.)
1427                if let Some(os) = &outer_store_proc {
1428                    state
1429                        .topology
1430                        .add_state_store::<Bytes, Bytes, BytesSerde, BytesSerde>(
1431                            os.clone(),
1432                            BytesSerde,
1433                            BytesSerde,
1434                            [h.name().to_string()],
1435                        );
1436                }
1437                state.handle_name.insert(this_id, h.name().to_string());
1438            }));
1439        }
1440
1441        // ── OTHER side: fed by `other`; puts into `other_store`, reads `this_store`.
1442        {
1443            let own = other_store_clone.clone();
1444            let other_s = this_store_clone.clone();
1445            let outer_store_other = outer_store_clone.clone();
1446            let tracker_other = Arc::clone(&tracker);
1447            let ks_for_proc = ks.clone();
1448            let vs_for_proc = vs2.clone();
1449            let oj = Arc::clone(outer_joiner);
1450            let other_emit = !kind.a_required; // other side emits non-joins (outer only)
1451
1452            g.graph.nodes[other_id].lower = Some(Box::new(move |state: &mut LowerState| {
1453                let parent =
1454                    NodeHandle::<K, V2>::from_name(state.handle_name[&other_parent_id].clone());
1455                let own_for_proc = own.clone();
1456                let other_for_proc = other_s.clone();
1457                let outer_store_proc = outer_store_other.clone();
1458                let tracker_proc = Arc::clone(&tracker_other);
1459                let ks = ks_for_proc.clone();
1460                let vs = vs_for_proc.clone();
1461                let oj = Arc::clone(&oj);
1462
1463                let ks_for_proc_inner = ks.clone();
1464                let vs_for_proc_inner = vs.clone();
1465                let outer_store_for_proc = outer_store_proc.clone();
1466                let h = state.topology.add_processor::<K, V2, K, VO, _, _, _>(
1467                    join_other.clone(),
1468                    move || KStreamKStreamJoinProcessor {
1469                        own_store: own_for_proc.clone(),
1470                        other_store: other_for_proc.clone(),
1471                        // SWAPPED relative to the named window: from the OTHER
1472                        // (right) record's perspective, the left side lives in
1473                        // `[t - after, t + before]`.
1474                        fetch_before: after,
1475                        fetch_after: before,
1476                        joiner: {
1477                            let oj = Arc::clone(&oj);
1478                            move |b: &V2, a: Option<&V>| oj(a, Some(b))
1479                        },
1480                        side_left: false,
1481                        emit_unmatched: other_emit,
1482                        outer_store: outer_store_for_proc.clone(),
1483                        tracker: outer_store_for_proc
1484                            .as_ref()
1485                            .map(|_| Arc::clone(&tracker_proc)),
1486                        key_serde: outer_store_for_proc
1487                            .as_ref()
1488                            .map(|_| Box::new(ks_for_proc_inner.clone()) as Box<dyn Serde<K>>),
1489                        value_serde: outer_store_for_proc
1490                            .as_ref()
1491                            .map(|_| Box::new(vs_for_proc_inner.clone()) as Box<dyn Serde<V2>>),
1492                        before,
1493                        after,
1494                        grace,
1495                        _pd: PhantomData,
1496                    },
1497                    [parent],
1498                );
1499                // Register the OTHER store (holds V2) + connect to BOTH stores.
1500                state.topology.add_join_window_store::<K, V2, KS, V2S>(
1501                    own.clone(),
1502                    ks.clone(),
1503                    vs.clone(),
1504                    (before, after, grace),
1505                    [h.name().to_string()],
1506                );
1507                state.topology.connect_processor_store(h.name(), &own);
1508                state.topology.connect_processor_store(h.name(), &other_s);
1509                // For left/outer, connect this processor to the shared outer store
1510                // (registered by the THIS thunk).
1511                if let Some(os) = &outer_store_proc {
1512                    state.topology.connect_processor_store(h.name(), os);
1513                }
1514                state.handle_name.insert(other_id, h.name().to_string());
1515            }));
1516        }
1517
1518        // ── MERGE: union the two join outputs (forwards each record unchanged).
1519        let merge_id = g.graph.add(
1520            merge.clone(),
1521            GraphNodeKind::StatelessProcessor {
1522                repartition_required: false,
1523            },
1524            vec![this_id, other_id],
1525        );
1526        g.graph.nodes[merge_id].merge_node = true;
1527        g.graph.nodes[merge_id].lower = Some(Box::new(move |state: &mut LowerState| {
1528            let this_parent = NodeHandle::<K, VO>::from_name(state.handle_name[&this_id].clone());
1529            let other_parent = NodeHandle::<K, VO>::from_name(state.handle_name[&other_id].clone());
1530            let h = state.topology.add_processor::<K, VO, K, VO, _, _, _>(
1531                merge.clone(),
1532                || stateless::MergeProcessor::<K, VO> { _pd: PhantomData },
1533                [this_parent, other_parent],
1534            );
1535            // Declare the copartition group when both streams are single-source.
1536            if let (Some(a), Some(bb)) = (&a_src, &b_src) {
1537                state
1538                    .topology
1539                    .add_copartition_group([a.clone(), bb.clone()]);
1540            }
1541            state.handle_name.insert(merge_id, h.name().to_string());
1542        }));
1543        drop(g);
1544        // The join output keeps the key but no longer maps to a single source topic.
1545        KStream::new_with_key_changing(
1546            Rc::clone(&self.builder),
1547            merge_id,
1548            false,
1549            self.key_serde.clone(),
1550            <VO as DefaultSerde>::Serde::default(),
1551        )
1552    }
1553
1554    /// `repartition`: force a repartition through an internal topic.
1555    ///
1556    /// This op lowers as `sink → add_repartition_topic → source`. It is the same
1557    /// pattern that the implicit repartition before a stateful aggregation uses.
1558    /// The repartition topic name is `<app_id>-<name>-repartition`. `<name>` is
1559    /// the explicit [`Repartitioned`](crate::dsl::config::Repartitioned) name when
1560    /// set, otherwise an auto-name minted from the counter.
1561    ///
1562    /// **Byte-exactness vs JVM:** the JVM assigns a distinct
1563    /// `KSTREAM-REPARTITION-` counter for standalone `repartition()` calls. No
1564    /// golden fixture validates that counter. The bar here is functional
1565    /// correctness: no panic, and records flow through.
1566    #[must_use]
1567    pub fn repartition<KS2, VS2>(
1568        &self,
1569        repartitioned: crate::dsl::config::Repartitioned<KS2, VS2>,
1570    ) -> KStream<K, V, KS2, VS2>
1571    where
1572        KS2: Serde<K> + Clone + 'static,
1573        VS2: Serde<V> + Clone + 'static,
1574    {
1575        let crate::dsl::config::Repartitioned {
1576            name: explicit_name,
1577            partitions,
1578            key_serde,
1579            value_serde,
1580        } = repartitioned;
1581        let parent_id = self.node;
1582        let mut g = self.builder.borrow_mut();
1583        // Mint the base name: explicit name or a fresh counter.
1584        let base = explicit_name.unwrap_or_else(|| g.new_processor_name(names::SOURCE));
1585        // Sink + source names used inside the thunk (minted now to advance
1586        // the counter at the right position relative to later ops).
1587        let sink_name = g.new_processor_name(names::SINK);
1588        let source_name = g.new_processor_name(names::SOURCE);
1589        let topic_base = base.clone();
1590        let id = g.graph.add(
1591            source_name.clone(),
1592            GraphNodeKind::Repartition {
1593                topic: format!("{topic_base}{}", names::REPARTITION_SUFFIX),
1594                partitions,
1595            },
1596            vec![parent_id],
1597        );
1598        let key_serde_clone = key_serde.clone();
1599        let value_serde_clone = value_serde.clone();
1600        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
1601            let parent_name = state.handle_name[&parent_id].clone();
1602            let parent = NodeHandle::<K, V>::from_name(parent_name);
1603            let topic = format!("{}-{topic_base}{}", state.app_id, names::REPARTITION_SUFFIX);
1604            // sink: write to repartition topic
1605            state.topology.add_sink_explicit::<K, V, KS2, VS2, _, _>(
1606                sink_name.clone(),
1607                topic.clone(),
1608                [parent],
1609                crate::processor::serde::Produced::with(
1610                    key_serde_clone.clone(),
1611                    value_serde_clone.clone(),
1612                ),
1613            );
1614            // mark the topic as internal repartition (loop-back)
1615            state.topology.add_repartition_topic(topic.clone());
1616            // source: read from repartition topic
1617            state.topology.add_source_explicit::<K, V, KS2, VS2>(
1618                source_name.clone(),
1619                [topic],
1620                crate::processor::serde::Consumed::with(
1621                    key_serde_clone.clone(),
1622                    value_serde_clone.clone(),
1623                ),
1624            );
1625            state.handle_name.insert(id, source_name.clone());
1626        }));
1627        drop(g);
1628        // An explicit repartition re-groups by key → downstream is no longer
1629        // key-changing relative to its (now repartitioned) partitioning.
1630        KStream::new_with_key_changing(Rc::clone(&self.builder), id, false, key_serde, value_serde)
1631    }
1632
1633    /// `split`: begin a branching fan-out.
1634    ///
1635    /// Returns a [`BranchedStream`] builder. Individual
1636    /// [`branch`](BranchedStream::branch) calls on that builder create filtered
1637    /// child streams. The split itself adds no node to the topology. Each
1638    /// `branch` call creates a filter-backed child wired directly to this
1639    /// stream's node.
1640    ///
1641    /// **Simplification vs JVM:** each branch receives a record when its predicate
1642    /// matches, not just the first matching branch. For mutually-exclusive
1643    /// predicates the behaviour is identical to the JVM first-match-wins semantics.
1644    ///
1645    /// [`branch`]: BranchedStream::branch
1646    #[must_use]
1647    pub fn split(&self) -> BranchedStream<K, V, KS, VS> {
1648        // The JVM mints a `KSTREAM-BRANCH-` node at split() time (counter-only:
1649        // the branch node itself is not wire-visible). Advance the counter here so
1650        // that downstream auto-names (e.g. from a subsequent aggregation) land at
1651        // the same indices as the JVM byte-for-byte. Mirrors the FILTER counter
1652        // mint in `KGroupedStream::record_repartition`.
1653        let _branch_name = self.builder.borrow_mut().new_processor_name(names::BRANCH);
1654        BranchedStream {
1655            builder: Rc::clone(&self.builder),
1656            parent: self.node,
1657            key_changing: self.key_changing,
1658            source_topic: self.source_topic.clone(),
1659            key_serde: self.key_serde.clone(),
1660            value_serde: self.value_serde.clone(),
1661            _pd: std::marker::PhantomData,
1662        }
1663    }
1664
1665    /// `groupByKey`: group by the existing key, ready for an aggregation.
1666    ///
1667    /// This op records no graph node. A terminal `count`, `reduce`, or
1668    /// `aggregate` call records the optional repartition and the aggregate node.
1669    /// The returned [`KGroupedStream`] carries two things: whether the upstream
1670    /// key lineage is key-changing, which forces the aggregation to insert a
1671    /// repartition, and a typed repartition-lowering thunk built from the
1672    /// `Grouped` serdes.
1673    pub fn group_by_key_explicit<GKS, GVS>(
1674        &self,
1675        grouped: impl Into<Grouped<GKS, GVS>>,
1676    ) -> KGroupedStream<K, V>
1677    where
1678        GKS: Serde<K> + Clone + 'static,
1679        GVS: Serde<V> + Clone + 'static,
1680    {
1681        let grouped = grouped.into();
1682        KGroupedStream::new(
1683            Rc::clone(&self.builder),
1684            self.node,
1685            self.key_changing,
1686            grouped.name,
1687            crate::dsl::kgrouped::repartition_lower::<K, V, GKS, GVS>(
1688                grouped.key_serde,
1689                grouped.value_serde,
1690            ),
1691        )
1692        .with_source_topic(if self.key_changing {
1693            None
1694        } else {
1695            self.source_topic.clone()
1696        })
1697    }
1698
1699    /// `groupByKey` using the stream's existing serdes.
1700    pub fn group_by_key(&self) -> KGroupedStream<K, V>
1701    where
1702        KS: Serde<K> + Clone + 'static,
1703        VS: Serde<V> + Clone + 'static,
1704    {
1705        KGroupedStream::new(
1706            Rc::clone(&self.builder),
1707            self.node,
1708            self.key_changing,
1709            None,
1710            crate::dsl::kgrouped::repartition_lower::<K, V, KS, VS>(
1711                self.key_serde.clone(),
1712                self.value_serde.clone(),
1713            ),
1714        )
1715        .with_source_topic(if self.key_changing {
1716            None
1717        } else {
1718            self.source_topic.clone()
1719        })
1720    }
1721
1722    /// `groupBy`: re-key with `f`, then group by the new key.
1723    ///
1724    /// This op is equivalent to `select_key(f).group_by_key_explicit(grouped)`.
1725    /// The key change forces a repartition before any later aggregation.
1726    pub fn group_by<GKS, GVS, F>(
1727        &self,
1728        f: F,
1729        grouped: impl Into<Grouped<GKS, GVS>>,
1730    ) -> KGroupedStream<GKS::Target, V>
1731    where
1732        K: Default,
1733        GKS: SerdeAssociate + Serde<GKS::Target> + Clone + 'static,
1734        GVS: SerdeAssociate + Serde<V> + Clone + 'static,
1735        GKS::Target: Any + Send + Sync + Clone,
1736        F: Fn(&K, &V) -> GKS::Target + Clone + Send + Sync + 'static,
1737    {
1738        let grouped = grouped.into();
1739        self.select_key_with_serde(f, grouped.key_serde.clone())
1740            .group_by_key_explicit(grouped)
1741    }
1742
1743    /// `process`: attach a custom Processor-API node that may rewrite the key.
1744    ///
1745    /// `supplier` is any user [`ProcessorSupplier`], for example a `|| MyProc`
1746    /// closure. `store_names` names the stores that
1747    /// [`StreamsBuilder::add_state_store`] registered and that the processor
1748    /// reads and writes through [`ProcessorContext::get_state_store`]. This
1749    /// method looks up each named store's connect thunk **now**, so you must add
1750    /// the store before this call. Lowering then invokes each thunk to register
1751    /// the store, emit its compact `<app>-<store>-changelog` changelog, and
1752    /// connect the store to this processor node.
1753    ///
1754    /// This op mirrors the JVM `KStream.process`. It treats the result as
1755    /// **key-changing**, because the processor may call `forward` with any key.
1756    /// The single-source-topic lineage therefore breaks, and a downstream
1757    /// aggregation or join must `repartition` first.
1758    ///
1759    /// # Panics
1760    /// Panics if any name in `store_names` was not registered through
1761    /// [`add_state_store`](crate::dsl::builder::StreamsBuilder::add_state_store).
1762    ///
1763    /// [`ProcessorSupplier`]: crate::processor::api::ProcessorSupplier
1764    /// [`ProcessorContext::get_state_store`]: crate::processor::api::ProcessorContext::get_state_store
1765    /// [`StreamsBuilder::add_state_store`]: crate::dsl::builder::StreamsBuilder::add_state_store
1766    pub fn process<KOut, VOut, PS>(
1767        &self,
1768        supplier: PS,
1769        store_names: impl IntoIterator<Item = impl Into<String>>,
1770    ) -> KStream<KOut, VOut, <KOut as DefaultSerde>::Serde, <VOut as DefaultSerde>::Serde>
1771    where
1772        KOut: DefaultSerde + Any + Send + Sync + Clone,
1773        VOut: DefaultSerde + Any + Send + Clone,
1774        PS: crate::processor::api::ProcessorSupplier<K, V, KOut, VOut> + Clone + 'static,
1775    {
1776        let stores: Vec<String> = store_names.into_iter().map(Into::into).collect();
1777        // `add_state_store` must precede `process`; look up each connect thunk now so
1778        // a missing store panics here (at call time) rather than during lowering.
1779        let thunks: Vec<crate::dsl::builder::StoreConnectThunk> = {
1780            let g = self.builder.borrow();
1781            stores
1782                .iter()
1783                .map(|s| {
1784                    g.store_thunk(s).unwrap_or_else(|| {
1785                        panic!(
1786                            "process references store '{s}' that was not added via add_state_store"
1787                        )
1788                    })
1789                })
1790                .collect()
1791        };
1792        let parent_id = self.node;
1793        let mut g = self.builder.borrow_mut();
1794        let name = g.new_processor_name(names::KSTREAM_PROCESSOR);
1795        let id = g.graph.add(
1796            name.clone(),
1797            GraphNodeKind::StatelessProcessor {
1798                repartition_required: false,
1799            },
1800            vec![parent_id],
1801        );
1802        g.graph.nodes[id].key_changing_operation = true;
1803        // The supplier is moved into the lower thunk; the thunk clones it per task
1804        // when `add_processor` runs (`PS: Clone`), so the supplier closure type need
1805        // not itself be re-instantiable.
1806        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
1807            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
1808            let h = state.topology.add_processor::<K, V, KOut, VOut, _, _, _>(
1809                name.clone(),
1810                supplier.clone(),
1811                [parent],
1812            );
1813            let proc_name = h.name().to_string();
1814            // Register + connect each named store to this processor (the connect
1815            // thunks carry the store serdes and emit the compact changelog).
1816            for t in &thunks {
1817                t(state, &proc_name);
1818            }
1819            state.handle_name.insert(id, proc_name);
1820        }));
1821        drop(g);
1822        // process MAY change the key → key-changing; source-topic lineage broken.
1823        KStream::new_with_key_changing(
1824            Rc::clone(&self.builder),
1825            id,
1826            true,
1827            <KOut as DefaultSerde>::Serde::default(),
1828            <VOut as DefaultSerde>::Serde::default(),
1829        )
1830    }
1831
1832    /// `processValues`: attach a custom fixed-key Processor-API node (KIP-820).
1833    ///
1834    /// The node may rewrite the value but NOT the key.
1835    ///
1836    /// `supplier` is any [`FixedKeyProcessorSupplier`], for example a
1837    /// `|| MyFixedProc` closure. `store_names` names the stores that
1838    /// [`add_state_store`] registered and that the processor reads and writes
1839    /// through [`FixedKeyProcessorContext::get_state_store`]. As with
1840    /// [`process`](Self::process), this method looks up each named store's
1841    /// connect thunk **now**, so you must add the store before this call.
1842    /// Lowering then invokes each thunk to register the store, emit its compact
1843    /// `<app>-<store>-changelog` changelog, and connect the store to this
1844    /// processor node.
1845    ///
1846    /// Unlike [`process`](Self::process), this op keeps the key. The result is
1847    /// therefore **non-key-changing** and carries the single-source-topic lineage
1848    /// unchanged, so a downstream aggregation or join needs no repartition. An
1849    /// internal adapter wraps the supplier, so the runtime sees a
1850    /// `Processor<K, V, K, VOut>`.
1851    ///
1852    /// # Panics
1853    /// Panics if any name in `store_names` was not registered through
1854    /// [`add_state_store`](crate::dsl::builder::StreamsBuilder::add_state_store).
1855    ///
1856    /// [`FixedKeyProcessorSupplier`]: crate::processor::fixed_key::FixedKeyProcessorSupplier
1857    /// [`FixedKeyProcessorContext::get_state_store`]: crate::processor::fixed_key::FixedKeyProcessorContext::get_state_store
1858    /// [`add_state_store`]: crate::dsl::builder::StreamsBuilder::add_state_store
1859    pub fn process_values<VOut, PS>(
1860        &self,
1861        supplier: PS,
1862        store_names: impl IntoIterator<Item = impl Into<String>>,
1863    ) -> KStream<K, VOut, KS, <VOut as DefaultSerde>::Serde>
1864    where
1865        VOut: DefaultSerde + Any + Send + Clone,
1866        PS: crate::processor::fixed_key::FixedKeyProcessorSupplier<K, V, VOut> + Clone + 'static,
1867    {
1868        let stores: Vec<String> = store_names.into_iter().map(Into::into).collect();
1869        // `add_state_store` must precede `process_values`; look up each connect thunk
1870        // now so a missing store panics here (at call time) not during lowering.
1871        let thunks: Vec<crate::dsl::builder::StoreConnectThunk> = {
1872            let g = self.builder.borrow();
1873            stores
1874                .iter()
1875                .map(|s| {
1876                    g.store_thunk(s).unwrap_or_else(|| {
1877                        panic!(
1878                            "process_values references store '{s}' that was not added \
1879                             via add_state_store"
1880                        )
1881                    })
1882                })
1883                .collect()
1884        };
1885        let parent_id = self.node;
1886        let mut g = self.builder.borrow_mut();
1887        let name = g.new_processor_name(names::KSTREAM_PROCESSVALUES);
1888        let id = g.graph.add(
1889            name.clone(),
1890            GraphNodeKind::StatelessProcessor {
1891                repartition_required: false,
1892            },
1893            vec![parent_id],
1894        );
1895        // The supplier is moved into the lower thunk; the thunk clones it per task
1896        // when `add_processor` runs (`PS: Clone`). A FixedKey supplier becomes a
1897        // regular `ProcessorSupplier` producing a `FixedKeyAdapter` (KOut = K), which
1898        // impls `Processor<K, V, K, VOut>` because `Box<dyn FixedKeyProcessor>` is a
1899        // `FixedKeyProcessor` (the fixed_key blanket impl).
1900        let sup = supplier;
1901        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
1902            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
1903            let sup2 = sup.clone();
1904            let h = state.topology.add_processor::<K, V, K, VOut, _, _, _>(
1905                name.clone(),
1906                move || crate::processor::fixed_key::FixedKeyAdapter { inner: sup2.get() },
1907                [parent],
1908            );
1909            let proc_name = h.name().to_string();
1910            // Register + connect each named store to this processor (the connect
1911            // thunks carry the store serdes and emit the compact changelog).
1912            for t in &thunks {
1913                t(state, &proc_name);
1914            }
1915            state.handle_name.insert(id, proc_name);
1916        }));
1917        drop(g);
1918        // process_values keeps the key → NON-key-changing; carry source-topic lineage.
1919        KStream::new_with_key_changing(
1920            Rc::clone(&self.builder),
1921            id,
1922            self.key_changing,
1923            self.key_serde.clone(),
1924            <VOut as DefaultSerde>::Serde::default(),
1925        )
1926        .with_source_topic(self.source_topic.clone())
1927    }
1928
1929    /// `toTable`: materialize this stream into a [`KTable`].
1930    ///
1931    /// The op writes each record into a state store and forwards a `Change<V>`
1932    /// change-stream that carries the prior store value as `old`.
1933    ///
1934    /// `to_table` carries the key through unchanged, so it never inserts a
1935    /// repartition. The JVM repartitions only when the upstream key is rewritten
1936    /// without a re-group. The store name is `Materialized`'s explicit name when
1937    /// set, otherwise a fresh `KSTREAM-TOTABLE-STATE-STORE-` counter. The store
1938    /// gets the standard `<app>-<store>-changelog` changelog, or no changelog
1939    /// when [`Materialized::with_logging(false)`].
1940    ///
1941    /// [`Materialized::with_logging(false)`]: crate::dsl::config::Materialized::with_logging
1942    pub fn to_table_explicit<NKS, NVS>(
1943        &self,
1944        materialized: impl Into<Materialized<NKS, NVS>>,
1945    ) -> KTable<K, V, NKS, NVS>
1946    where
1947        NKS: Serde<K> + Clone + 'static,
1948        NVS: Serde<V> + Clone + 'static,
1949    {
1950        let materialized = materialized.into();
1951        let parent_id = self.node;
1952        // Mint the store name at the JVM counter position (before the processor
1953        // name), matching the aggregate store-naming convention.
1954        let store_name = match &materialized.store_name {
1955            Some(name) => name.clone(),
1956            None => self
1957                .builder
1958                .borrow_mut()
1959                .new_processor_name(names::TOTABLE_STORE),
1960        };
1961        let Materialized {
1962            key_serde,
1963            value_serde,
1964            logging,
1965            caching,
1966            ..
1967        } = materialized;
1968        let mut g = self.builder.borrow_mut();
1969        let name = g.new_processor_name(names::TOTABLE);
1970        let id = g.graph.add(
1971            name.clone(),
1972            GraphNodeKind::Aggregate {
1973                store_name: store_name.clone(),
1974                changelog: logging,
1975            },
1976            vec![parent_id],
1977        );
1978        let store_for_thunk = store_name.clone();
1979        let key_serde_for_lower = key_serde.clone();
1980        let value_serde_for_lower = value_serde.clone();
1981        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
1982            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
1983            let store_for_proc = store_for_thunk.clone();
1984            // The stream → table boundary forwards Change<V> (prior store value as old).
1985            let h = state.topology.add_processor::<K, V, K, Change<V>, _, _, _>(
1986                name.clone(),
1987                move || KStreamToTableProcessor {
1988                    store_name: store_for_proc.clone(),
1989                    forwarder: TupleForwarder::default(),
1990                    _pd: PhantomData,
1991                },
1992                [parent],
1993            );
1994            // Honor `Materialized::with_logging(bool)`, mirroring the aggregate ops:
1995            // logging=true → changelog topic emitted; logging=false → store usable
1996            // at runtime but no state_changelog_topics entry in the wire topology.
1997            if logging {
1998                state.topology.add_state_store::<K, V, NKS, NVS>(
1999                    store_for_thunk.clone(),
2000                    key_serde_for_lower.clone(),
2001                    value_serde_for_lower.clone(),
2002                    [h.name().to_string()],
2003                );
2004            } else {
2005                state
2006                    .topology
2007                    .add_state_store_no_changelog::<K, V, NKS, NVS>(
2008                        store_for_thunk.clone(),
2009                        key_serde_for_lower.clone(),
2010                        value_serde_for_lower.clone(),
2011                    );
2012            }
2013            // Mark the store cached per `Materialized::with_caching` (default true);
2014            // the to_table processor's TupleForwarder suppresses immediate forwards
2015            // when cached and the cache flush forwards the deduped change.
2016            state.topology.mark_store_caching(&store_for_thunk, caching);
2017            state.handle_name.insert(id, h.name().to_string());
2018        }));
2019        drop(g);
2020        KTable::new(
2021            Rc::clone(&self.builder),
2022            id,
2023            Some(store_name),
2024            None,
2025            key_serde,
2026            value_serde,
2027        )
2028    }
2029
2030    /// Sourced `KTable` from this stream using the stream's carried serdes.
2031    pub fn to_table(&self, store_name: impl Into<String>) -> KTable<K, V, KS, VS>
2032    where
2033        KS: Serde<K> + Clone + 'static,
2034        VS: Serde<V> + Clone + 'static,
2035    {
2036        self.to_table_explicit(
2037            Materialized::with(self.key_serde.clone(), self.value_serde.clone())
2038                .as_store(store_name),
2039        )
2040    }
2041}
2042
2043// ---------------------------------------------------------------------------
2044// BranchedStream
2045// ---------------------------------------------------------------------------
2046
2047/// Builder returned by [`KStream::split`].
2048///
2049/// Each [`branch`](Self::branch) call adds a filter-backed child node wired to
2050/// the parent node. It returns a new [`KStream`] that carries only the records
2051/// for which the predicate returns `true`.
2052///
2053/// **Simplification vs JVM first-match-wins:** this builder forwards records to
2054/// ALL branches whose predicate matches. For mutually-exclusive predicates the
2055/// behaviour is identical.
2056///
2057/// Drop `BranchedStream` before you call [`StreamsBuilder::build`]. It holds an
2058/// `Rc` clone of the shared internal builder, and it otherwise makes the
2059/// `Rc::try_unwrap` inside `build` fail.
2060///
2061/// [`StreamsBuilder::build`]: crate::dsl::builder::StreamsBuilder::build
2062pub struct BranchedStream<K, V, KS = <K as DefaultSerde>::Serde, VS = <V as DefaultSerde>::Serde> {
2063    pub(crate) builder: Rc<RefCell<InternalStreamsBuilder>>,
2064    pub(crate) parent: NodeId,
2065    pub(crate) key_changing: bool,
2066    pub(crate) source_topic: Option<String>,
2067    pub(crate) key_serde: KS,
2068    pub(crate) value_serde: VS,
2069    pub(crate) _pd: std::marker::PhantomData<fn() -> (K, V)>,
2070}
2071
2072impl<K, V, KS, VS> BranchedStream<K, V, KS, VS>
2073where
2074    K: Any + Send + Clone + Default,
2075    V: Any + Send + Clone,
2076    KS: Clone,
2077    VS: Clone,
2078{
2079    /// Add a branch.
2080    ///
2081    /// This method forwards records for which `predicate(key, value)` returns
2082    /// `true` to the returned [`KStream`]. It uses a `KSTREAM-BRANCHCHILD-` node
2083    /// backed by the same filter processor that [`KStream::filter`] uses.
2084    pub fn branch<P>(&self, predicate: P) -> KStream<K, V, KS, VS>
2085    where
2086        P: Fn(&K, &V) -> bool + Clone + Send + Sync + 'static,
2087    {
2088        let parent_id = self.parent;
2089        let mut g = self.builder.borrow_mut();
2090        let name = g.new_processor_name(names::BRANCHCHILD);
2091        let id = g.graph.add(
2092            name.clone(),
2093            GraphNodeKind::StatelessProcessor {
2094                repartition_required: false,
2095            },
2096            vec![parent_id],
2097        );
2098        let p2 = predicate.clone();
2099        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
2100            let parent = NodeHandle::<K, V>::from_name(state.handle_name[&parent_id].clone());
2101            let h = state.topology.add_processor::<K, V, K, V, _, _, _>(
2102                name.clone(),
2103                move || stateless::FilterProcessor {
2104                    predicate: p2.clone(),
2105                    negate: false,
2106                    _pd: std::marker::PhantomData,
2107                },
2108                [parent],
2109            );
2110            state.handle_name.insert(id, h.name().to_string());
2111        }));
2112        drop(g);
2113        // branch is filter-only → key + source-topic lineage unchanged.
2114        KStream::new_with_key_changing(
2115            Rc::clone(&self.builder),
2116            id,
2117            self.key_changing,
2118            self.key_serde.clone(),
2119            self.value_serde.clone(),
2120        )
2121        .with_source_topic(self.source_topic.clone())
2122    }
2123}
2124
2125#[cfg(test)]
2126mod tests {
2127    use assert2::check;
2128    use krabka_units::prelude::*;
2129
2130    use crate::dsl::builder::StreamsBuilder;
2131
2132    #[test]
2133    fn stateless_chain_records_named_nodes() {
2134        let b = StreamsBuilder::new();
2135        b.stream::<String, String>(["in"])
2136            .map_values(|v: &String| v.to_uppercase())
2137            .filter(|_k: &String, _v: &String| true)
2138            .to("out");
2139        let g = b.internal.borrow();
2140        let names: Vec<&str> = g.graph.nodes.iter().map(|n| n.name.as_str()).collect();
2141        check!(
2142            names
2143                == vec![
2144                    "KSTREAM-SOURCE-0000000000",
2145                    "KSTREAM-MAPVALUES-0000000001",
2146                    "KSTREAM-FILTER-0000000002",
2147                    "KSTREAM-SINK-0000000003",
2148                ]
2149        );
2150    }
2151
2152    #[test]
2153    fn select_key_marks_key_changing() {
2154        let b = StreamsBuilder::new();
2155        b.stream::<String, String>(["in"])
2156            .select_key(|_k: &String, v: &String| v.clone());
2157        let g = b.internal.borrow();
2158        check!(g.graph.nodes[1].key_changing_operation);
2159    }
2160
2161    #[test]
2162    fn grace_join_builds_and_names_buffer_store() {
2163        // A grace join over a versioned table lowers cleanly and emits a buffer
2164        // changelog `app-<join_name>-Buffer-changelog`. The join node is the SAME
2165        // KSTREAM-JOIN node as a plain stream-table join (no extra processor). The
2166        // versioned table here supplies its own store name ("vt"), so only the
2167        // table SOURCE(1) + TABLE-SOURCE proc(2) and the stream SOURCE(0) are minted
2168        // before the join lands at KSTREAM-JOIN-0000000003 (see `dsl_golden_frame`
2169        // grace golden for the byte-exact node/store layout — this pins the name).
2170        use crate::{
2171            dsl::config::{Joined, Materialized},
2172            processor::serde::{Consumed, I64Serde, Produced, StringSerde},
2173        };
2174        let b = StreamsBuilder::new();
2175        let s = b
2176            .stream_explicit::<StringSerde, I64Serde>(["s"], Consumed::with(StringSerde, I64Serde));
2177        let t = b.table_explicit::<StringSerde, I64Serde>(
2178            "t",
2179            Consumed::with(StringSerde, I64Serde),
2180            Materialized::with(StringSerde, I64Serde).as_versioned("vt", millis(600_000)),
2181        );
2182        s.join_table_with(
2183            &t,
2184            |a: &i64, c: &i64| a + c,
2185            Joined::with_grace_period(millis(60_000)),
2186        )
2187        .to_explicit("out", Produced::with(StringSerde, I64Serde));
2188        drop(s);
2189        drop(t);
2190        let wire = b.build("app").unwrap().to_wire();
2191        let changelogs: Vec<&str> = wire
2192            .subtopologies
2193            .iter()
2194            .flat_map(|st| st.state_changelog_topics.iter())
2195            .map(|t| t.name.as_str())
2196            .collect();
2197        check!(
2198            changelogs.contains(&"app-KSTREAM-JOIN-0000000003-Buffer-changelog"),
2199            "buffer changelog missing; got {changelogs:?}"
2200        );
2201    }
2202
2203    #[test]
2204    #[should_panic(expected = "grace requires a versioned table")]
2205    fn grace_on_unversioned_table_panics() {
2206        use crate::{
2207            dsl::config::{Joined, Materialized},
2208            processor::serde::{Consumed, I64Serde, StringSerde},
2209        };
2210        let b = StreamsBuilder::new();
2211        let s = b
2212            .stream_explicit::<StringSerde, I64Serde>(["s"], Consumed::with(StringSerde, I64Serde));
2213        let t = b.table_explicit::<StringSerde, I64Serde>(
2214            "t",
2215            Consumed::with(StringSerde, I64Serde),
2216            Materialized::with(StringSerde, I64Serde).as_store("plain"),
2217        );
2218        let _ = s.join_table_with(
2219            &t,
2220            |a: &i64, c: &i64| a + c,
2221            Joined::with_grace_period(millis(1000)),
2222        );
2223    }
2224
2225    #[test]
2226    #[should_panic(expected = "grace must be < history_retention")]
2227    fn grace_geq_retention_panics() {
2228        use crate::{
2229            dsl::config::{Joined, Materialized},
2230            processor::serde::{Consumed, I64Serde, StringSerde},
2231        };
2232        let b = StreamsBuilder::new();
2233        let s = b
2234            .stream_explicit::<StringSerde, I64Serde>(["s"], Consumed::with(StringSerde, I64Serde));
2235        let t = b.table_explicit::<StringSerde, I64Serde>(
2236            "t",
2237            Consumed::with(StringSerde, I64Serde),
2238            Materialized::with(StringSerde, I64Serde).as_versioned("vt", millis(1000)),
2239        );
2240        let _ = s.join_table_with(
2241            &t,
2242            |a: &i64, c: &i64| a + c,
2243            Joined::with_grace_period(millis(1000)),
2244        );
2245    }
2246}
2247
2248#[cfg(test)]
2249mod to_table_caching_tests {
2250    use assert2::check;
2251    use krabka_units::prelude::*;
2252
2253    use crate::{
2254        I64Serde, Materialized, Produced, StringSerde, dsl::StreamsBuilder,
2255        store::backend::StoreBackend,
2256    };
2257
2258    /// Caching ON: the `to_table` store is marked cached (`cache_owner` rooted).
2259    /// Two same-key updates stay suppressed until flush, and the flush emits a
2260    /// single deduped record that carries the latest value.
2261    #[test]
2262    fn to_table_caches_marks_and_dedups_emit() {
2263        let b = StreamsBuilder::new();
2264        b.stream::<String, i64>(["in"])
2265            .to_table_explicit(Materialized::with(StringSerde, I64Serde).as_store("t"))
2266            .to_stream()
2267            .to_explicit("out", Produced::with(StringSerde, I64Serde));
2268        let built = b.build("app").unwrap();
2269        let mut g =
2270            pollster::block_on(built.instantiate(&StoreBackend::InMemory, "app", kibibytes(1)))
2271                .unwrap();
2272        check!(g.cache_owner.contains_key("t"));
2273        pollster::block_on(g.init_processors()).unwrap();
2274
2275        // Two same-key updates: 7 @0 then 9 @1.
2276        pollster::block_on(g.pipe("in", 0, 0, Some(b"k"), &7i64.to_be_bytes(), 0)).unwrap();
2277        pollster::block_on(g.pipe("in", 0, 1, Some(b"k"), &9i64.to_be_bytes(), 1)).unwrap();
2278        // Suppressed: nothing forwarded downstream until the cache flushes.
2279        check!(g.take_output().is_empty());
2280
2281        pollster::block_on(g.flush_caches()).unwrap();
2282        let out = g.take_output();
2283        check!(out.len() == 1);
2284        check!(out[0].topic == "out");
2285        // to_stream forwards the deduped `new` value = 9 (BE i64).
2286        check!(out[0].value.as_ref().unwrap().as_ref() == 9i64.to_be_bytes());
2287    }
2288
2289    /// `with_caching(false)`: the store is NOT cached even with a positive
2290    /// budget (mark opted out → absent from `cache_owner`).
2291    #[test]
2292    fn to_table_uncached_when_caching_off() {
2293        let b = StreamsBuilder::new();
2294        b.stream::<String, i64>(["in"])
2295            .to_table_explicit(
2296                Materialized::with(StringSerde, I64Serde)
2297                    .as_store("t")
2298                    .with_caching(false),
2299            )
2300            .to_stream()
2301            .to_explicit("out", Produced::with(StringSerde, I64Serde));
2302        let built = b.build("app").unwrap();
2303        let g = pollster::block_on(built.instantiate(&StoreBackend::InMemory, "app", kibibytes(1)))
2304            .unwrap();
2305        check!(!g.cache_owner.contains_key("t"));
2306    }
2307}