Skip to main content

krabka_client_streams/dsl/
ktable.rs

1//! `KTable<K,V>`: a materialized, changelog-backed table view.
2//!
3//! A terminal aggregation (`count`/`reduce`/`aggregate`) or
4//! [`StreamsBuilder::table`](crate::dsl::StreamsBuilder::table) produces a
5//! `KTable`. [`KTable::to_stream`] converts it back to a `KStream`.
6//!
7//! Each op records a logical node and a lowering thunk in the same style as
8//! [`crate::dsl::kstream::KStream`]. The thunk rebuilds the parent handle from
9//! `LowerState`, makes the typed Processor-API call, and records the resulting
10//! node name. The materialized ops `map_values` and `filter` also register a
11//! state store.
12use std::{any::Any, cell::RefCell, marker::PhantomData, rc::Rc, sync::Arc};
13
14use krabka_units::prelude::*;
15
16use crate::{
17    dsl::{
18        builder::InternalStreamsBuilder,
19        graph::{GraphNodeKind, LowerState, NodeId},
20        kstream::KStream,
21        names,
22        processors::{
23            change::Change,
24            fk::{
25                processors::{
26                    FkJoinOutputProcessor, ForeignTableJoinProcessor, SubscriptionJoinProcessor,
27                    SubscriptionReceiveProcessor, SubscriptionResolverProcessor,
28                    SubscriptionSendProcessor,
29                },
30                subscription::{SubscriptionResponseWrapper, SubscriptionWrapper},
31                wrapper_serde::{SubscriptionResponseWrapperSerde, SubscriptionWrapperSerde},
32            },
33            ktable_join::{
34                JoinKind, KTableKTableJoinOtherProcessor, KTableKTableJoinThisProcessor,
35            },
36            stateless::MergeProcessor,
37            table::{
38                KTableFilterProcessor, KTableMapValuesProcessor, KTableMapValuesViewProcessor,
39                KTableToStreamProcessor,
40            },
41            tuple_forwarder::TupleForwarder,
42        },
43    },
44    processor::serde::{DefaultSerde, Serde, SerdeArc},
45    topology::NodeHandle,
46};
47
48/// A serde-carrying closure that registers a `SuppressBytesStore` for a
49/// `suppress` node during lowering.
50///
51/// The producing op attaches this closure to a `KTable`. That op is a windowed
52/// aggregation, a session aggregation, or `builder.table`, and it alone knows the
53/// concrete serdes.
54///
55/// The caller calls it as `factory(state, store_name, processor_name, logging)`.
56/// It registers the suppress store with the captured serdes under `store_name`,
57/// connected to `processor_name`, and `logging` gates the changelog. The closure
58/// is type-erased, because the concrete `K`, `V`, and serdes are baked into it,
59/// so the `KTable` field is non-generic. It is an `Arc` and it is `Send + Sync`,
60/// because the lowering thunk that clones it in is itself `Send`. The captured
61/// serdes are `Send + Sync` through the `Serde` supertrait.
62pub(crate) type SuppressStoreFactory = Arc<dyn Fn(&mut LowerState, &str, &str, bool) + Send + Sync>;
63
64struct ForeignKeyJoinGraph {
65    registration_base: String,
66    send_name: String,
67    registration_sink: String,
68    registration_source: String,
69    subscription_store: String,
70    receive_name: String,
71    subscription_join_name: String,
72    foreign_join_name: String,
73    response_base: String,
74    response_sink: String,
75    response_source: String,
76    resolver_name: String,
77    output_name: String,
78    output_id: NodeId,
79}
80
81fn allocate_foreign_key_join_graph(
82    builder: &mut InternalStreamsBuilder,
83    parents: (NodeId, NodeId),
84) -> ForeignKeyJoinGraph {
85    let registration_base = builder.new_processor_name(names::FK_SUBSCRIPTION_REGISTRATION);
86    let send_name = builder.new_processor_name(names::FK_SUBSCRIPTION_REGISTRATION);
87    let registration_sink = builder.new_processor_name(names::KTABLE_SINK);
88    let registration_source = builder.new_processor_name(names::KTABLE_SOURCE);
89    let subscription_store = builder.new_processor_name(names::FK_SUBSCRIPTION_STATE_STORE);
90    let receive_name = builder.new_processor_name(names::FK_SUBSCRIPTION_PROCESSOR);
91    let subscription_join_name = builder.new_processor_name(names::FK_SUBSCRIPTION_PROCESSOR);
92    let foreign_join_name = builder.new_processor_name(names::FK_SUBSCRIPTION_PROCESSOR);
93    let response_base = builder.new_processor_name(names::FK_SUBSCRIPTION_RESPONSE);
94    let response_sink = builder.new_processor_name(names::KTABLE_SINK);
95    let response_source = builder.new_processor_name(names::KTABLE_SOURCE);
96    let resolver_name = builder.new_processor_name(names::FK_RESPONSE_RESOLVER);
97    let output_name = builder.new_processor_name(names::FK_OUTPUT);
98    let result_store = builder.new_processor_name(names::FK_OUTPUT_STATE_STORE);
99    let output_id = builder.graph.add(
100        output_name.clone(),
101        GraphNodeKind::TableProcessor {
102            store_name: Some(result_store.clone()),
103        },
104        vec![parents.0, parents.1],
105    );
106    ForeignKeyJoinGraph {
107        registration_base,
108        send_name,
109        registration_sink,
110        registration_source,
111        subscription_store,
112        receive_name,
113        subscription_join_name,
114        foreign_join_name,
115        response_base,
116        response_sink,
117        response_source,
118        resolver_name,
119        output_name,
120        output_id,
121    }
122}
123
124/// Build a non-windowed [`SuppressStoreFactory`] from a table's key and value
125/// serdes.
126///
127/// Plain aggregations and `builder.table` use this factory. It registers a
128/// `SuppressBytesStore<K, V>` with the JVM 1-day default changelog retention.
129/// Windowed and session aggregations use their own factories, which wrap
130/// `TimeWindowedSerde` and `SessionWindowedSerde`.
131pub(crate) fn kv_suppress_factory<K, V, KS, VS>(
132    key_serde: KS,
133    value_serde: VS,
134) -> SuppressStoreFactory
135where
136    K: Any + Send + Sync + Clone,
137    V: Any + Send + Clone,
138    KS: Serde<K> + Clone + 'static,
139    VS: Serde<V> + Clone + 'static,
140{
141    Arc::new(
142        move |state: &mut LowerState, store_name: &str, proc_name: &str, logging: bool| {
143            state.topology.add_suppress_store::<K, V, KS, VS>(
144                store_name.to_string(),
145                key_serde.clone(),
146                value_serde.clone(),
147                logging,
148                [proc_name.to_string()],
149            );
150        },
151    )
152}
153
154/// A changelog-backed table handle.
155///
156/// `store_name` is the materialized store this table reads and writes. The DSL
157/// uses it to derive changelog topics and to reuse the store in downstream
158/// materialized ops. `source_topic` is the Kafka topic this table was sourced
159/// from. It is set for `builder.table_explicit()` `KTables` and is `None` for
160/// derived `KTables`. The join DSL reads it to declare copartition groups.
161pub struct KTable<K, V, KS = <K as DefaultSerde>::Serde, VS = <V as DefaultSerde>::Serde> {
162    pub(crate) builder: Rc<RefCell<InternalStreamsBuilder>>,
163    pub(crate) node: NodeId,
164    #[allow(dead_code)]
165    pub(crate) store_name: Option<String>,
166    #[allow(dead_code)]
167    pub(crate) source_topic: Option<String>,
168    /// For windowed tables: the upstream window's grace (suppress closes a window
169    /// at `window.end + window_grace`). `None` for non-windowed tables.
170    pub(crate) window_grace: Option<Time>,
171    /// The history retention when this table is materialized into a versioned
172    /// store (KIP-889). It drives as-of stream-table join lookups (KIP-914), the
173    /// table-table out-of-order gate, and grace validation. It mirrors
174    /// `window_grace`. `None` for non-versioned and derived tables.
175    pub(crate) versioned_retention: Option<Time>,
176    /// The serde-carrying producers set this field. Those producers are the
177    /// aggregations and `builder.table`. `suppress` reads it to register its store
178    /// with the right serdes. It is `None` on a derived table whose value type
179    /// changed through `map_values`, and `suppress` then panics.
180    pub(crate) suppress_store_factory: Option<SuppressStoreFactory>,
181    pub(crate) key_serde: KS,
182    pub(crate) value_serde: VS,
183    pub(crate) _pd: PhantomData<fn() -> (K, V)>,
184}
185
186impl<K, V, KS, VS> KTable<K, V, KS, VS> {
187    pub(crate) fn new(
188        builder: Rc<RefCell<InternalStreamsBuilder>>,
189        node: NodeId,
190        store_name: Option<String>,
191        source_topic: Option<String>,
192        key_serde: KS,
193        value_serde: VS,
194    ) -> Self {
195        Self {
196            builder,
197            node,
198            store_name,
199            source_topic,
200            window_grace: None,
201            versioned_retention: None,
202            suppress_store_factory: None,
203            key_serde,
204            value_serde,
205            _pd: PhantomData,
206        }
207    }
208
209    pub fn with_key_serde<NewKS>(self, serde: NewKS) -> KTable<K, V, NewKS, VS> {
210        KTable {
211            builder: self.builder,
212            node: self.node,
213            store_name: self.store_name,
214            source_topic: self.source_topic,
215            window_grace: self.window_grace,
216            versioned_retention: self.versioned_retention,
217            suppress_store_factory: self.suppress_store_factory,
218            key_serde: serde,
219            value_serde: self.value_serde,
220            _pd: PhantomData,
221        }
222    }
223
224    pub fn with_value_serde<NewVS>(self, serde: NewVS) -> KTable<K, V, KS, NewVS> {
225        KTable {
226            builder: self.builder,
227            node: self.node,
228            store_name: self.store_name,
229            source_topic: self.source_topic,
230            window_grace: self.window_grace,
231            versioned_retention: self.versioned_retention,
232            suppress_store_factory: self.suppress_store_factory,
233            key_serde: self.key_serde,
234            value_serde: serde,
235            _pd: PhantomData,
236        }
237    }
238
239    /// The table's key serde, if captured (only `builder.table` tables have one).
240    pub(crate) fn key_serde(&self) -> Arc<dyn Serde<K>>
241    where
242        KS: Serde<K> + Clone + 'static,
243    {
244        Arc::new(self.key_serde.clone())
245    }
246
247    /// The table's value serde, if captured.
248    pub(crate) fn value_serde(&self) -> Arc<dyn Serde<V>>
249    where
250        VS: Serde<V> + Clone + 'static,
251    {
252        Arc::new(self.value_serde.clone())
253    }
254
255    /// This table's logical graph node id. The FK-join DSL feeds its
256    /// `SubscriptionSend` from the left node and its `ForeignTableJoin` from the
257    /// right node.
258    pub(crate) fn node_id(&self) -> NodeId {
259        self.node
260    }
261
262    /// The name of the materialized state store backing this table, if any.
263    #[allow(dead_code)]
264    pub(crate) fn store_name(&self) -> Option<&str> {
265        self.store_name.as_deref()
266    }
267
268    /// The Kafka source topic this table was sourced from through
269    /// `builder.table_explicit()`. `None` for a derived `KTable` such as an
270    /// aggregation, a `map_values`, or a `filter`.
271    #[allow(dead_code)]
272    pub(crate) fn source_topic(&self) -> Option<&str> {
273        self.source_topic.as_deref()
274    }
275
276    /// Tag this table with its upstream window's grace. The windowed and session
277    /// aggregations set it, and `Change`-preserving ops propagate it. `suppress`
278    /// reads it, and it accesses the `window_grace` field directly.
279    #[must_use]
280    pub(crate) fn with_window_grace(mut self, grace: Option<Time>) -> Self {
281        self.window_grace = grace;
282        self
283    }
284
285    /// Tag this table with its versioned-store history retention. `builder.table`
286    /// sets it when the caller used `Materialized::as_versioned`. The stream-table
287    /// join reads it for as-of routing, and the table-table join reads it for the
288    /// out-of-order gate.
289    #[must_use]
290    pub(crate) fn with_versioned_retention(mut self, retention: Option<Time>) -> Self {
291        self.versioned_retention = retention;
292        self
293    }
294
295    /// Attach or propagate the serde-carrying suppress-store factory. The
296    /// aggregations and `builder.table` set it. The value-preserving ops `filter`
297    /// and `suppress` itself propagate it. `suppress` reads it.
298    #[must_use]
299    pub(crate) fn with_suppress_factory(mut self, factory: Option<SuppressStoreFactory>) -> Self {
300        self.suppress_store_factory = factory;
301        self
302    }
303}
304
305impl<K, V, KS, VS> KTable<K, V, KS, VS>
306where
307    K: Any + Send + Sync + Clone,
308    V: Any + Send + Clone,
309{
310    /// Test-only terminal: collect the change-stream into a shared buffer.
311    ///
312    /// This terminal collects each forwarded `Change<V>`'s key and **new** value
313    /// into a shared buffer in arrival order. It includes tombstones, where
314    /// `new == None`. Unlike [`to_stream`](Self::to_stream) it keeps tombstones,
315    /// so an exec test can assert a table's full change-stream of value updates
316    /// *and* `None` deletions. That matches the JVM
317    /// `toStream().to_explicit(topic)` capture, which writes null-valued records.
318    #[cfg(test)]
319    pub(crate) fn collect_changes(
320        &self,
321        buf: crate::dsl::processors::fk::processors::ChangeBuffer<K, V>,
322    ) where
323        K: 'static,
324        V: Sync + 'static,
325    {
326        let parent_id = self.node;
327        let mut g = self.builder.borrow_mut();
328        let name = g.new_processor_name(names::TABLE_TOSTREAM);
329        let id = g.graph.add(
330            name.clone(),
331            GraphNodeKind::TableProcessor { store_name: None },
332            vec![parent_id],
333        );
334        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
335            let parent =
336                NodeHandle::<K, Change<V>>::from_name(state.handle_name[&parent_id].clone());
337            let buf = buf.clone();
338            let h = state.topology.add_processor::<K, Change<V>, K, V, _, _, _>(
339                name.clone(),
340                move || crate::dsl::processors::fk::processors::ChangeCollectorProcessor::<K, V> {
341                    buf: buf.clone(),
342                    _pd: PhantomData,
343                },
344                [parent],
345            );
346            state.handle_name.insert(id, h.name().to_string());
347        }));
348    }
349
350    /// `toStream`: view the table's change-stream as a `KStream`, forwarding
351    /// every record unchanged. Not key-changing.
352    #[must_use]
353    pub fn to_stream(&self) -> KStream<K, V, KS, VS>
354    where
355        KS: Clone,
356        VS: Clone,
357    {
358        let parent_id = self.node;
359        let mut g = self.builder.borrow_mut();
360        let name = g.new_processor_name(names::TABLE_TOSTREAM);
361        let id = g.graph.add(
362            name.clone(),
363            GraphNodeKind::TableProcessor { store_name: None },
364            vec![parent_id],
365        );
366        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
367            // Parent (a KTable node) forwards Change<V>; to_stream extracts new.
368            let parent =
369                NodeHandle::<K, Change<V>>::from_name(state.handle_name[&parent_id].clone());
370            let h = state.topology.add_processor::<K, Change<V>, K, V, _, _, _>(
371                name.clone(),
372                || KTableToStreamProcessor { _pd: PhantomData },
373                [parent],
374            );
375            state.handle_name.insert(id, h.name().to_string());
376        }));
377        drop(g);
378        KStream::new(
379            Rc::clone(&self.builder),
380            id,
381            self.key_serde.clone(),
382            self.value_serde.clone(),
383        )
384    }
385
386    /// `mapValues`: transform each value and forward the rewritten table view.
387    ///
388    /// This op **does not materialize** a store. It matches the JVM's
389    /// non-materialized `mapValues`. The key stays unchanged, and the op emits no
390    /// changelog topic. Use
391    /// [`map_values_materialized`](Self::map_values_materialized) for the
392    /// store-backed form.
393    pub fn map_values<V2, F>(&self, f: F) -> KTable<K, V2, KS, <V2 as DefaultSerde>::Serde>
394    where
395        V2: DefaultSerde + Any + Send + Clone,
396        F: Fn(&V) -> V2 + Clone + Send + Sync + 'static,
397        KS: Clone,
398    {
399        let grace = self.window_grace;
400        let parent_id = self.node;
401        let mut g = self.builder.borrow_mut();
402        let name = g.new_processor_name(names::TABLE_MAPVALUES);
403        let id = g.graph.add(
404            name.clone(),
405            GraphNodeKind::TableProcessor { store_name: None },
406            vec![parent_id],
407        );
408        let f2 = f.clone();
409        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
410            // Parent forwards Change<V>; the view maps both sides to Change<V2>.
411            let parent =
412                NodeHandle::<K, Change<V>>::from_name(state.handle_name[&parent_id].clone());
413            let h = state
414                .topology
415                .add_processor::<K, Change<V>, K, Change<V2>, _, _, _>(
416                    name.clone(),
417                    move || KTableMapValuesViewProcessor {
418                        f: f2.clone(),
419                        _pd: PhantomData,
420                    },
421                    [parent],
422                );
423            state.handle_name.insert(id, h.name().to_string());
424        }));
425        drop(g);
426        KTable::new(
427            Rc::clone(&self.builder),
428            id,
429            None,
430            None,
431            self.key_serde.clone(),
432            <V2 as DefaultSerde>::Serde::default(),
433        )
434        .with_window_grace(grace)
435    }
436
437    /// `mapValues`: transform each value, materializing the rewritten table into
438    /// a new store. Key unchanged.
439    pub fn map_values_materialized<V2, NKS, NVS, F>(
440        &self,
441        f: F,
442        materialized: impl Into<crate::dsl::config::Materialized<NKS, NVS>>,
443    ) -> KTable<K, V2, NKS, NVS>
444    where
445        V2: Any + Send + Clone,
446        NKS: Serde<K> + Clone + 'static,
447        NVS: Serde<V2> + Clone + 'static,
448        F: Fn(&V) -> V2 + Clone + Send + Sync + 'static,
449    {
450        let materialized = materialized.into();
451        let grace = self.window_grace;
452        let store_name = mint_table_store(&self.builder, &materialized, names::TABLE_MAPVALUES);
453        let key_serde = materialized.key_serde.clone();
454        let value_serde = materialized.value_serde.clone();
455        let key_serde_for_ktable = key_serde.clone();
456        let value_serde_for_ktable = value_serde.clone();
457        let caching = materialized.caching_enabled();
458        let crate::dsl::config::Materialized {
459            key_serde,
460            value_serde,
461            ..
462        } = materialized;
463        let parent_id = self.node;
464        let mut g = self.builder.borrow_mut();
465        let name = g.new_processor_name(names::TABLE_MAPVALUES);
466        let id = g.graph.add(
467            name.clone(),
468            GraphNodeKind::TableProcessor {
469                store_name: Some(store_name.clone()),
470            },
471            vec![parent_id],
472        );
473        let f2 = f.clone();
474        let store_for_thunk = store_name.clone();
475        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
476            // Parent forwards Change<V>; materialized map maps both sides to V2.
477            let parent =
478                NodeHandle::<K, Change<V>>::from_name(state.handle_name[&parent_id].clone());
479            let store_for_proc = store_for_thunk.clone();
480            let h = state
481                .topology
482                .add_processor::<K, Change<V>, K, Change<V2>, _, _, _>(
483                    name.clone(),
484                    move || KTableMapValuesProcessor {
485                        f: f2.clone(),
486                        store_name: store_for_proc.clone(),
487                        forwarder: TupleForwarder::default(),
488                        _pd: PhantomData,
489                    },
490                    [parent],
491                );
492            state.topology.add_state_store::<K, V2, NKS, NVS>(
493                store_for_thunk.clone(),
494                key_serde.clone(),
495                value_serde.clone(),
496                [h.name().to_string()],
497            );
498            state.topology.mark_store_caching(&store_for_thunk, caching);
499            state.handle_name.insert(id, h.name().to_string());
500        }));
501        drop(g);
502        KTable::new(
503            Rc::clone(&self.builder),
504            id,
505            Some(store_name),
506            None,
507            key_serde_for_ktable,
508            value_serde_for_ktable,
509        )
510        .with_window_grace(grace)
511    }
512
513    /// `filter`: keep the rows that match `predicate` and materialize the view.
514    ///
515    /// When a row that matched before stops matching, this op removes it from the
516    /// store and forwards it as a `Change<V>` tombstone, so downstream views drop
517    /// it. See the processor module doc.
518    #[must_use]
519    pub fn filter<NKS, NVS, P>(
520        &self,
521        predicate: P,
522        materialized: impl Into<crate::dsl::config::Materialized<NKS, NVS>>,
523    ) -> KTable<K, V, NKS, NVS>
524    where
525        NKS: Serde<K> + Clone + 'static,
526        NVS: Serde<V> + Clone + 'static,
527        P: Fn(&K, &V) -> bool + Clone + Send + Sync + 'static,
528    {
529        let materialized = materialized.into();
530        let grace = self.window_grace;
531        // filter preserves V → suppress can still register a store with the same
532        // serdes; propagate the factory.
533        let suppress_factory = self.suppress_store_factory.clone();
534        let store_name = mint_table_store(&self.builder, &materialized, names::TABLE_FILTER);
535        let key_serde = materialized.key_serde.clone();
536        let value_serde = materialized.value_serde.clone();
537        let key_serde_for_ktable = key_serde.clone();
538        let value_serde_for_ktable = value_serde.clone();
539        let caching = materialized.caching_enabled();
540        let crate::dsl::config::Materialized {
541            key_serde,
542            value_serde,
543            ..
544        } = materialized;
545        let parent_id = self.node;
546        let mut g = self.builder.borrow_mut();
547        let name = g.new_processor_name(names::TABLE_FILTER);
548        let id = g.graph.add(
549            name.clone(),
550            GraphNodeKind::TableProcessor {
551                store_name: Some(store_name.clone()),
552            },
553            vec![parent_id],
554        );
555        let p2 = predicate.clone();
556        let store_for_thunk = store_name.clone();
557        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
558            // Parent forwards Change<V>; filter re-applies the predicate to both
559            // sides and forwards Change<V> (emitting tombstones).
560            let parent =
561                NodeHandle::<K, Change<V>>::from_name(state.handle_name[&parent_id].clone());
562            let store_for_proc = store_for_thunk.clone();
563            let h = state
564                .topology
565                .add_processor::<K, Change<V>, K, Change<V>, _, _, _>(
566                    name.clone(),
567                    move || KTableFilterProcessor {
568                        predicate: p2.clone(),
569                        store_name: store_for_proc.clone(),
570                        forwarder: TupleForwarder::default(),
571                        _pd: PhantomData,
572                    },
573                    [parent],
574                );
575            state.topology.add_state_store::<K, V, NKS, NVS>(
576                store_for_thunk.clone(),
577                key_serde.clone(),
578                value_serde.clone(),
579                [h.name().to_string()],
580            );
581            state.topology.mark_store_caching(&store_for_thunk, caching);
582            state.handle_name.insert(id, h.name().to_string());
583        }));
584        drop(g);
585        KTable::new(
586            Rc::clone(&self.builder),
587            id,
588            Some(store_name),
589            None,
590            key_serde_for_ktable,
591            value_serde_for_ktable,
592        )
593        .with_window_grace(grace)
594        .with_suppress_factory(suppress_factory)
595    }
596
597    /// `groupBy`: re-group the table by a new `(KR, VR)` derived from each entry.
598    ///
599    /// After the re-group, aggregate with `count`, `reduce`, or `aggregate`. This
600    /// op always repartitions: the JVM `KTable.groupBy` inserts a repartition-map,
601    /// a sink, and a source.
602    pub fn group_by<KR, VR, M>(
603        &self,
604        mapper: M,
605    ) -> crate::dsl::kgrouped_table::KGroupedTable<KR, VR>
606    where
607        KR: DefaultSerde + Any + Send + Sync + Clone + PartialEq,
608        VR: DefaultSerde + Any + Send + Sync + Clone,
609        <KR as DefaultSerde>::Serde: Serde<KR> + Clone,
610        <VR as DefaultSerde>::Serde: Serde<VR> + Clone,
611        M: Fn(&K, &V) -> (KR, VR) + Clone + Send + Sync + 'static,
612        K: Any + Send + Sync + Clone,
613        V: Any + Send + Clone,
614    {
615        self.group_by_explicit(
616            mapper,
617            crate::dsl::config::Grouped::with(
618                <KR as DefaultSerde>::Serde::default(),
619                <VR as DefaultSerde>::Serde::default(),
620            ),
621        )
622    }
623
624    /// `groupBy` with explicit repartition serdes.
625    pub fn group_by_explicit<KR, VR, GKS, GVS, M>(
626        &self,
627        mapper: M,
628        grouped: impl Into<crate::dsl::config::Grouped<GKS, GVS>>,
629    ) -> crate::dsl::kgrouped_table::KGroupedTable<KR, VR>
630    where
631        KR: Any + Send + Sync + Clone + PartialEq,
632        VR: Any + Send + Sync + Clone,
633        GKS: Serde<KR> + Clone + 'static,
634        GVS: Serde<VR> + Clone + 'static,
635        M: Fn(&K, &V) -> (KR, VR) + Clone + Send + Sync + 'static,
636        K: Any + Send + Sync + Clone,
637        V: Any + Send + Clone,
638    {
639        use crate::dsl::processors::table_aggregate::KTableRepartitionMapProcessor;
640
641        let grouped = grouped.into();
642        let parent_id = self.node;
643
644        // Record the `KTABLE-SELECT` repartition-map node NOW (at `groupBy()` time),
645        // matching the JVM `KGroupedTableImpl`, which mints SELECT before the
646        // terminal aggregation mints its result store. Recording it here rather
647        // than deferring to the terminal op is what keeps an auto-named result
648        // store at the JVM counter index (pinned by the `kgrouped_table_autonamed`
649        // golden).
650        let mut g = self.builder.borrow_mut();
651        let select_name = g.new_processor_name(names::KTABLE_SELECT);
652        let select_id = g.graph.add(
653            select_name.clone(),
654            GraphNodeKind::TableProcessor { store_name: None },
655            vec![parent_id],
656        );
657        let mapper2 = mapper.clone();
658        g.graph.nodes[select_id].lower = Some(Box::new(move |state: &mut LowerState| {
659            let parent =
660                NodeHandle::<K, Change<V>>::from_name(state.handle_name[&parent_id].clone());
661            let h = state
662                .topology
663                .add_processor::<K, Change<V>, KR, Change<VR>, _, _, _>(
664                    select_name.clone(),
665                    move || KTableRepartitionMapProcessor {
666                        mapper: mapper2.clone(),
667                        _pd: PhantomData,
668                    },
669                    [parent],
670                );
671            state.handle_name.insert(select_id, h.name().to_string());
672        }));
673        drop(g);
674
675        crate::dsl::kgrouped_table::KGroupedTable::new(
676            Rc::clone(&self.builder),
677            select_id,
678            crate::dsl::kgrouped_table::repartition_lower_changed::<KR, VR, GKS, GVS>(
679                grouped.key_serde,
680                grouped.value_serde,
681            ),
682        )
683    }
684
685    /// `join` (inner KTable-KTable join): join rows that both tables hold.
686    ///
687    /// For each key, the join row exists only when **both** tables hold a value.
688    /// On any change to either side, the join re-reads the other side's current
689    /// value from its store and forwards a `Change<VR>`. It forwards a tombstone
690    /// when the row stops existing.
691    ///
692    /// Both tables must be materialized, because the join reads each side's
693    /// store. The join declares the two source topics as a copartition group
694    /// (KIP-1071).
695    /// # Panics
696    /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
697    pub fn join<VB, VR, F, VBS>(
698        &self,
699        other: &KTable<K, VB, KS, VBS>,
700        joiner: F,
701    ) -> KTable<K, VR, KS, <VR as DefaultSerde>::Serde>
702    where
703        V: Sync,
704        VB: Any + Send + Sync + Clone,
705        VR: DefaultSerde + Any + Send + Clone,
706        F: Fn(&V, &VB) -> VR + Clone + Send + Sync + 'static,
707        KS: Clone,
708        VBS: Clone,
709    {
710        // Inner: both sides required → the outer-form joiner only ever sees `Some`.
711        let jf = move |a: Option<&V>, b: Option<&VB>| {
712            joiner(
713                a.expect("inner join: a present"),
714                b.expect("inner join: b present"),
715            )
716        };
717        self.join_impl(other, jf, JoinKind::inner())
718    }
719
720    /// `leftJoin` (left KTable-KTable join): emit a row for every left row.
721    ///
722    /// This join emits a row whenever the **left** (this) side is present. The
723    /// right side is optional, and the joiner receives `None` for it on a miss.
724    /// # Panics
725    /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
726    pub fn left_join<VB, VR, F, VBS>(
727        &self,
728        other: &KTable<K, VB, KS, VBS>,
729        joiner: F,
730    ) -> KTable<K, VR, KS, <VR as DefaultSerde>::Serde>
731    where
732        V: Sync,
733        VB: Any + Send + Sync + Clone,
734        VR: DefaultSerde + Any + Send + Clone,
735        F: Fn(&V, Option<&VB>) -> VR + Clone + Send + Sync + 'static,
736        KS: Clone,
737        VBS: Clone,
738    {
739        let jf = move |a: Option<&V>, b: Option<&VB>| joiner(a.expect("left join: a present"), b);
740        self.join_impl(other, jf, JoinKind::left())
741    }
742
743    /// `outerJoin` (outer KTable-KTable join): emit a row for either side.
744    ///
745    /// This join emits a row whenever **either** side is present. The joiner
746    /// receives an `Option` for each side.
747    pub fn outer_join<VB, VR, F, VBS>(
748        &self,
749        other: &KTable<K, VB, KS, VBS>,
750        joiner: F,
751    ) -> KTable<K, VR, KS, <VR as DefaultSerde>::Serde>
752    where
753        V: Sync,
754        VB: Any + Send + Sync + Clone,
755        VR: DefaultSerde + Any + Send + Clone,
756        F: Fn(Option<&V>, Option<&VB>) -> VR + Clone + Send + Sync + 'static,
757        KS: Clone,
758        VBS: Clone,
759    {
760        self.join_impl(other, joiner, JoinKind::outer())
761    }
762
763    /// Shared lowering for the inner, left, and outer KTable-KTable joins.
764    ///
765    /// This method records three logical nodes and their thunks:
766    /// - `KTABLE-JOINTHIS-`, fed by this table's node. It reads the OTHER (`b`)
767    ///   store, applies the join, and forwards `Change<VR>`.
768    /// - `KTABLE-JOINOTHER-`, fed by the other table's node. It reads the OTHER
769    ///   (`a`) store, applies the join, and forwards `Change<VR>`.
770    /// - `KTABLE-MERGE-`, fed by both join nodes. It forwards each `Change<VR>`
771    ///   unchanged and unions the two join outputs.
772    ///
773    /// Each join node connects to the store it reads, so the lowering pulls it
774    /// into the same subtopology as that store's owning table source. When both
775    /// tables are single-source-topic tables, this method declares their source
776    /// topics as a copartition group.
777    fn join_impl<VB, VR, JF, VBS>(
778        &self,
779        other: &KTable<K, VB, KS, VBS>,
780        jf: JF,
781        kind: JoinKind,
782    ) -> KTable<K, VR, KS, <VR as DefaultSerde>::Serde>
783    where
784        V: Sync,
785        VB: Any + Send + Sync + Clone,
786        VR: DefaultSerde + Any + Send + Clone,
787        JF: Fn(Option<&V>, Option<&VB>) -> VR + Clone + Send + Sync + 'static,
788        KS: Clone,
789        VBS: Clone,
790    {
791        let a_store = self
792            .store_name()
793            .expect("KTable-KTable join: left table must be materialized")
794            .to_string();
795        let b_store = other
796            .store_name()
797            .expect("KTable-KTable join: right table must be materialized")
798            .to_string();
799        let a_src = self.source_topic().map(str::to_string);
800        let b_src = other.source_topic().map(str::to_string);
801        let self_node = self.node;
802        let other_node = other.node;
803
804        // KIP-914: each side's OWN store name, set only when that side is
805        // versioned. The matching processor reads its own latest `valid_from`
806        // and suppresses out-of-order updates (record ts strictly older).
807        let this_versioned_store = self.versioned_retention.is_some().then(|| a_store.clone());
808        let other_versioned_store = other.versioned_retention.is_some().then(|| b_store.clone());
809
810        // KIP-914: table-table joins read the OTHER side's LATEST value. Each
811        // processor must know whether ITS other store is versioned so the read
812        // goes through `get_versioned_store` (a plain `get_state_store` downcast
813        // returns `None` for a `VersionedBytesStore`).
814        // - This-processor reads the OTHER (b) table → versioned iff `other` is.
815        // - Other-processor reads the OTHER (a/self) table → versioned iff `self` is.
816        let other_is_versioned_this = other.versioned_retention.is_some();
817        let other_is_versioned_other = self.versioned_retention.is_some();
818
819        let mut g = self.builder.borrow_mut();
820        let join_this = g.new_processor_name(names::KTABLE_JOIN_THIS);
821        let join_other = g.new_processor_name(names::KTABLE_JOIN_OTHER);
822        let merge = g.new_processor_name(names::KTABLE_MERGE);
823
824        // ── "this" side: fed by this table, reads the OTHER (b) store ──────────
825        let this_id = g.graph.add(
826            join_this.clone(),
827            GraphNodeKind::StatelessProcessor {
828                repartition_required: false,
829            },
830            vec![self_node],
831        );
832        let b_store_this = b_store.clone();
833        let a_store_this = a_store.clone();
834        let jf_this = jf.clone();
835        let join_this_name = join_this.clone();
836        let this_versioned = this_versioned_store.clone();
837        g.graph.nodes[this_id].lower = Some(Box::new(move |state: &mut LowerState| {
838            let parent =
839                NodeHandle::<K, Change<V>>::from_name(state.handle_name[&self_node].clone());
840            let store_for_proc = b_store_this.clone();
841            let jf_for_proc = jf_this.clone();
842            let self_versioned = this_versioned.clone();
843            let h = state
844                .topology
845                .add_processor::<K, Change<V>, K, Change<VR>, _, _, _>(
846                    join_this_name.clone(),
847                    move || KTableKTableJoinThisProcessor {
848                        other_store: store_for_proc.clone(),
849                        joiner: jf_for_proc.clone(),
850                        kind,
851                        self_versioned_store: self_versioned.clone(),
852                        // KIP-914: the This-processor reads the OTHER (b) store; it
853                        // is versioned iff the `other` table is versioned.
854                        other_is_versioned: other_is_versioned_this,
855                        _pd: PhantomData,
856                    },
857                    [parent],
858                );
859            state
860                .topology
861                .connect_processor_store(h.name(), &b_store_this);
862            // KIP-914: connect this processor to its OWN store so the gate's
863            // `get_versioned_store` lookup resolves (only matters when versioned).
864            if this_versioned.is_some() {
865                state
866                    .topology
867                    .connect_processor_store(h.name(), &a_store_this);
868            }
869            state.handle_name.insert(this_id, h.name().to_string());
870        }));
871
872        // ── "other" side: fed by the other table, reads the OTHER (a) store ────
873        let other_id = g.graph.add(
874            join_other.clone(),
875            GraphNodeKind::StatelessProcessor {
876                repartition_required: false,
877            },
878            vec![other_node],
879        );
880        let a_store_other = a_store.clone();
881        let b_store_other = b_store.clone();
882        let jf_other = jf.clone();
883        let join_other_name = join_other.clone();
884        let other_versioned = other_versioned_store.clone();
885        g.graph.nodes[other_id].lower = Some(Box::new(move |state: &mut LowerState| {
886            let parent =
887                NodeHandle::<K, Change<VB>>::from_name(state.handle_name[&other_node].clone());
888            let store_for_proc = a_store_other.clone();
889            let jf_for_proc = jf_other.clone();
890            let self_versioned = other_versioned.clone();
891            let h = state
892                .topology
893                .add_processor::<K, Change<VB>, K, Change<VR>, _, _, _>(
894                    join_other_name.clone(),
895                    move || KTableKTableJoinOtherProcessor {
896                        other_store: store_for_proc.clone(),
897                        joiner: jf_for_proc.clone(),
898                        kind,
899                        self_versioned_store: self_versioned.clone(),
900                        // KIP-914: the Other-processor reads the OTHER (a/self) store;
901                        // it is versioned iff `self` (the receiver KTable) is versioned.
902                        other_is_versioned: other_is_versioned_other,
903                        _pd: PhantomData,
904                    },
905                    [parent],
906                );
907            state
908                .topology
909                .connect_processor_store(h.name(), &a_store_other);
910            // KIP-914: connect this processor to its OWN store so the gate's
911            // `get_versioned_store` lookup resolves (only matters when versioned).
912            if other_versioned.is_some() {
913                state
914                    .topology
915                    .connect_processor_store(h.name(), &b_store_other);
916            }
917            state.handle_name.insert(other_id, h.name().to_string());
918        }));
919
920        // ── merge: union the two join outputs (forwards Change<VR> unchanged) ──
921        let merge_id = g.graph.add(
922            merge.clone(),
923            GraphNodeKind::StatelessProcessor {
924                repartition_required: false,
925            },
926            vec![this_id, other_id],
927        );
928        g.graph.nodes[merge_id].lower = Some(Box::new(move |state: &mut LowerState| {
929            let this_parent =
930                NodeHandle::<K, Change<VR>>::from_name(state.handle_name[&this_id].clone());
931            let other_parent =
932                NodeHandle::<K, Change<VR>>::from_name(state.handle_name[&other_id].clone());
933            let h = state
934                .topology
935                .add_processor::<K, Change<VR>, K, Change<VR>, _, _, _>(
936                    merge.clone(),
937                    || MergeProcessor::<K, Change<VR>> { _pd: PhantomData },
938                    [this_parent, other_parent],
939                );
940            // Declare the copartition group (when both tables are single-source).
941            if let (Some(a), Some(bb)) = (&a_src, &b_src) {
942                state
943                    .topology
944                    .add_copartition_group([a.clone(), bb.clone()]);
945            }
946            state.handle_name.insert(merge_id, h.name().to_string());
947        }));
948        drop(g);
949        KTable::new(
950            Rc::clone(&self.builder),
951            merge_id,
952            None,
953            None,
954            self.key_serde.clone(),
955            <VR as DefaultSerde>::Serde::default(),
956        )
957    }
958
959    /// `join` on a foreign key (KIP-213 inner FK join).
960    ///
961    /// For each left record, the foreign key `fk_extractor(&VA)` selects a row in
962    /// `other`, which is a `KTable<KO, VB>`. `joiner(&VA, &VB)` produces the
963    /// result whenever both are present.
964    ///
965    /// Both tables must be materialized **source** tables built with
966    /// `builder.table`, because the join reads `sa` and `sb` and needs their
967    /// serdes. The join lowers to the two-subtopology KIP-213 graph: the
968    /// subscription registration and response repartition topics, a subscription
969    /// state store, and the five FK-join processors. See the module
970    /// `dsl::processors::fk` for the per-processor semantics.
971    /// # Panics
972    /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
973    pub fn join_on_foreign_key<KO, VB, VR, FKE, J, KOS, KosOther, VbsOther>(
974        &self,
975        other: &KTable<KO, VB, KosOther, VbsOther>,
976        fk_extractor: FKE,
977        joiner: J,
978        fk_serde: KOS,
979    ) -> KTable<K, VR, KS, <VR as DefaultSerde>::Serde>
980    where
981        KO: Any + Send + Sync + Clone,
982        VB: Any + Send + Sync + Clone,
983        VR: DefaultSerde + Any + Send + Sync + Clone,
984        K: Send + Sync,
985        V: Send + Sync,
986        FKE: Fn(&V) -> KO + Clone + Send + Sync + 'static,
987        J: Fn(&V, &VB) -> VR + Clone + Send + Sync + 'static,
988        KOS: Serde<KO> + Clone + 'static,
989        KS: Serde<K> + Clone + 'static,
990        VS: Serde<V> + Clone + 'static,
991        KosOther: Clone,
992        VbsOther: Serde<VB> + Clone + 'static,
993    {
994        // Inner: both sides required → the outer-form joiner only sees `Some`.
995        let jf = move |a: &V, b: Option<&VB>| joiner(a, b.expect("inner FK join: foreign present"));
996        self.fk_join_impl(other, fk_extractor, jf, fk_serde, false)
997    }
998
999    /// `leftJoin` on a foreign key (KIP-213 left FK join).
1000    ///
1001    /// This join emits a row for every left record. The joiner receives `None`
1002    /// for the foreign value when the foreign key has no matching row.
1003    pub fn left_join_on_foreign_key<KO, VB, VR, FKE, J, KOS, KosOther, VbsOther>(
1004        &self,
1005        other: &KTable<KO, VB, KosOther, VbsOther>,
1006        fk_extractor: FKE,
1007        joiner: J,
1008        fk_serde: KOS,
1009    ) -> KTable<K, VR, KS, <VR as DefaultSerde>::Serde>
1010    where
1011        KO: Any + Send + Sync + Clone,
1012        VB: Any + Send + Sync + Clone,
1013        VR: DefaultSerde + Any + Send + Sync + Clone,
1014        K: Send + Sync,
1015        V: Send + Sync,
1016        FKE: Fn(&V) -> KO + Clone + Send + Sync + 'static,
1017        J: Fn(&V, Option<&VB>) -> VR + Clone + Send + Sync + 'static,
1018        KOS: Serde<KO> + Clone + 'static,
1019        KS: Serde<K> + Clone + 'static,
1020        VS: Serde<V> + Clone + 'static,
1021        KosOther: Clone,
1022        VbsOther: Serde<VB> + Clone + 'static,
1023    {
1024        self.fk_join_impl(other, fk_extractor, joiner, fk_serde, true)
1025    }
1026
1027    /// Shared lowering for the inner and left foreign-key joins.
1028    ///
1029    /// `jf` is the outer-form joiner `Fn(&V, Option<&VB>) -> VR`. `is_left`
1030    /// selects either the JVM `leftJoinInstructions` or the inner staleness
1031    /// rules.
1032    ///
1033    /// This method records the whole KIP-213 graph under a **single** logical
1034    /// OUTPUT node fed by both tables' nodes, so the lowering driver visits it
1035    /// after both sources and before `toStream`. It mints all 14 JVM counter
1036    /// indices **eagerly**, from registration topic 4 to result store 17, so that
1037    /// a downstream op lands at the JVM index (`toStream`=18, sink=19). The thunk
1038    /// then registers the Topology sources, processors, sinks, stores,
1039    /// repartition topics, and copartition group.
1040    // the 14-node KIP-213 graph is one cohesive lowering
1041    fn foreign_key_join_sources<KO, VB, KosOther, VbsOther>(
1042        &self,
1043        other: &KTable<KO, VB, KosOther, VbsOther>,
1044    ) -> (String, String, String, String) {
1045        let left_store = self
1046            .store_name()
1047            .expect("FK join: left table must be a materialized source table")
1048            .to_string();
1049        let right_store = other
1050            .store_name()
1051            .expect("FK join: right table must be a materialized source table")
1052            .to_string();
1053        let left_topic = self
1054            .source_topic()
1055            .expect("FK join: left table must be sourced from a single topic")
1056            .to_string();
1057        let right_topic = other
1058            .source_topic()
1059            .expect("FK join: right table must be sourced from a single topic")
1060            .to_string();
1061        (left_store, right_store, left_topic, right_topic)
1062    }
1063
1064    fn fk_join_impl<KO, VB, VR, FKE, JF, KOS, KosOther, VbsOther>(
1065        &self,
1066        other: &KTable<KO, VB, KosOther, VbsOther>,
1067        fk_extractor: FKE,
1068        jf: JF,
1069        fk_serde: KOS,
1070        is_left: bool,
1071    ) -> KTable<K, VR, KS, <VR as DefaultSerde>::Serde>
1072    where
1073        KO: Any + Send + Sync + Clone,
1074        VB: Any + Send + Sync + Clone,
1075        VR: DefaultSerde + Any + Send + Sync + Clone,
1076        K: Send + Sync,
1077        V: Send + Sync,
1078        FKE: Fn(&V) -> KO + Clone + Send + Sync + 'static,
1079        JF: Fn(&V, Option<&VB>) -> VR + Clone + Send + Sync + 'static,
1080        KOS: Serde<KO> + Clone + 'static,
1081        KS: Serde<K> + Clone + 'static,
1082        VS: Serde<V> + Clone + 'static,
1083        KosOther: Clone,
1084        VbsOther: Serde<VB> + Clone + 'static,
1085    {
1086        let (sa, sb, a_src, b_src) = self.foreign_key_join_sources(other);
1087        // Capture the left key/value + right value serdes (boxed clones for the
1088        // per-processor closures).
1089        let k_serde = self.key_serde();
1090        let left_value_serde = self.value_serde();
1091        let right_value_serde = other.value_serde();
1092        let self_node = self.node_id();
1093        let other_node = other.node_id();
1094
1095        let mut g = self.builder.borrow_mut();
1096        let graph = allocate_foreign_key_join_graph(&mut g, (self_node, other_node));
1097        let output_id = graph.output_id;
1098
1099        let thunk = move |state: &mut LowerState| {
1100            let app = state.app_id.clone();
1101            let registration_topic = format!(
1102                "{app}-{}{}",
1103                graph.registration_base,
1104                names::FK_TOPIC_SUFFIX
1105            );
1106            let response_topic = format!("{app}-{}{}", graph.response_base, names::FK_TOPIC_SUFFIX);
1107
1108            let a_parent =
1109                NodeHandle::<K, Change<V>>::from_name(state.handle_name[&self_node].clone());
1110            let b_parent =
1111                NodeHandle::<KO, Change<VB>>::from_name(state.handle_name[&other_node].clone());
1112
1113            // ── Left chain: SubscriptionSend → reg sink → reg source ──────────
1114            let send_h = state
1115                .topology
1116                .add_processor::<K, Change<V>, KO, SubscriptionWrapper, _, _, _>(
1117                    graph.send_name.clone(),
1118                    {
1119                        let fke = fk_extractor.clone();
1120                        let left_serde = left_value_serde.clone();
1121                        let ko = fk_serde.clone();
1122                        let ks = k_serde.clone();
1123                        move || SubscriptionSendProcessor {
1124                            fk_extractor: fke.clone(),
1125                            va_serde: Box::new(SerdeArc(left_serde.clone())),
1126                            ko_serde: Box::new(ko.clone()),
1127                            k_serde: Box::new(SerdeArc(ks.clone())),
1128                            is_left,
1129                            _pd: PhantomData,
1130                        }
1131                    },
1132                    [a_parent],
1133                );
1134            state
1135                .topology
1136                .add_sink_explicit::<KO, SubscriptionWrapper, KOS, SubscriptionWrapperSerde, _, _>(
1137                    graph.registration_sink.clone(),
1138                    registration_topic.clone(),
1139                    [&send_h],
1140                    crate::processor::serde::Produced::with(
1141                        fk_serde.clone(),
1142                        SubscriptionWrapperSerde,
1143                    ),
1144                );
1145            state
1146                .topology
1147                .add_repartition_topic(registration_topic.clone());
1148            let reg_src_h = state
1149                .topology
1150                .add_source_explicit::<KO, SubscriptionWrapper, KOS, SubscriptionWrapperSerde>(
1151                    graph.registration_source.clone(),
1152                    [registration_topic.clone()],
1153                    crate::processor::serde::Consumed::with(
1154                        fk_serde.clone(),
1155                        SubscriptionWrapperSerde,
1156                    ),
1157                );
1158
1159            // ── Right chain (sub1): receive → subscription-join ───────────────
1160            let receive_h = state
1161                .topology
1162                .add_processor::<KO, SubscriptionWrapper, KO, SubscriptionWrapper, _, _, _>(
1163                    graph.receive_name.clone(),
1164                    {
1165                        let store = graph.subscription_store.clone();
1166                        let ko = fk_serde.clone();
1167                        move || SubscriptionReceiveProcessor {
1168                            store_name: store.clone(),
1169                            ko_serde: Box::new(ko.clone()),
1170                            _pd: PhantomData,
1171                        }
1172                    },
1173                    [&reg_src_h],
1174                );
1175            let sub_join_h = state
1176                .topology
1177                .add_processor::<KO, SubscriptionWrapper, K, SubscriptionResponseWrapper, _, _, _>(
1178                    graph.subscription_join_name.clone(),
1179                    {
1180                        let b = sb.clone();
1181                        let ks = k_serde.clone();
1182                        let right_serde = right_value_serde.clone();
1183                        move || SubscriptionJoinProcessor::<KO, K, VB> {
1184                            b_store: b.clone(),
1185                            k_serde: Box::new(SerdeArc(ks.clone())),
1186                            vb_serde: Box::new(SerdeArc(right_serde.clone())),
1187                            _pd: PhantomData,
1188                        }
1189                    },
1190                    [&receive_h],
1191                );
1192
1193            // ── Right chain (sub1): foreign-table-join (fed by sb source) ─────
1194            let foreign_join_h = state
1195                .topology
1196                .add_processor::<KO, Change<VB>, K, SubscriptionResponseWrapper, _, _, _>(
1197                    graph.foreign_join_name.clone(),
1198                    {
1199                        let store = graph.subscription_store.clone();
1200                        let ko = fk_serde.clone();
1201                        let ks = k_serde.clone();
1202                        let right_serde = right_value_serde.clone();
1203                        move || ForeignTableJoinProcessor::<KO, K, VB> {
1204                            store_name: store.clone(),
1205                            ko_serde: Box::new(ko.clone()),
1206                            k_serde: Box::new(SerdeArc(ks.clone())),
1207                            vb_serde: Box::new(SerdeArc(right_serde.clone())),
1208                            _pd: PhantomData,
1209                        }
1210                    },
1211                    [&b_parent],
1212                );
1213
1214            // Subscription store: connected to receive (writer) + foreign-join
1215            // (prefix-scanner). This unites the registration-source chain with sb's
1216            // subtopology (subtopology 1).
1217            state.topology.add_fk_subscription_store(
1218                graph.subscription_store.clone(),
1219                [
1220                    receive_h.name().to_string(),
1221                    foreign_join_h.name().to_string(),
1222                ],
1223            );
1224            // sub-join reads sb → connect so it joins sb's subtopology.
1225            state
1226                .topology
1227                .connect_processor_store(sub_join_h.name(), &sb);
1228
1229            // ── Response sink (sub1) ← {sub-join, foreign-join} ───────────────
1230            state
1231                .topology
1232                .add_sink_explicit::<K, SubscriptionResponseWrapper, _, SubscriptionResponseWrapperSerde, _, _>(
1233                    graph.response_sink.clone(),
1234                    response_topic.clone(),
1235                    [&sub_join_h, &foreign_join_h],
1236                    crate::processor::serde::Produced::with(
1237                        SerdeArc(k_serde.clone()),
1238                        SubscriptionResponseWrapperSerde,
1239                    ),
1240                );
1241            state.topology.add_repartition_topic(response_topic.clone());
1242
1243            // ── Response source (sub0) → resolver → output ────────────────────
1244            let resp_src_h = state
1245                .topology
1246                .add_source_explicit::<K, SubscriptionResponseWrapper, _, SubscriptionResponseWrapperSerde>(
1247                    graph.response_source.clone(),
1248                    [response_topic.clone()],
1249                    crate::processor::serde::Consumed::with(
1250                        SerdeArc(k_serde.clone()),
1251                        SubscriptionResponseWrapperSerde,
1252                    ),
1253                );
1254            let resolver_h = state
1255                .topology
1256                .add_processor::<K, SubscriptionResponseWrapper, K, Change<VR>, _, _, _>(
1257                    graph.resolver_name.clone(),
1258                    {
1259                        let a = sa.clone();
1260                        let left_serde = left_value_serde.clone();
1261                        let right_serde = right_value_serde.clone();
1262                        let joiner = jf.clone();
1263                        move || SubscriptionResolverProcessor::<K, V, VB, VR, JF> {
1264                            a_store: a.clone(),
1265                            va_serde: Box::new(SerdeArc(left_serde.clone())),
1266                            vb_serde: Box::new(SerdeArc(right_serde.clone())),
1267                            joiner: joiner.clone(),
1268                            is_left,
1269                            _pd: PhantomData,
1270                        }
1271                    },
1272                    [&resp_src_h],
1273                );
1274            // Resolver reads sa → connect so it joins sa's subtopology (subtopology 0).
1275            state
1276                .topology
1277                .connect_processor_store(resolver_h.name(), &sa);
1278
1279            let output_h = state
1280                .topology
1281                .add_processor::<K, Change<VR>, K, Change<VR>, _, _, _>(
1282                    graph.output_name.clone(),
1283                    || FkJoinOutputProcessor::<K, VR> { _pd: PhantomData },
1284                    [&resolver_h],
1285                );
1286
1287            // Copartition: the left source topic + the registration repartition
1288            // source (subtopology 1) and the response repartition source +
1289            // left source (subtopology 0) are each copartitioned. The JVM declares
1290            // the external source with the repartition source it co-reads:
1291            //   sub0: [a, response-topic]  ;  sub1: [b, registration-topic]
1292            // We declare both; the grouping pass routes each to the subtopology
1293            // that reads all its members.
1294            state
1295                .topology
1296                .add_copartition_group([a_src.clone(), response_topic.clone()]);
1297            state
1298                .topology
1299                .add_copartition_group([b_src.clone(), registration_topic.clone()]);
1300
1301            state
1302                .handle_name
1303                .insert(output_id, output_h.name().to_string());
1304        };
1305        g.graph.nodes[output_id].lower = Some(Box::new(thunk));
1306        drop(g);
1307        KTable::new(
1308            Rc::clone(&self.builder),
1309            output_id,
1310            None,
1311            None,
1312            self.key_serde.clone(),
1313            <VR as DefaultSerde>::Serde::default(),
1314        )
1315    }
1316}
1317
1318impl<K, V, KS, VS> KTable<K, V, KS, VS>
1319where
1320    K: Any + Send + Sync + Clone,
1321    V: Any + Send + Clone,
1322{
1323    /// `suppress(Suppressed)`: buffer updates and emit on a delay.
1324    ///
1325    /// On a windowed table, `until_window_closes` emits each window's final value
1326    /// once the window closes. `until_time_limit` rate-limits any table to one
1327    /// update per key per wait.
1328    ///
1329    /// The buffer is a registered
1330    /// [`SuppressBytesStore`](crate::store::suppress_store). It is durable, with
1331    /// a changelog and restore, when `logging` is on. The serdes come from the
1332    /// table-producing operation. A call to `suppress` on a table that changed its
1333    /// value type through `map_values` panics, because no serde factory is
1334    /// available.
1335    #[must_use]
1336    /// # Panics
1337    /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
1338    pub fn suppress(&self, suppressed: crate::dsl::suppress::Suppressed<K>) -> KTable<K, V, KS, VS>
1339    where
1340        KS: Clone,
1341        VS: Clone,
1342    {
1343        let wait = match suppressed.wait {
1344            crate::dsl::suppress::WaitKind::UpstreamGrace => {
1345                self.window_grace.unwrap_or(Time::ZERO)
1346            }
1347            crate::dsl::suppress::WaitKind::Fixed(wait) => wait,
1348        };
1349        let buffer_time = suppressed.buffer_time;
1350        let max_records = suppressed.buffer.record_cap();
1351        let max_bytes = suppressed.buffer.byte_cap();
1352        let emit_early = suppressed.buffer.is_emit_early();
1353        let logging = suppressed.logging;
1354        // The serde-carrying factory that registers the suppress store. Required:
1355        // suppress needs the table's serdes to (de)serialize the buffered changes.
1356        let factory = self.suppress_store_factory.clone().expect(
1357            "suppress requires a serde-carrying KTable (a windowed/session aggregation \
1358             or builder.table); a mapValues-derived view has no value serde for the buffer",
1359        );
1360        let parent_id = self.node;
1361        let mut g = self.builder.borrow_mut();
1362        let name = g.new_processor_name(names::KTABLE_SUPPRESS);
1363        // The JVM mints the buffer store via `newStoreName(SUPPRESS_NAME)` right after
1364        // the processor name → `KTABLE-SUPPRESS-STATE-STORE-<index+1>` (consecutive).
1365        let store_name = g.new_processor_name(names::KTABLE_SUPPRESS_STORE);
1366        let store_for_thunk = store_name.clone();
1367        let id = g.graph.add(
1368            name.clone(),
1369            GraphNodeKind::TableProcessor { store_name: None },
1370            vec![parent_id],
1371        );
1372        g.graph.nodes[id].lower = Some(Box::new(move |state: &mut LowerState| {
1373            let parent =
1374                NodeHandle::<K, Change<V>>::from_name(state.handle_name[&parent_id].clone());
1375            let store_for_proc = store_for_thunk.clone();
1376            let h = state
1377                .topology
1378                .add_processor::<K, Change<V>, K, Change<V>, _, _, _>(
1379                    name.clone(),
1380                    move || {
1381                        crate::dsl::processors::suppress::KTableSuppressProcessor::<K, V>::new(
1382                            store_for_proc.clone(),
1383                            wait,
1384                            buffer_time,
1385                            max_records,
1386                            max_bytes,
1387                            emit_early,
1388                        )
1389                    },
1390                    [parent],
1391                );
1392            let proc_name = h.name().to_string();
1393            // Register the suppress store (with the producer's serdes), connected to
1394            // this processor; `logging` gates whether a changelog topic is emitted.
1395            factory(state, &store_for_thunk, &proc_name, logging);
1396            state.handle_name.insert(id, proc_name);
1397        }));
1398        drop(g);
1399        // suppress preserves K/V → propagate the grace + factory so a downstream
1400        // suppress/filter can register against the same serdes.
1401        KTable::new(
1402            Rc::clone(&self.builder),
1403            id,
1404            Some(store_name),
1405            None,
1406            self.key_serde.clone(),
1407            self.value_serde.clone(),
1408        )
1409        .with_window_grace(self.window_grace)
1410        .with_suppress_factory(self.suppress_store_factory.clone())
1411    }
1412}
1413
1414/// Mint a materialized table store name.
1415///
1416/// Returns the `Materialized` name when it is present, otherwise a fresh counter
1417/// at the JVM position.
1418fn mint_table_store<KS, VS>(
1419    builder: &Rc<RefCell<InternalStreamsBuilder>>,
1420    materialized: &crate::dsl::config::Materialized<KS, VS>,
1421    prefix: &str,
1422) -> String {
1423    match &materialized.store_name {
1424        Some(name) => name.clone(),
1425        None => builder.borrow_mut().new_processor_name(prefix),
1426    }
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431    use assert2::check;
1432    use krabka_units::prelude::*;
1433
1434    #[test]
1435    fn versioned_table_handle_carries_retention() {
1436        use crate::{
1437            dsl::{builder::StreamsBuilder, config::Materialized},
1438            processor::serde::{I64Serde, StringSerde},
1439        };
1440
1441        let b = StreamsBuilder::new();
1442        let t = b.table_explicit::<StringSerde, I64Serde>(
1443            "in",
1444            crate::processor::serde::Consumed::with(StringSerde, I64Serde),
1445            Materialized::with(StringSerde, I64Serde).as_versioned("vt", minutes(10)),
1446        );
1447        check!(t.versioned_retention == Some(minutes(10)));
1448
1449        let plain = b.table_explicit::<StringSerde, I64Serde>(
1450            "in2",
1451            crate::processor::serde::Consumed::with(StringSerde, I64Serde),
1452            Materialized::with(StringSerde, I64Serde).as_store("pt"),
1453        );
1454        check!(plain.versioned_retention == None);
1455    }
1456}
1457
1458#[cfg(test)]
1459mod fk_exec_tests {
1460    use std::sync::{Arc, Mutex};
1461
1462    use crate::{
1463        dsl::builder::StreamsBuilder,
1464        processor::serde::{Consumed, StringSerde},
1465        test_driver::TopologyTestDriver,
1466    };
1467
1468    type Out = Arc<Mutex<Vec<(Option<String>, Option<String>)>>>;
1469    /// One sequence step: `(input_topic, key, value, ts, expected_emissions)`,
1470    /// where each expected emission is `(key, Some(value)|None-tombstone)`.
1471    type Step<'a> = (
1472        &'a str,
1473        &'a str,
1474        &'a str,
1475        i64,
1476        &'a [(&'a str, Option<&'a str>)],
1477    );
1478
1479    /// One input record: `topic:key=val@ts`.
1480    ///
1481    /// The runtime has no null-value source-record path, because a `Record`'s
1482    /// value is always present. The behavior.json `a:k1=null@6`
1483    /// tombstone-into-the-table step therefore cannot be piped here. That step is
1484    /// a redundant `k1=null` after step 5 already emits `k1=null`, so dropping it
1485    /// leaves every distinct FK retraction case covered.
1486    fn pipe(d: &mut TopologyTestDriver, topic: &str, key: &str, val: &str, ts: i64) {
1487        d.pipe_input(
1488            topic,
1489            Consumed::with(StringSerde, StringSerde),
1490            Some(key.to_string()),
1491            val.to_string(),
1492            ts,
1493        );
1494    }
1495
1496    /// Drive the (k, v, ts) input steps and assert each step's *incremental*
1497    /// collected output equals `want`. The collector keeps tombstones as
1498    /// `(Some(k), None)`.
1499    fn run_sequence(buf: &Out, d: &mut TopologyTestDriver, steps: &[Step]) {
1500        let mut seen = 0usize;
1501        for (topic, key, val, ts, want) in steps {
1502            pipe(d, topic, key, val, *ts);
1503            let all = buf.lock().unwrap().clone();
1504            let step_out: Vec<(Option<String>, Option<String>)> = all[seen..].to_vec();
1505            seen = all.len();
1506            let want_owned: Vec<(Option<String>, Option<String>)> = want
1507                .iter()
1508                .map(|(k, v)| (Some((*k).to_string()), v.map(str::to_string)))
1509                .collect();
1510            assert_eq!(step_out, want_owned, "step {topic}:{key}={val}@{ts}");
1511        }
1512    }
1513
1514    fn tables(
1515        b: &StreamsBuilder,
1516    ) -> (super::KTable<String, String>, super::KTable<String, String>) {
1517        let ta = b.table::<String, String>("a", "sa");
1518        let tb = b.table::<String, String>("b", "sb");
1519        (ta, tb)
1520    }
1521
1522    /// Inner FK join over the behavior.json `inner_sequence` (steps 0–5).
1523    ///
1524    /// The fk extractor is the identity on the left String value, and the joiner
1525    /// is va+vb. The test checks the first-arrival skip (`a:k1=A` → []), the
1526    /// match emit (`b:A=X` → `k1=AX`), and the FK-change retraction tombstone
1527    /// (`a:k1=A2` → `k1=null`, because fk "A"→"A2" and "A2" has no foreign
1528    /// value). It then checks a second primary key, the right-table re-emit
1529    /// (`b:A=Y` → `k2=AY`), and another FK-change tombstone.
1530    #[test]
1531    fn fk_inner_sequence_matches_behavior_json() {
1532        let b = StreamsBuilder::new();
1533        let (ta, tb) = tables(&b);
1534        let buf: Out = Arc::new(Mutex::new(Vec::new()));
1535        ta.join_on_foreign_key(
1536            &tb,
1537            |va: &String| va.clone(),
1538            |va: &String, vb: &String| format!("{va}{vb}"),
1539            StringSerde,
1540        )
1541        .collect_changes(buf.clone());
1542        drop(ta);
1543        drop(tb);
1544        let built = b.build("app").unwrap();
1545        let mut d = TopologyTestDriver::new(&built).unwrap();
1546        run_sequence(
1547            &buf,
1548            &mut d,
1549            &[
1550                ("a", "k1", "A", 0, &[]),
1551                ("b", "A", "X", 1, &[("k1", Some("AX"))]),
1552                ("a", "k1", "A2", 2, &[("k1", None)]),
1553                ("a", "k2", "A", 3, &[("k2", Some("AX"))]),
1554                ("b", "A", "Y", 4, &[("k2", Some("AY"))]),
1555                ("a", "k1", "B", 5, &[("k1", None)]),
1556            ],
1557        );
1558    }
1559
1560    /// Left FK join over the behavior.json `left_sequence` (steps 0–5).
1561    ///
1562    /// The joiner is va + (vb? vb : "_"). The test checks the left-join non-match
1563    /// emit (`a:k1=A` → `k1=A_`), the match (`b:A=X` → `k1=AX`), the FK-change
1564    /// re-evaluation (`a:k1=A2` → `k1=A2_`), and the right-table re-emit. In the
1565    /// re-evaluation, fk "A2" has no foreign value, so left emits the left value
1566    /// with the empty marker and not a tombstone.
1567    #[test]
1568    fn fk_left_sequence_matches_behavior_json() {
1569        let b = StreamsBuilder::new();
1570        let (ta, tb) = tables(&b);
1571        let buf: Out = Arc::new(Mutex::new(Vec::new()));
1572        ta.left_join_on_foreign_key(
1573            &tb,
1574            |va: &String| va.clone(),
1575            |va: &String, vb: Option<&String>| format!("{va}{}", vb.map_or("_", String::as_str)),
1576            StringSerde,
1577        )
1578        .collect_changes(buf.clone());
1579        drop(ta);
1580        drop(tb);
1581        let built = b.build("app").unwrap();
1582        let mut d = TopologyTestDriver::new(&built).unwrap();
1583        run_sequence(
1584            &buf,
1585            &mut d,
1586            &[
1587                ("a", "k1", "A", 0, &[("k1", Some("A_"))]),
1588                ("b", "A", "X", 1, &[("k1", Some("AX"))]),
1589                ("a", "k1", "A2", 2, &[("k1", Some("A2_"))]),
1590                ("a", "k2", "A", 3, &[("k2", Some("AX"))]),
1591                ("b", "A", "Y", 4, &[("k2", Some("AY"))]),
1592                ("a", "k1", "B", 5, &[("k1", Some("B_"))]),
1593            ],
1594        );
1595    }
1596}