Skip to main content

krabka_client_streams/topology/
builder.rs

1//! Topology builder: public Processor-API surface.
2
3use std::{
4    any::Any,
5    borrow::Borrow,
6    collections::{BTreeMap, HashMap, HashSet},
7    marker::PhantomData,
8};
9
10/// Factory that builds a fresh erased [`StateStore`].
11///
12/// The caller supplies the store name, the pre-derived changelog topic, and an
13/// already-opened byte backend. The factory owns only the serdes.
14pub(crate) type StoreFactory = Box<
15    dyn Fn(
16            &str,
17            String,
18            Box<dyn crate::store::byte::ByteKeyValueStore>,
19        ) -> Box<dyn crate::store::api::StateStore>
20        + Send
21        + Sync,
22>;
23
24/// The JVM default for `windowstore.changelog.additional.retention.ms`.
25///
26/// This is the extra time added to a window store's own retention. An instance
27/// that restores its state can then still read the records that back its open
28/// windows.
29const CHANGELOG_ADDITIONAL_RETENTION: Time = days(1);
30
31use krabka_protocol::owned::streams_group_heartbeat_request::Topology as WireTopology;
32use krabka_units::prelude::*;
33
34use super::{
35    grouping::group_nodes,
36    node::{NodeKind, NodeRegistry},
37    wire::{changelog_topic_name, to_wire},
38};
39use crate::processor::{
40    api::ProcessorSupplier,
41    erased::ProcessorError,
42    factory::{MakeDeser, NodeFactory},
43    graph::{Graph, GraphSource},
44    node::{ErasedNode, ProcessorNode, SinkNode, SourceNode},
45    serde::{Consumed, DefaultSerde, Produced, Serde},
46};
47
48// ──────────────────────────────────────────────────────────────────────────────
49// TopologyError
50// ──────────────────────────────────────────────────────────────────────────────
51
52/// An error that stops a topology build, such as a bad node graph or an
53/// invalid configuration.
54///
55/// This enum does not cover parent→child *type* mismatches. Typed
56/// [`NodeHandle`] wiring makes such a mismatch a compile error, so it never
57/// reaches `build()`.
58#[derive(Debug, thiserror::Error)]
59pub enum TopologyError {
60    #[error("duplicate node name: {0}")]
61    DuplicateNode(String),
62    #[error("node {node} references unknown predecessor {predecessor}")]
63    UnknownPredecessor { node: String, predecessor: String },
64    #[error("topology has no source nodes")]
65    Empty,
66}
67
68// Cloneable error subset.
69#[derive(Debug, Clone)]
70enum StoredError {
71    DuplicateNode(String),
72    UnknownPredecessor { node: String, predecessor: String },
73    Empty,
74}
75
76impl From<StoredError> for TopologyError {
77    fn from(e: StoredError) -> Self {
78        match e {
79            StoredError::DuplicateNode(n) => TopologyError::DuplicateNode(n),
80            StoredError::UnknownPredecessor { node, predecessor } => {
81                TopologyError::UnknownPredecessor { node, predecessor }
82            }
83            StoredError::Empty => TopologyError::Empty,
84        }
85    }
86}
87
88impl From<TopologyError> for StoredError {
89    fn from(e: TopologyError) -> Self {
90        match e {
91            TopologyError::DuplicateNode(n) => StoredError::DuplicateNode(n),
92            TopologyError::UnknownPredecessor { node, predecessor } => {
93                StoredError::UnknownPredecessor { node, predecessor }
94            }
95            TopologyError::Empty => StoredError::Empty,
96        }
97    }
98}
99
100// ──────────────────────────────────────────────────────────────────────────────
101// Topology
102// ──────────────────────────────────────────────────────────────────────────────
103
104/// A Processor-API topology under construction.
105///
106/// Node insertion order is significant. It sets the subtopology indices, which
107/// match the JVM.
108#[derive(Default)]
109pub struct Topology {
110    reg: NodeRegistry,
111    error: Option<StoredError>,
112    factories: HashMap<String, NodeFactory>,
113    /// `(changelog_override, factory)`. The override is `None` for the default
114    /// `<app_id>-<store_name>-changelog` derivation.
115    store_factories: HashMap<String, (Option<String>, StoreFactory)>,
116    /// `GlobalKTable` store factories, keyed by store name. These stay SEPARATE
117    /// from `store_factories` for two reasons: per-task `instantiate` must NOT
118    /// build them, because a global store is fully replicated and not
119    /// task-partitioned, and NO changelog topic is emitted. The override is
120    /// always `None` here, because a global store has no changelog, but the
121    /// tuple still mirrors the regular-store shape that the global-store manager
122    /// reads. [`Topology::add_global_store`] fills this map.
123    global_store_factories: HashMap<String, (Option<String>, StoreFactory)>,
124    /// `global store name -> source topic` for each `GlobalKTable`. The shared
125    /// global consumer reads all partitions of each source topic to fully
126    /// replicate the matching store. [`Topology::add_global_store`] fills this
127    /// map together with `global_store_factories`. It is invisible in the wire
128    /// output.
129    global_store_topics: HashMap<String, String>,
130    /// Materialized KV stores that can use a record cache. This is JVM
131    /// `Materialized` caching, which is on by default.
132    /// [`Topology::mark_store_caching`] fills this set at the materialized
133    /// `KTable` and aggregate lowering sites. `instantiate` reads it and wraps the
134    /// store in a [`NamedCache`] when the cache budget is positive.
135    caching_stores: std::collections::HashSet<String>,
136}
137
138impl std::fmt::Debug for Topology {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct("Topology")
141            .field("reg", &self.reg)
142            .field("error", &self.error)
143            .field(
144                "factories",
145                &format!("<{} factories>", self.factories.len()),
146            )
147            .field(
148                "store_factories",
149                &format!("<{} store_factories>", self.store_factories.len()),
150            )
151            .field(
152                "global_store_factories",
153                &format!(
154                    "<{} global_store_factories>",
155                    self.global_store_factories.len()
156                ),
157            )
158            .field("global_store_topics", &self.global_store_topics)
159            .field("caching_stores", &self.caching_stores)
160            .finish()
161    }
162}
163
164/// A typed handle to a node in a [`Topology`].
165///
166/// [`Topology::add_source`] and [`Topology::add_processor`] return a handle.
167/// Pass it by reference as a parent when you add a child node.
168///
169/// Wiring uses values and not string names, so the compiler does two checks.
170/// First, a parent that does not exist yet has no handle to pass, so you cannot
171/// write a forward reference or a cycle. Second, the compiler checks the
172/// parent's output type `(K, V)` against the child's input type. A mismatch is
173/// a **compile error**, not a runtime `build()` failure.
174///
175/// `K` and `V` are the key and value types the node *produces*. The handle is
176/// cheap to [`Clone`], because it carries only the node name, so one parent can
177/// feed many children.
178pub struct NodeHandle<K, V> {
179    name: String,
180    _pd: PhantomData<fn() -> (K, V)>,
181}
182
183impl<K, V> NodeHandle<K, V> {
184    fn new(name: String) -> Self {
185        Self {
186            name,
187            _pd: PhantomData,
188        }
189    }
190
191    /// Reconstruct a typed handle from a node name recorded during DSL lowering.
192    ///
193    /// The DSL lowers a type-erased logical graph. Each lowering thunk knows its
194    /// own concrete `K` and `V` statically and looks up its parent's
195    /// Processor-API node name in `LowerState`. The thunk then rebuilds a typed
196    /// handle to pass to [`Topology::add_processor`] or [`Topology::add_sink`].
197    pub(crate) fn from_name(name: String) -> Self {
198        Self::new(name)
199    }
200
201    /// The node's name, as it appears in the wire topology.
202    ///
203    /// [`Topology::add_state_store`] needs this name, because it connects
204    /// stores to processors by name.
205    #[must_use]
206    pub fn name(&self) -> &str {
207        &self.name
208    }
209}
210
211impl<K, V> Clone for NodeHandle<K, V> {
212    fn clone(&self) -> Self {
213        Self::new(self.name.clone())
214    }
215}
216
217impl<K, V> std::fmt::Debug for NodeHandle<K, V> {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.debug_struct("NodeHandle")
220            .field("name", &self.name)
221            .finish()
222    }
223}
224
225impl Topology {
226    #[must_use]
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    /// Add a source node reading the given external topics with the default
232    /// serdes for `K` and `V`, returning a typed [`NodeHandle`] used to wire
233    /// children to it.
234    ///
235    /// Use [`Topology::add_source_explicit`] when either type has no default
236    /// serde or when a topology needs custom serdes.
237    pub fn add_source<K, V>(
238        &mut self,
239        name: impl Into<String>,
240        topics: impl IntoIterator<Item = impl Into<String>>,
241    ) -> NodeHandle<K, V>
242    where
243        K: Any + Send + Sync + Clone + DefaultSerde,
244        V: Any + Send + Sync + Clone + DefaultSerde,
245    {
246        self.add_source_explicit::<K, V, K::Serde, V::Serde>(
247            name,
248            topics,
249            Consumed::with(K::Serde::default(), V::Serde::default()),
250        )
251    }
252
253    /// Add a source node reading the given external topics with explicit serdes,
254    /// returning a typed [`NodeHandle`] used to wire children to it.
255    ///
256    /// `consumed` carries the key serde and the value serde. At runtime they
257    /// deserialize incoming bytes into typed `Record<K, V>` values. Write it as
258    /// `Consumed::with(key_serde, value_serde)` so the two roles are visible.
259    ///
260    /// Prefer [`Topology::add_source`], the default-serde form, for types that
261    /// implement [`DefaultSerde`]. Use this escape hatch when a type has no
262    /// default serde, or to override the default for a topic. Examples are a
263    /// hand-rolled `Serde<T>`, a **key**-role schema serde
264    /// (`AvroSerde::<T>::key(&cache)`), and a validation-on JSON serde
265    /// (`JsonSerde::value(&cache, true)`).
266    pub fn add_source_explicit<K, V, KS, VS>(
267        &mut self,
268        name: impl Into<String>,
269        topics: impl IntoIterator<Item = impl Into<String>>,
270        consumed: impl Into<Consumed<KS, VS>>,
271    ) -> NodeHandle<K, V>
272    where
273        K: Any + Send + Clone,
274        V: Any + Send + Clone,
275        KS: Serde<K> + Clone,
276        VS: Serde<V> + Clone,
277    {
278        let Consumed {
279            key_serde,
280            value_serde,
281        } = consumed.into();
282        let name: String = name.into();
283        let topics: Vec<String> = topics.into_iter().map(Into::into).collect();
284        // Let each serde pre-register any per-topic state (e.g. a schema-registry
285        // subject) so membership pre-warm can resolve ids before processing.
286        for t in &topics {
287            key_serde.prepare(t, crate::processor::serde::SerdeRole::Key);
288            value_serde.prepare(t, crate::processor::serde::SerdeRole::Value);
289        }
290        let r = self.reg.add_source(&name, topics.clone());
291        self.record(r);
292
293        // Build the make_deser factory closure.
294        let make_deser: MakeDeser = {
295            let ks = key_serde;
296            let vs = value_serde;
297            let n = name.clone();
298            Box::new(move || {
299                let node = SourceNode::new(n.clone(), ks.clone(), vs.clone());
300                Box::new(move |k: Option<&[u8]>, v: &[u8], ts: i64| node.deserialize(k, v, ts))
301                    as Box<dyn Fn(Option<&[u8]>, &[u8], i64) -> Result<_, ProcessorError> + Send>
302            })
303        };
304
305        self.factories.insert(
306            name.clone(),
307            NodeFactory {
308                make_node: None,
309                make_deser: Some(make_deser),
310            },
311        );
312        NodeHandle::new(name)
313    }
314
315    /// Add a processor node fed by the given parent [`NodeHandle`]s, returning a
316    /// handle for its output.
317    ///
318    /// Wiring is by value. Every parent's output type must equal this
319    /// processor's input type `(KIn, VIn)`, and the compiler enforces this.
320    /// `supplier` produces a fresh `Processor` per task. The closure form
321    /// `|| MyProc` satisfies [`ProcessorSupplier`] through a blanket impl, and
322    /// it infers all four KV type parameters from the processor's `Processor`
323    /// impl, so callers never annotate them.
324    pub fn add_processor<KIn, VIn, KOut, VOut, S, P, I>(
325        &mut self,
326        name: impl Into<String>,
327        supplier: S,
328        parents: I,
329    ) -> NodeHandle<KOut, VOut>
330    where
331        KIn: Any + Send,
332        VIn: Any + Send,
333        KOut: Any + Send + Clone,
334        VOut: Any + Send + Clone,
335        S: ProcessorSupplier<KIn, VIn, KOut, VOut> + Clone,
336        I: IntoIterator<Item = P>,
337        P: Borrow<NodeHandle<KIn, VIn>>,
338    {
339        let name: String = name.into();
340        let preds: Vec<String> = parents
341            .into_iter()
342            .map(|p| p.borrow().name.clone())
343            .collect();
344        let r = self.reg.add_processor(&name, preds);
345        self.record(r);
346
347        let make_node: crate::processor::factory::MakeNode = {
348            let n = name.clone();
349            let s = supplier;
350            Box::new(move || {
351                Box::new(ProcessorNode::<KIn, VIn, KOut, VOut>::new(n.clone(), &s))
352                    as Box<dyn ErasedNode>
353            })
354        };
355
356        self.factories.insert(
357            name.clone(),
358            NodeFactory {
359                make_node: Some(make_node),
360                make_deser: None,
361            },
362        );
363        NodeHandle::new(name)
364    }
365
366    /// Add a sink node writing to `topic` with the default serdes for `K` and
367    /// `V`, fed by the given parent [`NodeHandle`]s. Every parent's output type
368    /// must equal the sink's input type `(K, V)`, and the compiler enforces this.
369    ///
370    /// Use [`Topology::add_sink_explicit`] when either type has no default serde
371    /// or when a topology needs custom serdes.
372    pub fn add_sink<K, V>(
373        &mut self,
374        name: impl Into<String>,
375        topic: impl Into<String>,
376        parents: impl IntoIterator<Item = impl Borrow<NodeHandle<K, V>>>,
377    ) where
378        K: Any + Send + Sync + DefaultSerde,
379        V: Any + Send + Sync + DefaultSerde,
380    {
381        self.add_sink_explicit::<K, V, K::Serde, V::Serde, _, _>(
382            name,
383            topic,
384            parents,
385            Produced::with(K::Serde::default(), V::Serde::default()),
386        );
387    }
388
389    /// Add a sink node writing to `topic` with explicit serdes, fed by the given
390    /// parent [`NodeHandle`]s. Every parent's output type must equal the sink's
391    /// input type `(K, V)`, and the compiler enforces this.
392    ///
393    /// `produced` carries the key serde and the value serde that serialize
394    /// outgoing records. Write it as `Produced::with(key_serde, value_serde)`.
395    /// A sink is terminal, so this method returns nothing.
396    ///
397    /// Prefer [`Topology::add_sink`], the default-serde form, for types that
398    /// implement [`DefaultSerde`]. Use this escape hatch when a type has no
399    /// default serde, or to override the default for a topic. Examples are a
400    /// hand-rolled `Serde<T>`, a **key**-role schema serde
401    /// (`AvroSerde::<T>::key(&cache)`), and a validation-on JSON serde
402    /// (`JsonSerde::value(&cache, true)`).
403    pub fn add_sink_explicit<K, V, KS, VS, P, I>(
404        &mut self,
405        name: impl Into<String>,
406        topic: impl Into<String>,
407        parents: I,
408        produced: impl Into<Produced<KS, VS>>,
409    ) where
410        K: Any + Send,
411        V: Any + Send,
412        KS: Serde<K> + Clone,
413        VS: Serde<V> + Clone,
414        I: IntoIterator<Item = P>,
415        P: Borrow<NodeHandle<K, V>>,
416    {
417        let Produced {
418            key_serde,
419            value_serde,
420        } = produced.into();
421        let name: String = name.into();
422        let topic: String = topic.into();
423        // Pre-register per-topic serde state (e.g. schema-registry subject).
424        key_serde.prepare(&topic, crate::processor::serde::SerdeRole::Key);
425        value_serde.prepare(&topic, crate::processor::serde::SerdeRole::Value);
426        let preds: Vec<String> = parents
427            .into_iter()
428            .map(|p| p.borrow().name.clone())
429            .collect();
430        let r = self.reg.add_sink(&name, topic.clone(), preds);
431        self.record(r);
432
433        let make_node: crate::processor::factory::MakeNode = {
434            let n = name.clone();
435            let t = topic.clone();
436            let ks = key_serde;
437            let vs = value_serde;
438            Box::new(move || {
439                Box::new(SinkNode::<K, V, KS, VS>::new(
440                    n.clone(),
441                    t.clone(),
442                    ks.clone(),
443                    vs.clone(),
444                )) as Box<dyn ErasedNode>
445            })
446        };
447
448        self.factories.insert(
449            name,
450            NodeFactory {
451                make_node: Some(make_node),
452                make_deser: None,
453            },
454        );
455    }
456
457    /// Register a state store connected to the given processors, with a
458    /// changelog.
459    ///
460    /// `key_serde` and `value_serde` define how the store serializes records
461    /// into the changelog topic (`<app_id>-<name>-changelog`) and into the
462    /// store's byte map. Stores connect processors that can have different
463    /// types, so the processor list uses names. Pass [`NodeHandle::name`] for a
464    /// handle you hold.
465    pub fn add_state_store<K, V, KS, VS>(
466        &mut self,
467        name: impl Into<String>,
468        key_serde: KS,
469        value_serde: VS,
470        processors: impl IntoIterator<Item = impl Into<String>>,
471    ) -> &mut Self
472    where
473        K: Send + 'static,
474        V: Send + 'static,
475        KS: Serde<K> + Clone,
476        VS: Serde<V> + Clone,
477    {
478        self.add_state_store_inner::<K, V, KS, VS>(name, key_serde, value_serde, processors, None)
479    }
480
481    /// Register a state store whose changelog is an existing **source topic**
482    /// rather than the derived `<app_id>-<name>-changelog`.
483    ///
484    /// This backs the `REUSE_KTABLE_SOURCE_TOPICS` DSL optimizer. A
485    /// `builder.table_explicit(topic, ...)` store can reuse `topic` as its
486    /// changelog. No separate `app-<store>-changelog` topic is created, and the
487    /// wire topology lists `topic` as the store's changelog. `changelog_topic`
488    /// is the topic name used both in the wire `state_changelog_topics` entry
489    /// and as the runtime store's changelog target.
490    pub fn add_state_store_with_changelog<K, V, KS, VS>(
491        &mut self,
492        name: impl Into<String>,
493        key_serde: KS,
494        value_serde: VS,
495        processors: impl IntoIterator<Item = impl Into<String>>,
496        changelog_topic: impl Into<String>,
497    ) -> &mut Self
498    where
499        K: Send + 'static,
500        V: Send + 'static,
501        KS: Serde<K> + Clone,
502        VS: Serde<V> + Clone,
503    {
504        self.add_state_store_inner::<K, V, KS, VS>(
505            name,
506            key_serde,
507            value_serde,
508            processors,
509            Some(changelog_topic.into()),
510        )
511    }
512
513    fn add_state_store_inner<K, V, KS, VS>(
514        &mut self,
515        name: impl Into<String>,
516        key_serde: KS,
517        value_serde: VS,
518        processors: impl IntoIterator<Item = impl Into<String>>,
519        changelog_override: Option<String>,
520    ) -> &mut Self
521    where
522        K: Send + 'static,
523        V: Send + 'static,
524        KS: Serde<K> + Clone,
525        VS: Serde<V> + Clone,
526    {
527        let name: String = name.into();
528        let procs: Vec<String> = processors.into_iter().map(Into::into).collect();
529        self.reg.add_store(&name, procs, changelog_override.clone());
530        self.store_factories.insert(
531            name,
532            (
533                changelog_override,
534                Box::new(
535                    move |store_name: &str,
536                          changelog: String,
537                          backend: Box<dyn crate::store::byte::ByteKeyValueStore>| {
538                        Box::new(crate::store::kv::KeyValueBytesStore::<K, V>::new(
539                            store_name.to_string(),
540                            backend,
541                            Box::new(key_serde.clone()),
542                            Box::new(value_serde.clone()),
543                            changelog,
544                        )) as Box<dyn crate::store::api::StateStore>
545                    },
546                ),
547            ),
548        );
549        self
550    }
551
552    /// Register a windowed state store connected to the given processors.
553    ///
554    /// Like [`add_state_store`] but for windowed stores. The changelog topic
555    /// carries `compact,delete` configs and a `retention.ms` derived from
556    /// `size + grace +` [`CHANGELOG_ADDITIONAL_RETENTION`].
557    ///
558    /// [`add_state_store`]: Topology::add_state_store
559    ///
560    /// The tuple is `(retention_basis, window_size, grace)`. The **retention
561    /// basis** sets the changelog `retention.ms`. For tumbling and hopping
562    /// windows it equals the window size. For sliding windows it is the
563    /// retention span (`2 * time_difference`), which is wider than the window.
564    ///
565    /// The **window size** is the true window length. Only the store's cache
566    /// flush uses it, to rebuild the downstream `Windowed<K>` key's
567    /// `end = start + window_size`, because the store-key bytes hold only the
568    /// start. For sliding windows this is `time_difference` (1x), NOT the
569    /// retention span.
570    // retention basis + window size (key-end) are distinct
571    pub fn add_window_store<K, V, KS, VS>(
572        &mut self,
573        name: impl Into<String>,
574        key_serde: KS,
575        value_serde: VS,
576        window: (Time, Time, Time),
577        processors: impl IntoIterator<Item = impl Into<String>>,
578    ) -> &mut Self
579    where
580        K: Send + 'static,
581        V: Send + 'static,
582        KS: Serde<K> + Clone,
583        VS: Serde<V> + Clone,
584    {
585        let (retention_basis, window_size, grace) = window;
586        let name: String = name.into();
587        let retention = retention_basis + grace + CHANGELOG_ADDITIONAL_RETENTION;
588        let procs: Vec<String> = processors.into_iter().map(Into::into).collect();
589        self.reg.add_window_store(&name, procs, None, retention);
590        self.store_factories.insert(
591            name.clone(),
592            (
593                None,
594                Box::new(
595                    move |store_name: &str,
596                          changelog: String,
597                          backend: Box<dyn crate::store::byte::ByteKeyValueStore>| {
598                        Box::new(crate::store::window::WindowBytesStore::<K, V>::new(
599                            store_name.to_string(),
600                            backend,
601                            Box::new(key_serde.clone()),
602                            Box::new(value_serde.clone()),
603                            changelog,
604                            // Aggregate window stores need the real window size so the
605                            // cache flush can reconstruct `end = start + window_size`
606                            // for the downstream `Windowed` key (the store-key bytes hold
607                            // only the start). This is distinct from the retention basis
608                            // — they diverge for sliding windows.
609                            window_size,
610                        )) as Box<dyn crate::store::api::StateStore>
611                    },
612                ),
613            ),
614        );
615        self
616    }
617
618    /// Register a join window state store connected to the given processors.
619    ///
620    /// Like [`add_window_store`] but for join window stores (retainDuplicates).
621    /// The changelog topic carries `delete`-only configs and a `retention.ms`
622    /// derived from `before + after + grace + `[`CHANGELOG_ADDITIONAL_RETENTION`].
623    /// Compaction is not applicable because the store retains duplicates.
624    ///
625    /// [`add_window_store`]: Topology::add_window_store
626    // mirrors add_window_store + extra before/after split
627    pub fn add_join_window_store<K, V, KS, VS>(
628        &mut self,
629        name: impl Into<String>,
630        key_serde: KS,
631        value_serde: VS,
632        window: (Time, Time, Time),
633        processors: impl IntoIterator<Item = impl Into<String>>,
634    ) -> &mut Self
635    where
636        K: Send + 'static,
637        V: Send + 'static,
638        KS: Serde<K> + Clone,
639        VS: Serde<V> + Clone,
640    {
641        let (before, after, grace) = window;
642        let name: String = name.into();
643        let retention = before + after + grace + CHANGELOG_ADDITIONAL_RETENTION;
644        let procs: Vec<String> = processors.into_iter().map(Into::into).collect();
645        self.reg
646            .add_join_window_store(&name, procs, None, retention);
647        self.store_factories.insert(
648            name.clone(),
649            (
650                None,
651                Box::new(
652                    move |store_name: &str,
653                          changelog: String,
654                          backend: Box<dyn crate::store::byte::ByteKeyValueStore>| {
655                        Box::new(crate::store::join_window::JoinWindowBytesStore::<K, V>::new(
656                            store_name.to_string(),
657                            backend,
658                            Box::new(key_serde.clone()),
659                            Box::new(value_serde.clone()),
660                            changelog,
661                        )) as Box<dyn crate::store::api::StateStore>
662                    },
663                ),
664            ),
665        );
666        self
667    }
668
669    /// Register a session state store connected to the given processors.
670    ///
671    /// Like [`add_window_store`] but for session stores. It reuses the windowed
672    /// (`compact,delete`) changelog config. The `retention.ms` comes from
673    /// `gap + grace + `[`CHANGELOG_ADDITIONAL_RETENTION`]. The store holds the
674    /// raw aggregate (`SessionBytesStore`).
675    ///
676    /// [`add_window_store`]: Topology::add_window_store
677    pub fn add_session_store<K, V, KS, VS>(
678        &mut self,
679        name: impl Into<String>,
680        key_serde: KS,
681        value_serde: VS,
682        gap: Time,
683        grace: Time,
684        processors: impl IntoIterator<Item = impl Into<String>>,
685    ) -> &mut Self
686    where
687        K: Send + 'static,
688        V: Send + 'static,
689        KS: Serde<K> + Clone,
690        VS: Serde<V> + Clone,
691    {
692        let name: String = name.into();
693        let retention = gap + grace + CHANGELOG_ADDITIONAL_RETENTION;
694        let procs: Vec<String> = processors.into_iter().map(Into::into).collect();
695        // Session changelog == windowed changelog (compact,delete + retention);
696        // reuse the AggWindow ChangelogKind via add_window_store.
697        self.reg.add_window_store(&name, procs, None, retention);
698        self.store_factories.insert(
699            name.clone(),
700            (
701                None,
702                Box::new(
703                    move |store_name: &str,
704                          changelog: String,
705                          backend: Box<dyn crate::store::byte::ByteKeyValueStore>| {
706                        Box::new(crate::store::session::SessionBytesStore::<K, V>::new(
707                            store_name.to_string(),
708                            backend,
709                            Box::new(key_serde.clone()),
710                            Box::new(value_serde.clone()),
711                            changelog,
712                        )) as Box<dyn crate::store::api::StateStore>
713                    },
714                ),
715            ),
716        );
717        self
718    }
719
720    /// Register a versioned state store (KIP-889) connected to the given
721    /// processors.
722    ///
723    /// The changelog topic carries `compact` + `min.compaction.lag.ms
724    /// = history_retention + `[`CHANGELOG_ADDITIONAL_RETENTION`]. The
725    /// version-chain store is self-contained in memory, so it does not use the
726    /// supplied byte backend.
727    pub fn add_versioned_store<K, V, KS, VS>(
728        &mut self,
729        name: impl Into<String>,
730        key_serde: KS,
731        value_serde: VS,
732        history_retention: Time,
733        processors: impl IntoIterator<Item = impl Into<String>>,
734    ) -> &mut Self
735    where
736        K: Send + Sync + 'static,
737        V: Send + 'static,
738        KS: Serde<K> + Clone,
739        VS: Serde<V> + Clone,
740    {
741        self.add_versioned_store_inner::<K, V, KS, VS>(
742            name,
743            key_serde,
744            value_serde,
745            history_retention,
746            processors,
747            None,
748        )
749    }
750
751    /// Like [`add_versioned_store`] but the changelog is an existing **source
752    /// topic**. The `REUSE_KTABLE_SOURCE_TOPICS` optimizer points a versioned
753    /// `builder.table_explicit(topic, …)` store's changelog at its own source
754    /// `topic`. This mirrors [`add_state_store_with_changelog`].
755    ///
756    /// [`add_versioned_store`]: Topology::add_versioned_store
757    /// [`add_state_store_with_changelog`]: Topology::add_state_store_with_changelog
758    pub fn add_versioned_store_with_changelog<K, V, KS, VS>(
759        &mut self,
760        name: impl Into<String>,
761        key_serde: KS,
762        value_serde: VS,
763        history_retention: Time,
764        processors: impl IntoIterator<Item = impl Into<String>>,
765        changelog_topic: impl Into<String>,
766    ) -> &mut Self
767    where
768        K: Send + Sync + 'static,
769        V: Send + 'static,
770        KS: Serde<K> + Clone,
771        VS: Serde<V> + Clone,
772    {
773        self.add_versioned_store_inner::<K, V, KS, VS>(
774            name,
775            key_serde,
776            value_serde,
777            history_retention,
778            processors,
779            Some(changelog_topic.into()),
780        )
781    }
782
783    fn add_versioned_store_inner<K, V, KS, VS>(
784        &mut self,
785        name: impl Into<String>,
786        key_serde: KS,
787        value_serde: VS,
788        history_retention: Time,
789        processors: impl IntoIterator<Item = impl Into<String>>,
790        changelog_override: Option<String>,
791    ) -> &mut Self
792    where
793        K: Send + Sync + 'static,
794        V: Send + 'static,
795        KS: Serde<K> + Clone,
796        VS: Serde<V> + Clone,
797    {
798        let name: String = name.into();
799        let min_compaction_lag = history_retention + CHANGELOG_ADDITIONAL_RETENTION;
800        let procs: Vec<String> = processors.into_iter().map(Into::into).collect();
801        self.reg
802            .add_versioned_store(&name, procs, changelog_override.clone(), min_compaction_lag);
803        self.store_factories.insert(
804            name.clone(),
805            (
806                changelog_override,
807                Box::new(
808                    move |store_name: &str,
809                          changelog: String,
810                          _backend: Box<dyn crate::store::byte::ByteKeyValueStore>| {
811                        Box::new(
812                            crate::store::versioned::VersionedBytesStore::<K, V>::new(
813                                store_name.to_string(),
814                                history_retention,
815                                Box::new(key_serde.clone()),
816                                Box::new(value_serde.clone()),
817                                changelog,
818                            ),
819                        ) as Box<dyn crate::store::api::StateStore>
820                    },
821                ),
822            ),
823        );
824        self
825    }
826
827    /// Register a KIP-213 FK-join subscription store connected to the given
828    /// processors.
829    ///
830    /// The subscription store is keyed by `combined_key(fk, pk)` bytes →
831    /// `ValueAndTimestamp<SubscriptionWrapper>` bytes. Its changelog is a plain
832    /// compacted KV changelog (`<app_id>-<name>-changelog`, like
833    /// [`add_state_store`]), NOT windowed retention. The store types are fixed
834    /// to raw bytes in and `SubscriptionWrapper` out, so this method takes no
835    /// key serde and no value serde.
836    ///
837    /// [`add_state_store`]: Topology::add_state_store
838    pub(crate) fn add_fk_subscription_store(
839        &mut self,
840        name: impl Into<String>,
841        processors: impl IntoIterator<Item = impl Into<String>>,
842    ) -> &mut Self {
843        let name: String = name.into();
844        let procs: Vec<String> = processors.into_iter().map(Into::into).collect();
845        self.reg.add_store(&name, procs, None); // plain compact changelog
846        self.store_factories.insert(
847            name,
848            (
849                None,
850                Box::new(
851                    move |store_name: &str,
852                          changelog: String,
853                          backend: Box<dyn crate::store::byte::ByteKeyValueStore>| {
854                        Box::new(crate::store::fk_subscription::SubscriptionBytesStore::new(
855                            store_name.to_string(),
856                            backend,
857                            changelog,
858                        )) as Box<dyn crate::store::api::StateStore>
859                    },
860                ),
861            ),
862        );
863        self
864    }
865
866    /// Register a suppress buffer store connected to the given processor.
867    ///
868    /// The suppress buffer ([`SuppressBytesStore`]) is a time-ordered in-memory
869    /// buffer with its own storage. It does NOT use the pluggable byte backend,
870    /// so the factory ignores the opened backend.
871    ///
872    /// `logging` toggles ONLY the changelog. When `true`, the wire topology
873    /// gets the changelog topic, a plain `cleanup.policy=compact` changelog,
874    /// because the JVM suppress buffer is a compacted KV store, and the store
875    /// logs and restores. When `false`, the store stays in memory and NO
876    /// changelog topic appears, so a logging-off suppress has the same wire
877    /// shape as an unsuppressed topology.
878    ///
879    /// [`SuppressBytesStore`]: crate::store::suppress_store::SuppressBytesStore
880    pub fn add_suppress_store<K, V, KS, VS>(
881        &mut self,
882        name: impl Into<String>,
883        key_serde: KS,
884        value_serde: VS,
885        logging: bool,
886        processors: impl IntoIterator<Item = impl Into<String>>,
887    ) -> &mut Self
888    where
889        K: Send + 'static,
890        V: Send + 'static,
891        KS: Serde<K> + Clone,
892        VS: Serde<V> + Clone,
893    {
894        let name: String = name.into();
895        let procs: Vec<String> = processors.into_iter().map(Into::into).collect();
896        // Logging toggles ONLY the changelog topic; the runtime store is always
897        // registered so the processor can buffer through it either way. The suppress
898        // changelog is a plain compacted KV changelog (ChangelogKind::Kv).
899        if logging {
900            self.reg.add_store(&name, procs, None);
901        }
902        self.store_factories.insert(
903            name.clone(),
904            (
905                None,
906                Box::new(
907                    move |store_name: &str,
908                          changelog: String,
909                          _backend: Box<dyn crate::store::byte::ByteKeyValueStore>| {
910                        // logging off → empty changelog (never flushed) + flag off.
911                        let cl = if logging { changelog } else { String::new() };
912                        let mut store =
913                            crate::store::suppress_store::SuppressBytesStore::<K, V>::new(
914                                store_name.to_string(),
915                                Box::new(key_serde.clone()),
916                                Box::new(value_serde.clone()),
917                                cl,
918                            );
919                        if !logging {
920                            crate::store::api::StateStore::set_logging(&mut store, false);
921                        }
922                        Box::new(store) as Box<dyn crate::store::api::StateStore>
923                    },
924                ),
925            ),
926        );
927        self
928    }
929
930    /// Register a join-grace buffer store (KIP-923) connected to the given
931    /// processor.
932    ///
933    /// The grace buffer ([`JoinGraceBufferStore`]) is a stream-side,
934    /// time-ordered in-memory buffer with its own storage. It does NOT use the
935    /// pluggable byte backend, so the factory ignores the opened backend. Its
936    /// changelog is a COMPACTED KV changelog (`ChangelogKind::Kv` →
937    /// `cleanup.policy=compact`, `message.timestamp.type=CreateTime`, NO
938    /// explicit `retention.ms`), pinned from the JVM 4.1.0 capture.
939    ///
940    /// `logging` toggles ONLY the changelog. When `true`, the changelog topic
941    /// is emitted and the store logs and restores. When `false`, the store
942    /// stays in memory and NO changelog topic appears.
943    ///
944    /// [`JoinGraceBufferStore`]: crate::store::join_grace_buffer::JoinGraceBufferStore
945    pub(crate) fn add_join_grace_store<K, V, KS, VS>(
946        &mut self,
947        name: impl Into<String>,
948        key_serde: KS,
949        value_serde: VS,
950        logging: bool,
951        processors: impl IntoIterator<Item = impl Into<String>>,
952    ) -> &mut Self
953    where
954        K: Send + 'static,
955        V: Send + 'static,
956        KS: Serde<K> + Clone,
957        VS: Serde<V> + Clone,
958    {
959        let name: String = name.into();
960        let procs: Vec<String> = processors.into_iter().map(Into::into).collect();
961        // Logging toggles ONLY the changelog topic; the runtime store is always
962        // registered so the processor can buffer through it either way. The grace
963        // buffer changelog is a plain compacted KV changelog (ChangelogKind::Kv).
964        if logging {
965            self.reg.add_store(&name, procs, None);
966        }
967        self.store_factories.insert(
968            name.clone(),
969            (
970                None,
971                Box::new(
972                    move |store_name: &str,
973                          changelog: String,
974                          _backend: Box<dyn crate::store::byte::ByteKeyValueStore>| {
975                        // logging off → empty changelog (never flushed) + flag off.
976                        let cl = if logging { changelog } else { String::new() };
977                        let mut store =
978                            crate::store::join_grace_buffer::JoinGraceBufferStore::<K, V>::new(
979                                store_name.to_string(),
980                                Box::new(key_serde.clone()),
981                                Box::new(value_serde.clone()),
982                                cl,
983                            );
984                        if !logging {
985                            crate::store::api::StateStore::set_logging(&mut store, false);
986                        }
987                        Box::new(store) as Box<dyn crate::store::api::StateStore>
988                    },
989                ),
990            ),
991        );
992        self
993    }
994
995    /// Register a state store **without** a changelog topic.
996    ///
997    /// The store is available at runtime for in-memory state, but NO entry is
998    /// emitted in the wire topology's `state_changelog_topics` array. This backs
999    /// `Materialized::with_logging(false)` in the DSL.
1000    pub(crate) fn add_state_store_no_changelog<K, V, KS, VS>(
1001        &mut self,
1002        name: impl Into<String>,
1003        key_serde: KS,
1004        value_serde: VS,
1005    ) -> &mut Self
1006    where
1007        K: Send + 'static,
1008        V: Send + 'static,
1009        KS: Serde<K> + Clone,
1010        VS: Serde<V> + Clone,
1011    {
1012        let name: String = name.into();
1013        // Insert only into store_factories (runtime use) — NOT into reg.stores,
1014        // so no changelog topic appears in the wire topology.
1015        self.store_factories.insert(
1016            name,
1017            (
1018                None, // no changelog
1019                Box::new(
1020                    move |store_name: &str,
1021                          _changelog: String,
1022                          backend: Box<dyn crate::store::byte::ByteKeyValueStore>| {
1023                        // No changelog: pass empty string so the store never flushes.
1024                        Box::new(crate::store::kv::KeyValueBytesStore::<K, V>::new(
1025                            store_name.to_string(),
1026                            backend,
1027                            Box::new(key_serde.clone()),
1028                            Box::new(value_serde.clone()),
1029                            String::new(),
1030                        )) as Box<dyn crate::store::api::StateStore>
1031                    },
1032                ),
1033            ),
1034        );
1035        self
1036    }
1037
1038    /// Register a `GlobalKTable` source, update-processor, and KV store.
1039    ///
1040    /// A `GlobalKTable` is **invisible in the wire**. It has no subtopology of
1041    /// its own and no changelog topic. Its source node still takes a node-group
1042    /// index during grouping, so other subtopology ids shift. This method does
1043    /// three things:
1044    ///
1045    /// 1. it registers a source node reading `topic` and a processor node fed
1046    ///    by it, and the source→processor edge unites them into one node group;
1047    /// 2. it marks `topic` global so the grouping pass skips it in the
1048    ///    source-bucketing pass. The final filter drops the resulting
1049    ///    source-less group, but that group already took its index;
1050    /// 3. it stores the global KV factory in a SEPARATE map
1051    ///    (`global_store_factories`, NOT `store_factories`) so per-task
1052    ///    `instantiate` does not build it and NO changelog topic is emitted. The
1053    ///    factory builds a [`KeyValueBytesStore`] with an empty changelog.
1054    ///
1055    /// Per-task `instantiate` does not build the global store. The
1056    /// fully-replicated global-store manager reads the factory.
1057    ///
1058    /// [`KeyValueBytesStore`]: crate::store::kv::KeyValueBytesStore
1059    pub fn add_global_store<K, V, KS, VS>(
1060        &mut self,
1061        store_name: impl Into<String>,
1062        source_name: impl Into<String>,
1063        topic: impl Into<String>,
1064        processor_name: impl Into<String>,
1065        consumed: impl Into<Consumed<KS, VS>>,
1066    ) -> &mut Self
1067    where
1068        K: Send + 'static,
1069        V: Send + 'static,
1070        KS: Serde<K> + Clone,
1071        VS: Serde<V> + Clone,
1072    {
1073        let store_name: String = store_name.into();
1074        let source_name: String = source_name.into();
1075        let topic: String = topic.into();
1076        let processor_name: String = processor_name.into();
1077        let Consumed {
1078            key_serde,
1079            value_serde,
1080        } = consumed.into();
1081
1082        // (a) source node reading the global topic (consumes a node-group index).
1083        let r = self.reg.add_source(&source_name, vec![topic.clone()]);
1084        self.record(r);
1085        // (b) update-processor fed by the source — the edge unites them.
1086        let r = self
1087            .reg
1088            .add_processor(&processor_name, vec![source_name.clone()]);
1089        self.record(r);
1090        // (c) mark the topic global so grouping skips it (group ends source-less).
1091        self.reg.add_global_source(&topic);
1092
1093        // (d) global KV factory, in the SEPARATE global map. The override is
1094        //     `None`; the factory uses an empty changelog so the store never
1095        //     flushes. `instantiate` only iterates `store_factories`, so it ignores
1096        //     this and no changelog topic is emitted.
1097        let factory: StoreFactory = Box::new(
1098            move |sn: &str,
1099                  _changelog: String,
1100                  backend: Box<dyn crate::store::byte::ByteKeyValueStore>| {
1101                Box::new(crate::store::kv::KeyValueBytesStore::<K, V>::new(
1102                    sn.to_string(),
1103                    backend,
1104                    Box::new(key_serde.clone()),
1105                    Box::new(value_serde.clone()),
1106                    String::new(),
1107                )) as Box<dyn crate::store::api::StateStore>
1108            },
1109        );
1110        self.global_store_topics.insert(store_name.clone(), topic);
1111        self.global_store_factories
1112            .insert(store_name, (None, factory));
1113        self
1114    }
1115
1116    /// Connect an additional processor to an already-registered state store.
1117    ///
1118    /// This mirrors `InternalTopologyBuilder.connectProcessorAndStateStores`. A
1119    /// join processor must read the joined table's store even though it was not
1120    /// the store's original owner. The grouping pass unions all connected
1121    /// processors into one subtopology, so a second processor here is enough to
1122    /// pull the join processor into the same subtopology as the store.
1123    pub fn connect_processor_store(&mut self, processor: &str, store: &str) -> &mut Self {
1124        self.reg.connect_processor_store(processor, store);
1125        self
1126    }
1127
1128    /// Mark a materialized store as record-cache eligible.
1129    ///
1130    /// This is JVM `Materialized` caching, which is on by default. The
1131    /// materialized `KTable` and aggregate lowering sites call this method with
1132    /// the `Materialized`'s `caching_enabled()`. `on == false`, which
1133    /// `with_caching(false)` gives, leaves the store uncached.
1134    pub(crate) fn mark_store_caching(&mut self, name: &str, on: bool) {
1135        if on {
1136            self.caching_stores.insert(name.to_string());
1137        }
1138    }
1139
1140    /// Return the connected-processor list for a store (test helper).
1141    #[cfg(test)]
1142    pub(crate) fn store_entry_for_test(&self, store: &str) -> Option<Vec<String>> {
1143        self.reg
1144            .stores
1145            .iter()
1146            .find(|e| e.name == store)
1147            .map(|e| e.processors.clone())
1148    }
1149
1150    /// Whether a global store factory is registered under `store` (test helper).
1151    #[cfg(test)]
1152    pub(crate) fn has_global_store_for_test(&self, store: &str) -> bool {
1153        self.global_store_factories.contains_key(store)
1154    }
1155
1156    /// Register a topic name as an internal repartition topic.
1157    pub fn add_repartition_topic<S: Into<String>>(&mut self, name: S) -> &mut Self {
1158        self.reg.repartition_topics.insert(name.into());
1159        self
1160    }
1161
1162    /// Declare a copartition group. The given member topics must share a
1163    /// partitioning.
1164    ///
1165    /// The grouping pass assigns the group to the subtopology that reads its
1166    /// members. The wire layer encodes member names as `int16` indices into that
1167    /// subtopology's sorted source and repartition arrays. Joins need this
1168    /// (KIP-1071).
1169    pub fn add_copartition_group(
1170        &mut self,
1171        topics: impl IntoIterator<Item = impl Into<String>>,
1172    ) -> &mut Self {
1173        self.reg
1174            .add_copartition_group(topics.into_iter().map(Into::into).collect());
1175        self
1176    }
1177
1178    /// Derive subtopologies and the wire topology.
1179    ///
1180    /// `application_id` sets the internal-topic names
1181    /// (`<app>-<store>-changelog`).
1182    ///
1183    /// The typed [`NodeHandle`] wiring already guarantees that parent→child KV
1184    /// types match, so `build()` checks only the structural invariants: no
1185    /// duplicate names, every predecessor exists, and at least one source. The
1186    /// wire `Topology` is byte-identical to the untyped implementation.
1187    /// # Errors
1188    /// Returns an error when configuration is invalid, protocol encoding fails, the broker rejects the request, or transport I/O fails.
1189    pub fn build<S: Into<String>>(
1190        mut self,
1191        application_id: S,
1192    ) -> Result<BuiltTopology, TopologyError> {
1193        if let Some(e) = self.error.take() {
1194            return Err(e.into());
1195        }
1196        self.reg.validate_predecessors()?;
1197        let groups = group_nodes(&self.reg);
1198        if groups.is_empty() {
1199            return Err(TopologyError::Empty);
1200        }
1201        let app = application_id.into();
1202        let wire = to_wire(&groups, &app);
1203
1204        // Build source_topics map (unchanged from original).
1205        let mut source_topics: BTreeMap<String, Vec<String>> = BTreeMap::new();
1206        for g in &groups {
1207            let mut all = g.source_topics.clone();
1208            all.extend(g.repartition_source_topics.iter().cloned());
1209            source_topics.insert(g.id.clone(), all);
1210        }
1211        let mut changelog_topics: Vec<(String, String)> = groups
1212            .iter()
1213            .flat_map(|g| g.changelog_stores.iter())
1214            .map(|(store, changelog_override, _kind)| {
1215                (
1216                    store.clone(),
1217                    changelog_topic_name(&app, store, changelog_override.as_deref()),
1218                )
1219            })
1220            .collect();
1221        changelog_topics.sort();
1222
1223        // ── Identify the GlobalKTable source + update-processor nodes ─────────
1224        // A `GlobalKTable` source/processor is invisible in the wire AND has no
1225        // per-task runtime factory (the fully-replicated global store is built by
1226        // the shared global manager or populated directly by the TestDriver, NOT
1227        // by per-task `instantiate`). Excluding them from
1228        // `node_specs` keeps `instantiate` from trying to build a node it has no
1229        // factory for. A node is global if it reads a global source topic, or if
1230        // any predecessor is global (nodes are in topological insertion order, so
1231        // one forward pass suffices).
1232        let mut global_nodes: std::collections::HashSet<&str> = std::collections::HashSet::new();
1233        for n in &self.reg.nodes {
1234            let is_global = match &n.kind {
1235                NodeKind::Source { topics } => topics
1236                    .iter()
1237                    .any(|t| self.reg.global_source_topics.contains(t)),
1238                NodeKind::Processor { predecessors } | NodeKind::Sink { predecessors, .. } => {
1239                    predecessors
1240                        .iter()
1241                        .any(|p| global_nodes.contains(p.as_str()))
1242                }
1243            };
1244            if is_global {
1245                global_nodes.insert(n.name.as_str());
1246            }
1247        }
1248
1249        // The non-global nodes of each subtopology, for a graph that runs one
1250        // subtopology alone.
1251        let subtopology_nodes: BTreeMap<String, HashSet<String>> = groups
1252            .iter()
1253            .map(|g| {
1254                let nodes = g
1255                    .nodes
1256                    .iter()
1257                    .filter(|n| !global_nodes.contains(n.as_str()))
1258                    .cloned()
1259                    .collect();
1260                (g.id.clone(), nodes)
1261            })
1262            .collect();
1263
1264        // ── Build node specs for instantiation (excluding global nodes) ───────
1265        let node_specs: Vec<NodeSpec> = self
1266            .reg
1267            .nodes
1268            .iter()
1269            .filter(|n| !global_nodes.contains(n.name.as_str()))
1270            .map(|n| {
1271                let (predecessors, kind_str, st, sink_t) = match &n.kind {
1272                    NodeKind::Source { topics } => (Vec::new(), "source", topics.clone(), None),
1273                    NodeKind::Processor { predecessors } => {
1274                        (predecessors.clone(), "processor", Vec::new(), None)
1275                    }
1276                    NodeKind::Sink {
1277                        predecessors,
1278                        topic,
1279                    } => (
1280                        predecessors.clone(),
1281                        "sink",
1282                        Vec::new(),
1283                        Some(topic.clone()),
1284                    ),
1285                };
1286                NodeSpec {
1287                    name: n.name.clone(),
1288                    kind: kind_str,
1289                    predecessors,
1290                    source_topics: st,
1291                    sink_topic: sink_t,
1292                }
1293            })
1294            .collect();
1295
1296        // Store name → connected processor names (used by `instantiate` to root a
1297        // cached store's forwarded changes at its materializing processor's node).
1298        let store_processors: HashMap<String, Vec<String>> = self
1299            .reg
1300            .stores
1301            .iter()
1302            .map(|e| (e.name.clone(), e.processors.clone()))
1303            .collect();
1304
1305        Ok(BuiltTopology {
1306            wire,
1307            source_topics,
1308            changelog_topics,
1309            subtopology_nodes,
1310            application_id: app,
1311            factories: self.factories,
1312            node_specs,
1313            store_factories: self.store_factories,
1314            global_store_factories: self.global_store_factories,
1315            global_store_topics: self.global_store_topics,
1316            caching_stores: self.caching_stores,
1317            store_processors,
1318        })
1319    }
1320
1321    fn record(&mut self, r: Result<(), TopologyError>) {
1322        if self.error.is_none()
1323            && let Err(e) = r
1324        {
1325            self.error = Some(e.into());
1326        }
1327    }
1328}
1329
1330// ──────────────────────────────────────────────────────────────────────────────
1331// NodeSpec
1332// ──────────────────────────────────────────────────────────────────────────────
1333
1334/// Lightweight description of one node's wiring, with no type parameters.
1335///
1336/// `BuiltTopology::instantiate()` uses this description.
1337pub(crate) struct NodeSpec {
1338    pub name: String,
1339    /// `"source"` | `"processor"` | `"sink"`
1340    pub kind: &'static str,
1341    /// Predecessor names (empty for sources).
1342    pub predecessors: Vec<String>,
1343    /// Topics read (sources only; empty otherwise).
1344    pub source_topics: Vec<String>,
1345    /// Topic written (sinks only; `None` otherwise).
1346    pub sink_topic: Option<String>,
1347}
1348
1349// ──────────────────────────────────────────────────────────────────────────────
1350// BuiltTopology
1351// ──────────────────────────────────────────────────────────────────────────────
1352
1353/// A built topology: the wire `Topology` plus the per-subtopology source-topic
1354/// map that resolves task assignments to concrete topic-partitions.
1355///
1356/// NOTE: `BuiltTopology` is **not** `Clone`, because the node factories hold
1357/// `Box<dyn Fn…>` closures that are not cloneable. The membership client wraps
1358/// it in an `Arc<BuiltTopology>`. Use that to share it across tasks.
1359pub struct BuiltTopology {
1360    wire: WireTopology,
1361    source_topics: BTreeMap<String, Vec<String>>,
1362    /// Every logged store and its changelog topic, as `(store, topic)` in
1363    /// ascending store order. The wire `state_changelog_topics` arrays carry
1364    /// the same topics without the store names.
1365    changelog_topics: Vec<(String, String)>,
1366    /// Subtopology id to the names of its nodes, global nodes excluded.
1367    /// [`instantiate_subtopology`](Self::instantiate_subtopology) builds one
1368    /// subtopology's graph from it.
1369    subtopology_nodes: BTreeMap<String, HashSet<String>>,
1370    application_id: String,
1371    factories: HashMap<String, NodeFactory>,
1372    node_specs: Vec<NodeSpec>,
1373    /// `(changelog_override, factory)`. The override is `None` for the default
1374    /// `<app_id>-<store_name>-changelog` derivation.
1375    store_factories: HashMap<String, (Option<String>, StoreFactory)>,
1376    /// `GlobalKTable` store factories, separate from `store_factories`. Per-task
1377    /// `instantiate` does NOT build them. The fully-replicated global-store
1378    /// runtime (`StreamThread`) reads these to build and restore each global
1379    /// store once. The [`TopologyTestDriver`] reads them through
1380    /// [`global_store_factories`](Self::global_store_factories)
1381    /// to materialize global stores directly for join tests.
1382    global_store_factories: HashMap<String, (Option<String>, StoreFactory)>,
1383    /// `global store name -> source topic` for each `GlobalKTable`. The shared
1384    /// global manager reads this so the consumer knows which topic feeds each
1385    /// store.
1386    global_store_topics: HashMap<String, String>,
1387    /// Materialized KV stores that can use a record cache. This is JVM
1388    /// `Materialized` caching, which is on by default; `with_caching(false)`
1389    /// opts out. `instantiate` wraps each store in a [`NamedCache`] when the
1390    /// cache budget is positive.
1391    caching_stores: std::collections::HashSet<String>,
1392    /// Store name → its connected processor names. A cached store has one
1393    /// materializing processor. `instantiate` maps that processor name to the
1394    /// node's graph index to root the store's `cache_owner` entry.
1395    store_processors: HashMap<String, Vec<String>>,
1396}
1397
1398impl std::fmt::Debug for BuiltTopology {
1399    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1400        f.debug_struct("BuiltTopology")
1401            .field("application_id", &self.application_id)
1402            .field("source_topics", &self.source_topics)
1403            .finish_non_exhaustive()
1404    }
1405}
1406
1407impl BuiltTopology {
1408    /// The wire `Topology` as a serde-serializable view, for golden-frame
1409    /// interop assertions against captured JVM fixtures.
1410    #[must_use]
1411    pub fn to_wire(&self) -> super::wire::WireTopology {
1412        super::wire::WireTopology::from(&self.wire)
1413    }
1414
1415    /// The byte-exact KIP-1071 `StreamsGroupHeartbeat` `Topology`.
1416    ///
1417    /// The membership client sends this value when it joins the streams group,
1418    /// and an embedder that runs its own membership sends the same value.
1419    #[must_use]
1420    pub fn to_wire_request(&self) -> WireTopology {
1421        self.wire.clone()
1422    }
1423
1424    /// The subtopology ids, in the order the wire `Topology` lists them.
1425    ///
1426    /// Each id names one task per source partition, so an embedder wires one
1427    /// [`EmbeddedTask`](crate::EmbeddedTask) per `(subtopology, partition)`.
1428    #[must_use]
1429    pub fn subtopology_ids(&self) -> Vec<String> {
1430        self.wire
1431            .subtopologies
1432            .iter()
1433            .map(|s| s.subtopology_id.clone())
1434            .collect()
1435    }
1436
1437    /// The internal repartition topics, sorted and without duplicates.
1438    ///
1439    /// A subtopology writes a repartition topic through a sink, and another
1440    /// subtopology reads it as a source. The broker creates these topics when
1441    /// the group joins, so an embedder that plays the broker creates them too.
1442    #[must_use]
1443    pub fn repartition_topics(&self) -> Vec<String> {
1444        let mut topics: Vec<String> = self
1445            .wire
1446            .subtopologies
1447            .iter()
1448            .flat_map(|s| {
1449                s.repartition_sink_topics.iter().cloned().chain(
1450                    s.repartition_source_topics
1451                        .iter()
1452                        .map(|info| info.name.clone()),
1453                )
1454            })
1455            .collect();
1456        topics.sort();
1457        topics.dedup();
1458        topics
1459    }
1460
1461    /// Every logged store and its changelog topic, as `(store, topic)` pairs in
1462    /// ascending store order.
1463    ///
1464    /// The topic is the `REUSE_KTABLE_SOURCE_TOPICS` source topic when the
1465    /// optimizer reuses one, else `<application_id>-<store>-changelog`. A store
1466    /// with logging off has no pair, and neither does a global store.
1467    #[must_use]
1468    pub fn changelog_topics(&self) -> &[(String, String)] {
1469        &self.changelog_topics
1470    }
1471
1472    /// The `GlobalKTable` store factories, keyed by store name.
1473    ///
1474    /// The [`TopologyTestDriver`] materializes these directly into its per-task
1475    /// store registry so a stream-globaltable join can find them. The real
1476    /// runtime (`StreamThread`) builds them once in a shared global manager,
1477    /// NOT per task.
1478    ///
1479    /// [`TopologyTestDriver`]: crate::TopologyTestDriver
1480    pub(crate) fn global_store_factories(
1481        &self,
1482    ) -> &HashMap<String, (Option<String>, StoreFactory)> {
1483        &self.global_store_factories
1484    }
1485
1486    /// The `global store name -> source topic` map for each `GlobalKTable`.
1487    ///
1488    /// The shared global-store manager reads this so the global consumer knows
1489    /// which topic feeds each store, and an embedder reads it to know which
1490    /// topics to replay into [`EmbeddedTask::apply_global`]. The map is
1491    /// invisible in the wire output.
1492    ///
1493    /// [`EmbeddedTask::apply_global`]: crate::EmbeddedTask::apply_global
1494    #[must_use]
1495    pub fn global_store_topics(&self) -> HashMap<String, String> {
1496        self.global_store_topics.clone()
1497    }
1498
1499    /// The external and repartition source topics that a subtopology's tasks
1500    /// read.
1501    #[must_use]
1502    pub fn source_topics_for(&self, subtopology_id: &str) -> &[String] {
1503        self.source_topics
1504            .get(subtopology_id)
1505            .map_or(&[], Vec::as_slice)
1506    }
1507
1508    /// The application id, which sets the internal-topic names.
1509    #[must_use]
1510    pub fn application_id(&self) -> &str {
1511        &self.application_id
1512    }
1513
1514    /// Topics that are sources in this topology, for the test driver and the
1515    /// repartition loopback.
1516    #[must_use]
1517    pub fn list_source_topics(&self) -> Vec<String> {
1518        self.node_specs
1519            .iter()
1520            .filter(|s| s.kind == "source")
1521            .flat_map(|s| s.source_topics.iter().cloned())
1522            .collect()
1523    }
1524
1525    /// Topics that sinks in this topology write to.
1526    #[must_use]
1527    pub fn list_sink_topics(&self) -> Vec<String> {
1528        self.node_specs
1529            .iter()
1530            .filter_map(|s| s.sink_topic.clone())
1531            .collect()
1532    }
1533
1534    /// Instantiate a runnable [`Graph`] for this topology.
1535    ///
1536    /// Each call produces an independent graph with its own processor instances
1537    /// and a fresh byte-store backend opened through `backend`. The graph holds
1538    /// every subtopology.
1539    #[tracing::instrument(
1540        name = "streams.topology.instantiate",
1541        level = "info",
1542        skip_all,
1543        fields(app_id = %app_id, nodes = self.node_specs.len(), cache_max_bytes = cache_max_bytes.bytes_i64()),
1544        err,
1545    )]
1546    pub(crate) async fn instantiate(
1547        &self,
1548        backend: &crate::store::backend::StoreBackend,
1549        app_id: &str,
1550        cache_max_bytes: ByteSize,
1551    ) -> Result<Graph, ProcessorError> {
1552        self.instantiate_nodes(backend, app_id, cache_max_bytes, None)
1553            .await
1554    }
1555
1556    /// Instantiate a runnable [`Graph`] of one subtopology: its sources,
1557    /// processors and sinks, and the stores its processors connect to.
1558    ///
1559    /// # Errors
1560    ///
1561    /// Returns [`ProcessorError::UnknownSubtopology`] when the topology has no
1562    /// subtopology `subtopology_id`, else what [`instantiate`](Self::instantiate)
1563    /// returns.
1564    #[tracing::instrument(
1565        name = "streams.topology.instantiate_subtopology",
1566        level = "info",
1567        skip_all,
1568        fields(app_id = %app_id, subtopology_id = %subtopology_id, cache_max_bytes = cache_max_bytes.bytes_i64()),
1569        err,
1570    )]
1571    pub(crate) async fn instantiate_subtopology(
1572        &self,
1573        backend: &crate::store::backend::StoreBackend,
1574        app_id: &str,
1575        cache_max_bytes: ByteSize,
1576        subtopology_id: &str,
1577    ) -> Result<Graph, ProcessorError> {
1578        let scope = self.subtopology_nodes.get(subtopology_id).ok_or_else(|| {
1579            ProcessorError::UnknownSubtopology {
1580                id: subtopology_id.to_string(),
1581            }
1582        })?;
1583        self.instantiate_nodes(backend, app_id, cache_max_bytes, Some(scope))
1584            .await
1585    }
1586
1587    /// Instantiates the graph of the nodes in `scope`, or of every node when
1588    /// `scope` is `None`. A store is built when one of its processors is.
1589    async fn instantiate_nodes(
1590        &self,
1591        backend: &crate::store::backend::StoreBackend,
1592        app_id: &str,
1593        cache_max_bytes: ByteSize,
1594        scope: Option<&HashSet<String>>,
1595    ) -> Result<Graph, ProcessorError> {
1596        let keep = |name: &str| scope.is_none_or(|set| set.contains(name));
1597        // 1. Collect the processor/sink nodes in spec order and build a name→idx map.
1598        //    Sources are NOT in the nodes vec — they become GraphSources.
1599        let non_source: Vec<&NodeSpec> = self
1600            .node_specs
1601            .iter()
1602            .filter(|s| s.kind != "source" && keep(&s.name))
1603            .collect();
1604
1605        let name_to_idx: HashMap<&str, usize> = non_source
1606            .iter()
1607            .enumerate()
1608            .map(|(i, s)| (s.name.as_str(), i))
1609            .collect();
1610
1611        let nodes: Vec<Box<dyn ErasedNode>> = non_source
1612            .iter()
1613            .map(|s| {
1614                let factory = self
1615                    .factories
1616                    .get(&s.name)
1617                    .ok_or_else(|| ProcessorError::Serde {
1618                        node: s.name.clone(),
1619                        message: "factory missing".into(),
1620                    })?;
1621                let make = factory
1622                    .make_node
1623                    .as_ref()
1624                    .ok_or_else(|| ProcessorError::Serde {
1625                        node: s.name.clone(),
1626                        message: "make_node missing".into(),
1627                    })?;
1628                Ok((make)())
1629            })
1630            .collect::<Result<Vec<_>, ProcessorError>>()?;
1631
1632        // 2. Build children[idx] = indices of nodes that have this node as a predecessor.
1633        let mut children: Vec<Vec<usize>> = vec![Vec::new(); nodes.len()];
1634        for (child_idx, s) in non_source.iter().enumerate() {
1635            for parent_name in &s.predecessors {
1636                // parent might be a source (not in name_to_idx) or a processor/sink
1637                if let Some(&parent_idx) = name_to_idx.get(parent_name.as_str()) {
1638                    children[parent_idx].push(child_idx);
1639                }
1640            }
1641        }
1642
1643        // 3. Build GraphSources.
1644        let sources: Vec<GraphSource> = self
1645            .node_specs
1646            .iter()
1647            .filter(|s| s.kind == "source" && keep(&s.name))
1648            .flat_map(|src_spec| {
1649                // Find which processor/sink nodes list this source as a predecessor.
1650                let src_name = src_spec.name.as_str();
1651                let src_children: Vec<usize> = non_source
1652                    .iter()
1653                    .enumerate()
1654                    .filter(|(_, s)| s.predecessors.iter().any(|p| p == src_name))
1655                    .map(|(i, _)| i)
1656                    .collect();
1657
1658                let Some(factory) = self.factories.get(src_spec.name.as_str()) else {
1659                    return vec![];
1660                };
1661                let Some(make_deser) = &factory.make_deser else {
1662                    return vec![];
1663                };
1664
1665                src_spec
1666                    .source_topics
1667                    .iter()
1668                    .map(|topic| {
1669                        let deser = (make_deser)();
1670                        GraphSource {
1671                            topic: topic.clone(),
1672                            deserialize: deser,
1673                            children: src_children.clone(),
1674                        }
1675                    })
1676                    .collect()
1677            })
1678            .collect();
1679
1680        // Build the per-task store registry from the typed factories.
1681        let mut store_registry = crate::store::registry::StoreRegistry::default();
1682        for (store_name, (changelog_override, factory)) in &self.store_factories {
1683            let connected = scope.is_none_or(|_| {
1684                self.store_processors
1685                    .get(store_name)
1686                    .is_some_and(|procs| procs.iter().any(|p| keep(p)))
1687            });
1688            if !connected {
1689                continue;
1690            }
1691            let changelog = changelog_override
1692                .clone()
1693                .unwrap_or_else(|| format!("{app_id}-{store_name}-changelog"));
1694            let bytes = backend.open(app_id, store_name).await;
1695            store_registry.insert(factory(store_name, changelog, bytes));
1696        }
1697
1698        // Build-time record-cache wiring (see `wire_record_caches`): a no-op when
1699        // the budget is zero (TopologyTestDriver) or no store is marked.
1700        let (cache, cache_owner) =
1701            self.wire_record_caches(&mut store_registry, &name_to_idx, cache_max_bytes);
1702
1703        Ok(Graph {
1704            nodes,
1705            children,
1706            sources,
1707            output: Vec::new(),
1708            stores: store_registry,
1709            // Default-empty here; app wiring / TopologyTestDriver builds
1710            // and assigns the shared GlobalStateManager. `instantiate` produces a
1711            // per-task graph and never owns the fully-replicated global stores.
1712            globals: crate::runtime::global::GlobalStateManager::default(),
1713            schedules: Vec::new(),
1714            stream_time: i64::MIN,
1715            wall_clock: 0,
1716            cache_max_bytes,
1717            cache_owner,
1718            cache,
1719        })
1720    }
1721
1722    /// Wire the record caches at build time.
1723    ///
1724    /// For each materialized KV store marked cache-eligible by the default-on
1725    /// `Materialized` caching, this method does three things. It registers a
1726    /// [`NamedCache`](crate::store::cache::named::NamedCache) in the per-task
1727    /// [`ThreadCache`](crate::store::cache::thread::ThreadCache). It wraps the
1728    /// typed store's backend through the erased
1729    /// [`enable_cache_erased`](crate::store::api::StateStore::enable_cache_erased)
1730    /// hook. It then roots the store's forwarded changes at the materializing
1731    /// node.
1732    ///
1733    /// The method returns the populated `ThreadCache` and the `store name →
1734    /// owning node index` map for `Graph::flush_caches`. With a zero budget, as
1735    /// in the `TopologyTestDriver`, or with no marked stores, the cache and the
1736    /// owner map both stay empty. Every store then stays uncached and the
1737    /// goldens do not change.
1738    fn wire_record_caches(
1739        &self,
1740        store_registry: &mut crate::store::registry::StoreRegistry,
1741        name_to_idx: &HashMap<&str, usize>,
1742        cache_max_bytes: ByteSize,
1743    ) -> (
1744        crate::store::cache::thread::ThreadCache,
1745        HashMap<String, usize>,
1746    ) {
1747        let mut cache = crate::store::cache::thread::ThreadCache::new(cache_max_bytes);
1748        let mut cache_owner: HashMap<String, usize> = HashMap::new();
1749        if cache_max_bytes <= ByteSize::ZERO {
1750            return (cache, cache_owner);
1751        }
1752        for store_name in &self.caching_stores {
1753            // The store's single materializing processor → its graph node index.
1754            let owner_idx = self
1755                .store_processors
1756                .get(store_name)
1757                .and_then(|procs| procs.first())
1758                .and_then(|proc_name| name_to_idx.get(proc_name.as_str()).copied());
1759            let Some(owner_idx) = owner_idx else {
1760                // No connected processor in this subtopology (e.g. a store owned
1761                // by another partition's graph) — nothing to root here.
1762                continue;
1763            };
1764            let nc = cache.register(store_name);
1765            // KV, window, and session stores are all cache-aware: each overrides
1766            // `enable_cache_erased` to return `true` and drives its own
1767            // `flush_cache_into`. A store that declines caching (returns `false`)
1768            // is skipped so we don't root a `cache_owner` entry the flush
1769            // mechanism couldn't drive.
1770            if store_registry.enable_cache(store_name, nc) {
1771                cache_owner.insert(store_name.clone(), owner_idx);
1772            }
1773        }
1774        (cache, cache_owner)
1775    }
1776}
1777
1778// ──────────────────────────────────────────────────────────────────────────────
1779// Tests
1780// ──────────────────────────────────────────────────────────────────────────────
1781
1782#[cfg(test)]
1783mod tests {
1784    use assert2::check;
1785    use async_trait::async_trait;
1786
1787    use super::*;
1788    use crate::processor::{
1789        api::{Processor, ProcessorContext},
1790        record::Record,
1791        serde::StringSerde,
1792    };
1793
1794    struct Upper;
1795    #[async_trait]
1796    impl Processor<String, String, String, String> for Upper {
1797        async fn process(
1798            &mut self,
1799            ctx: &mut ProcessorContext<'_, '_, String, String>,
1800            r: Record<String, String>,
1801        ) {
1802            ctx.forward(Record::new(r.key, r.value.to_uppercase(), r.timestamp));
1803        }
1804    }
1805
1806    #[test]
1807    fn copartition_group_emitted_in_wire() {
1808        let mut t = Topology::new();
1809        let a: NodeHandle<bytes::Bytes, bytes::Bytes> = t.add_source("sa", ["left"]);
1810        let b: NodeHandle<bytes::Bytes, bytes::Bytes> = t.add_source("sb", ["right"]);
1811        t.add_sink("snk", "out", [&a, &b]); // both → one subtopology
1812        t.add_copartition_group(["left", "right"]);
1813        let wire = t.build("app").unwrap().to_wire();
1814        let sub = &wire.subtopologies[0];
1815        check!(sub.copartition_groups.len() == 1);
1816        check!(sub.copartition_groups[0].source_topics == vec![0i16, 1i16]); // sorted ["left","right"]
1817    }
1818
1819    #[test]
1820    fn global_store_is_invisible_and_bumps_stream_index() {
1821        // A GlobalKTable declared FIRST takes node-group index 0 but is invisible
1822        // in the wire (no subtopology, no changelog). The normal stream emits as
1823        // "1". The global topic appears in NO source_topics. The global factory is
1824        // recorded in the SEPARATE map (not built by `instantiate`).
1825        let mut t = Topology::new();
1826        // Global store declared first → index 0.
1827        t.add_global_store::<String, String, _, _>(
1828            "global-store",
1829            "gsrc",
1830            "global",
1831            "gproc",
1832            Consumed::with(StringSerde, StringSerde),
1833        );
1834        // Normal stream second → index 1.
1835        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
1836        t.add_sink("snk", "out", [&src]);
1837        check!(t.has_global_store_for_test("global-store"));
1838        let built = t.build("app").unwrap();
1839        let wire = built.to_wire();
1840        check!(wire.subtopologies.len() == 1);
1841        check!(wire.subtopologies[0].subtopology_id == "1");
1842        check!(wire.subtopologies[0].source_topics == vec!["in".to_string()]);
1843        // No changelog topic anywhere.
1844        check!(
1845            wire.subtopologies
1846                .iter()
1847                .all(|s| s.state_changelog_topics.is_empty())
1848        );
1849        // The global topic is not a wire source topic.
1850        check!(
1851            !wire.subtopologies[0]
1852                .source_topics
1853                .contains(&"global".to_string())
1854        );
1855    }
1856
1857    #[test]
1858    fn add_source_and_sink_can_use_default_serdes() {
1859        let mut t = Topology::new();
1860        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
1861        t.add_sink("out", "out-topic", [&src]);
1862        let built = t.build("app").unwrap();
1863        let wire = built.to_wire();
1864
1865        check!(wire.subtopologies[0].source_topics == vec!["in".to_string()]);
1866        check!(built.source_topics_for("0") == ["in".to_string()]);
1867    }
1868
1869    #[test]
1870    fn build_single_source_sink_wire_unchanged() {
1871        let mut t = Topology::new();
1872        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
1873        let up = t.add_processor("up", || Upper, [&src]);
1874        t.add_sink("out", "out-topic", [&up]);
1875        let built = t.build("app").unwrap();
1876        let wire = built.to_wire();
1877        check!(wire.epoch == 0);
1878        check!(wire.subtopologies[0].subtopology_id == "0");
1879        check!(wire.subtopologies[0].source_topics == vec!["in".to_string()]);
1880        check!(built.source_topics_for("0") == ["in".to_string()]);
1881    }
1882
1883    #[test]
1884    fn topology_error_converts_to_stored_error() {
1885        // `record()` stashes registry errors as the cloneable `StoredError`;
1886        // exercise every arm of the conversion so it stays total (the registry
1887        // only emits `DuplicateNode` at runtime, but the type covers all three).
1888        check!(matches!(
1889            StoredError::from(TopologyError::Empty),
1890            StoredError::Empty
1891        ));
1892        check!(matches!(
1893            StoredError::from(TopologyError::DuplicateNode("n".into())),
1894            StoredError::DuplicateNode(_)
1895        ));
1896        check!(matches!(
1897            StoredError::from(TopologyError::UnknownPredecessor {
1898                node: "a".into(),
1899                predecessor: "b".into(),
1900            }),
1901            StoredError::UnknownPredecessor { .. }
1902        ));
1903    }
1904
1905    #[test]
1906    fn node_handle_clone_debug_and_owned_wiring() {
1907        // NodeHandle is Clone + Debug, and parents may be passed by value (an
1908        // owned handle), not only by reference — exercise all three.
1909        let mut t = Topology::new();
1910        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
1911        let src2 = src.clone();
1912        check!(src2.name() == "src");
1913        check!(format!("{src2:?}").contains("src"));
1914        // `[src2]` wires by value (the owned-handle `Borrow` path).
1915        t.add_sink("out", "out", [src2]);
1916        check!(t.build("app").is_ok());
1917    }
1918
1919    #[test]
1920    fn handle_from_another_topology_is_rejected_at_build() {
1921        // A handle's node name is only registered in the topology that created
1922        // it; wiring it into a different topology leaves a dangling predecessor
1923        // that `build()` rejects. (Within one topology, forward references and
1924        // cycles can't even be written — you need a parent's handle first.)
1925        let mut a = Topology::new();
1926        let foreign: NodeHandle<String, String> = a.add_source("src", ["in"]);
1927
1928        let mut b = Topology::new();
1929        b.add_sink("out", "o", [&foreign]);
1930        check!(b.build("app").is_err());
1931    }
1932
1933    #[test]
1934    fn instantiate_runs_records() {
1935        let mut t = Topology::new();
1936        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
1937        let up = t.add_processor("up", || Upper, [&src]);
1938        t.add_sink("out", "out-topic", [&up]);
1939        let built = t.build("app").unwrap();
1940        let mut g = pollster::block_on(built.instantiate(
1941            &crate::store::backend::StoreBackend::InMemory,
1942            "app",
1943            ByteSize::ZERO,
1944        ))
1945        .unwrap();
1946        pollster::block_on(g.pipe("in", 0, 0, Some(b"k"), b"hi", 0)).unwrap();
1947        let out = g.take_output();
1948        check!(out.len() == 1);
1949        check!(out[0].value.as_ref().unwrap().as_ref() == b"HI");
1950    }
1951
1952    #[test]
1953    fn empty_topology_is_rejected() {
1954        let topo = Topology::new();
1955        check!(topo.build("app").is_err());
1956    }
1957
1958    #[test]
1959    fn build_with_processor_store_and_repartition() {
1960        let mut t = Topology::new();
1961        t.add_repartition_topic("rp");
1962        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
1963        let proc = t.add_processor("proc", || Upper, [&src]);
1964        t.add_state_store("store", StringSerde, StringSerde, [proc.name()]);
1965        t.add_sink("rsink", "rp", [&proc]);
1966        let rsrc: NodeHandle<String, String> = t.add_source("rsrc", ["rp"]);
1967        t.add_sink("out", "out-topic", [&rsrc]);
1968        let built = t.build("my-app").unwrap();
1969        check!(built.application_id() == "my-app");
1970        let wire = built.to_wire();
1971        // repartition chain produces at least 2 subtopologies
1972        check!(wire.subtopologies.len() >= 2);
1973        // subtopology containing the state store has a changelog topic named my-app-store-changelog
1974        let has_changelog = wire.subtopologies.iter().any(|s| {
1975            s.state_changelog_topics
1976                .iter()
1977                .any(|c| c.name == "my-app-store-changelog")
1978        });
1979        check!(has_changelog);
1980    }
1981
1982    #[test]
1983    fn application_id_accessor() {
1984        let mut t = Topology::new();
1985        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
1986        t.add_sink("snk", "out", [&src]);
1987        let built = t.build("my-streams-app").unwrap();
1988        check!(built.application_id() == "my-streams-app");
1989    }
1990
1991    #[test]
1992    fn source_topics_for_unknown_id_returns_empty() {
1993        let mut t = Topology::new();
1994        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
1995        t.add_sink("snk", "out", [&src]);
1996        let built = t.build("app").unwrap();
1997        check!(built.source_topics_for("99").is_empty());
1998    }
1999
2000    #[test]
2001    fn connect_processor_store_adds_processor_to_store() {
2002        let mut t = Topology::new();
2003        // register a store connected to processor "p1"
2004        t.add_state_store::<String, String, _, _>("s", StringSerde, StringSerde, ["p1"]);
2005        t.connect_processor_store("p2", "s");
2006        // the store's connected-processor list now has both p1 and p2
2007        let entry = t.store_entry_for_test("s").unwrap();
2008        check!(entry == vec!["p1".to_string(), "p2".to_string()]);
2009    }
2010
2011    #[test]
2012    fn connect_processor_store_is_idempotent() {
2013        let mut t = Topology::new();
2014        t.add_state_store::<String, String, _, _>("s", StringSerde, StringSerde, ["p1"]);
2015        t.connect_processor_store("p1", "s"); // p1 already connected — must not duplicate
2016        let entry = t.store_entry_for_test("s").unwrap();
2017        check!(entry == vec!["p1".to_string()]);
2018    }
2019
2020    #[test]
2021    fn connect_processor_store_unknown_store_is_noop() {
2022        let mut t = Topology::new();
2023        t.add_state_store::<String, String, _, _>("s", StringSerde, StringSerde, ["p1"]);
2024        t.connect_processor_store("p2", "no_such_store"); // should not panic
2025        let entry = t.store_entry_for_test("s").unwrap();
2026        check!(entry == vec!["p1".to_string()]); // unchanged
2027    }
2028
2029    #[test]
2030    fn duplicate_node_name_propagates_error() {
2031        let mut t = Topology::new();
2032        let _: NodeHandle<String, String> = t.add_source("src", ["in"]);
2033        let _: NodeHandle<String, String> = t.add_source("src", ["other"]); // duplicate
2034        check!(t.build("app").is_err());
2035    }
2036
2037    #[test]
2038    fn instantiate_repartition_topology_lists_topics() {
2039        let mut t = Topology::new();
2040        t.add_repartition_topic("rp");
2041        let s1: NodeHandle<String, String> = t.add_source("s1", ["in"]);
2042        let p = t.add_processor("p", || Upper, [&s1]);
2043        t.add_sink("to_rp", "rp", [&p]);
2044        let s2: NodeHandle<String, String> = t.add_source("s2", ["rp"]);
2045        t.add_sink("out", "out", [&s2]);
2046        let built = t.build("app").unwrap();
2047        let mut srcs = built.list_source_topics();
2048        srcs.sort();
2049        check!(srcs == vec!["in".to_string(), "rp".to_string()]);
2050        let mut sinks = built.list_sink_topics();
2051        sinks.sort();
2052        check!(sinks == vec!["out".to_string(), "rp".to_string()]);
2053        // instantiate must succeed and pipe through the first subtopology
2054        let mut g = pollster::block_on(built.instantiate(
2055            &crate::store::backend::StoreBackend::InMemory,
2056            "app",
2057            ByteSize::ZERO,
2058        ))
2059        .unwrap();
2060        pollster::block_on(g.pipe("in", 0, 0, None, b"hi", 0)).unwrap();
2061        let out1 = g.take_output();
2062        check!(out1.iter().any(|o| o.topic == "rp"));
2063    }
2064
2065    #[test]
2066    fn instantiate_builds_stores_and_processes_statefully() {
2067        use crate::processor::serde::I64Serde;
2068        struct Counter;
2069        #[async_trait]
2070        impl Processor<String, String, String, i64> for Counter {
2071            async fn process(
2072                &mut self,
2073                ctx: &mut ProcessorContext<'_, '_, String, i64>,
2074                r: Record<String, String>,
2075            ) {
2076                let n = {
2077                    let s = ctx.get_state_store::<String, i64>("counts").unwrap();
2078                    let n = s.get(&r.value).await.unwrap_or(0) + 1;
2079                    s.put(r.value.clone(), n).await;
2080                    n
2081                };
2082                ctx.forward(Record::new(Some(r.value), n, r.timestamp));
2083            }
2084        }
2085        let mut t = Topology::new();
2086        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
2087        let c = t.add_processor("c", || Counter, [&src]);
2088        t.add_state_store("counts", StringSerde, I64Serde, [c.name()]);
2089        t.add_sink("out", "out", [&c]);
2090        let built = t.build("app").unwrap();
2091        // wire topology still has the changelog topic (golden frame contract)
2092        check!(built.to_wire().subtopologies.iter().any(|s| {
2093            s.state_changelog_topics
2094                .iter()
2095                .any(|c| c.name == "app-counts-changelog")
2096        }));
2097        let mut g = pollster::block_on(built.instantiate(
2098            &crate::store::backend::StoreBackend::InMemory,
2099            "app",
2100            ByteSize::ZERO,
2101        ))
2102        .unwrap();
2103        pollster::block_on(g.pipe("in", 0, 0, None, b"x", 0)).unwrap();
2104        pollster::block_on(g.pipe("in", 0, 1, None, b"x", 1)).unwrap();
2105        // After two "x" records the count should be 2 (i64 big-endian = [0,0,0,0,0,0,0,2])
2106        check!(
2107            g.take_output()
2108                .last()
2109                .unwrap()
2110                .value
2111                .as_ref()
2112                .unwrap()
2113                .as_ref()
2114                == [0, 0, 0, 0, 0, 0, 0, 2]
2115        );
2116    }
2117
2118    // ── Build-time record-cache wiring (sub-task 3b-ii) ───────────────────────
2119    //
2120    // A materializing processor that, per record, accumulates a count in its
2121    // store and forwards `Change<i64>` (so the store's owning node can feed a
2122    // `Change`-consuming sink). Mirrors the DSL aggregate's store-write + emit.
2123    struct CountingMaterializer;
2124    #[async_trait]
2125    impl Processor<String, String, String, crate::dsl::processors::change::Change<i64>>
2126        for CountingMaterializer
2127    {
2128        async fn process(
2129            &mut self,
2130            ctx: &mut ProcessorContext<'_, '_, String, crate::dsl::processors::change::Change<i64>>,
2131            r: Record<String, String>,
2132        ) {
2133            use crate::dsl::processors::change::Change;
2134            let (old, new) = {
2135                let s = ctx.get_state_store::<String, i64>("counts").unwrap();
2136                let old = s.get(&r.value).await;
2137                let new = old.unwrap_or(0) + 1;
2138                s.put(r.value.clone(), new).await;
2139                (old, Some(new))
2140            };
2141            ctx.forward(Record::new(Some(r.value), Change { old, new }, r.timestamp));
2142        }
2143    }
2144
2145    /// Build a `source → CountingMaterializer(materializes "counts") → sink`
2146    /// topology, optionally marking the "counts" store cache-eligible.
2147    fn counting_topology(caching: bool) -> BuiltTopology {
2148        use crate::{dsl::processors::change::Change, processor::serde::I64Serde};
2149        let mut t = Topology::new();
2150        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
2151        let c = t.add_processor("c", || CountingMaterializer, [&src]);
2152        t.add_state_store("counts", StringSerde, I64Serde, [c.name()]);
2153        // The materializing node's `Change<i64>` output feeds the sink (a real
2154        // cached flush forwards `Change<i64>` rooted at "c").
2155        t.add_sink_explicit::<String, Change<i64>, _, _, _, _>(
2156            "out",
2157            "out",
2158            [&c],
2159            crate::processor::serde::Produced::with(StringSerde, ChangeI64Serde),
2160        );
2161        if caching {
2162            t.mark_store_caching("counts", true);
2163        }
2164        t.build("app").unwrap()
2165    }
2166
2167    // A trivial serde so the `Change<i64>` sink can be wired; it never has to
2168    // round-trip in these tests (we assert on the changelog, not the sink bytes).
2169    #[derive(Clone)]
2170    struct ChangeI64Serde;
2171    impl crate::processor::serde::Serde<crate::dsl::processors::change::Change<i64>>
2172        for ChangeI64Serde
2173    {
2174        fn serialize(
2175            &self,
2176            _topic: &str,
2177            v: &crate::dsl::processors::change::Change<i64>,
2178        ) -> bytes::Bytes {
2179            // Encode just the `new` side (8 bytes BE) so the sink has bytes to emit.
2180            bytes::Bytes::copy_from_slice(&v.new.unwrap_or(0).to_be_bytes())
2181        }
2182        fn deserialize(
2183            &self,
2184            _topic: &str,
2185            _bytes: &[u8],
2186        ) -> Result<crate::dsl::processors::change::Change<i64>, crate::processor::serde::SerdeError>
2187        {
2188            unreachable!("Change<i64> sink is never deserialized in these tests")
2189        }
2190    }
2191
2192    #[test]
2193    fn instantiate_caches_materialized_store_when_budget_positive() {
2194        // cache_max_bytes > 0 + marked store → the store is cached and
2195        // cache_owner roots it at the materializing node ("c"), which is graph
2196        // node index 0 (sources are not in the nodes vec; "c" is first non-source).
2197        let built = counting_topology(true);
2198        let mut g = pollster::block_on(built.instantiate(
2199            &crate::store::backend::StoreBackend::InMemory,
2200            "app",
2201            kibibytes(1),
2202        ))
2203        .unwrap();
2204        check!(g.cache_owner.get("counts") == Some(&0));
2205        check!(g.stores.kv_is_cached("counts"));
2206
2207        // Pipe two records for the SAME key, then flush: a cached store dedups the
2208        // two staged writes into ONE changelog entry. (Without caching the store
2209        // logs each put immediately → two entries; see the no-cache test below.)
2210        pollster::block_on(g.pipe("in", 0, 0, None, b"x", 0)).unwrap();
2211        pollster::block_on(g.pipe("in", 0, 1, None, b"x", 1)).unwrap();
2212        // No changelog buffered yet — cached writes defer logging to flush.
2213        check!(
2214            g.drain_changelogs(&std::collections::HashSet::new())
2215                .is_empty()
2216        );
2217        pollster::block_on(g.flush_caches()).unwrap();
2218        let cl = g.drain_changelogs(&std::collections::HashSet::new());
2219        check!(cl.len() == 1); // deduped to the latest count (2)
2220        // tuple is (changelog_topic, key, value, ts); value is the BE i64 count.
2221        check!(cl[0].2.as_ref().unwrap().as_ref() == [0, 0, 0, 0, 0, 0, 0, 2]);
2222    }
2223
2224    #[test]
2225    fn instantiate_does_not_cache_when_budget_zero() {
2226        // cache_max_bytes == 0 (TopologyTestDriver default): nothing is wrapped,
2227        // cache_owner is empty, and the store logs each put immediately (2 entries
2228        // for two same-key writes — no dedup).
2229        let built = counting_topology(true);
2230        let mut g = pollster::block_on(built.instantiate(
2231            &crate::store::backend::StoreBackend::InMemory,
2232            "app",
2233            ByteSize::ZERO,
2234        ))
2235        .unwrap();
2236        check!(g.cache_owner.is_empty());
2237        check!(!g.stores.kv_is_cached("counts"));
2238
2239        pollster::block_on(g.pipe("in", 0, 0, None, b"x", 0)).unwrap();
2240        pollster::block_on(g.pipe("in", 0, 1, None, b"x", 1)).unwrap();
2241        // Uncached: each put logs immediately → two changelog entries (no dedup).
2242        let cl = g.drain_changelogs(&std::collections::HashSet::new());
2243        check!(cl.len() == 2);
2244    }
2245
2246    #[test]
2247    fn instantiate_does_not_cache_when_marking_opted_out() {
2248        // with_caching(false) equivalent (store NOT marked) + budget > 0: the store
2249        // is NOT in cache_owner and stays uncached.
2250        let built = counting_topology(false);
2251        let g = pollster::block_on(built.instantiate(
2252            &crate::store::backend::StoreBackend::InMemory,
2253            "app",
2254            kibibytes(1),
2255        ))
2256        .unwrap();
2257        check!(g.cache_owner.is_empty());
2258        check!(!g.stores.kv_is_cached("counts"));
2259    }
2260
2261    #[test]
2262    fn add_versioned_store_registers_wire_spec() {
2263        use crate::processor::serde::{I64Serde, StringSerde};
2264        let mut t = Topology::new();
2265        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
2266        let proc = t.add_processor("proc", || Upper, [&src]);
2267        t.add_versioned_store::<String, i64, _, _>(
2268            "vstore",
2269            StringSerde,
2270            I64Serde,
2271            minutes(10),
2272            [proc.name()],
2273        );
2274        t.add_sink("out", "out-topic", [&proc]);
2275        let wire = t.build("app").unwrap().to_wire();
2276        let blob = serde_json::to_value(&wire).unwrap().to_string();
2277        check!(blob.contains("vstore"), "changelog topic name not in wire");
2278        check!(
2279            blob.contains("min.compaction.lag.ms"),
2280            "min.compaction.lag.ms key not in wire"
2281        );
2282        // history_retention = 10m -> min.compaction.lag.ms = 600_000 + 86_400_000 = 87_000_000
2283        check!(blob.contains("87000000"), "lag value not in wire");
2284    }
2285
2286    #[tokio::test]
2287    async fn add_window_store_uses_window_size_not_retention_for_cached_key_end() {
2288        // Regression: a CACHED sliding aggregate registers its window store with a
2289        // retention basis of `2 * timeDifferenceMs` but a TRUE window size of
2290        // `1 * timeDifferenceMs`. The flushed `Windowed` key end must be
2291        // `start + windowSize`, NOT `start + retentionBasis`. Before the fix the
2292        // factory fed the retention basis to `WindowBytesStore::new`,
2293        // so the end was doubled (this asserts `end == 10`, which would be `20`).
2294        use std::sync::{Arc, Mutex};
2295
2296        use crate::{
2297            dsl::{
2298                processors::change::Change,
2299                windows::{Window, Windowed},
2300            },
2301            processor::{record::RecordContext, serde::I64Serde},
2302            store::{
2303                api::StateStore,
2304                byte::InMemoryBytes,
2305                cache::named::NamedCache,
2306                window::{WindowBytesStore, WindowStore},
2307            },
2308        };
2309
2310        const D: Time = millis(10);
2311        let mut t = Topology::new();
2312        // The retention basis is 2*D; the true window size (the key end) is D.
2313        t.add_window_store::<String, i64, _, _>(
2314            "sw",
2315            StringSerde,
2316            I64Serde,
2317            (D * 2.0, D, Time::ZERO),
2318            ["p"],
2319        );
2320
2321        // Pull the registered factory and instantiate the store over a fresh backend.
2322        let (_changelog, factory) = t.store_factories.get("sw").expect("factory registered");
2323        let backend: Box<dyn crate::store::byte::ByteKeyValueStore> =
2324            Box::new(InMemoryBytes::default());
2325        let mut store: Box<dyn crate::store::api::StateStore> =
2326            factory("sw", "sw-changelog".to_string(), backend);
2327
2328        // Enable the record cache and stage a put for a window starting at 0.
2329        let typed = store
2330            .as_any_mut()
2331            .downcast_mut::<WindowBytesStore<String, i64>>()
2332            .expect("window store downcast");
2333        typed.enable_cache(Arc::new(Mutex::new(NamedCache::new("sw".into()))));
2334        typed.set_record_context(RecordContext {
2335            topic: "t".into(),
2336            partition: 0,
2337            offset: 0,
2338            timestamp: 7,
2339        });
2340        typed.put("a".into(), 0, 1, 7).await;
2341
2342        let mut buffer = std::collections::VecDeque::new();
2343        store.flush_cache_into(&mut buffer, &[0]).await;
2344        assert_eq!(buffer.len(), 1);
2345        let (_child, rec) = &buffer[0];
2346        let key = rec
2347            .key
2348            .as_ref()
2349            .unwrap()
2350            .downcast_ref::<Windowed<String>>()
2351            .unwrap();
2352        assert_eq!(key.key, "a");
2353        // end == start + window_size (D), NOT start + retention basis (2*D).
2354        check!(
2355            key.window
2356                == Window {
2357                    start: 0,
2358                    end: D.millis_i64()
2359                }
2360        );
2361        let change = rec.value.downcast_ref::<Change<i64>>().unwrap();
2362        assert_eq!(change.new, Some(1));
2363    }
2364}