1use std::{cell::RefCell, rc::Rc};
4
5use crate::{
6 dsl::graph::{GraphNodeKind, LogicalGraph},
7 processor::serde::{Consumed, DefaultSerde, Serde, SerdeAssociate},
8};
9
10pub(crate) type StoreConnectThunk =
15 std::sync::Arc<dyn Fn(&mut crate::dsl::graph::LowerState, &str) + Send + Sync>;
16
17pub(crate) struct InternalStreamsBuilder {
18 pub graph: LogicalGraph,
19 index: usize,
20 pub store_thunks: std::collections::HashMap<String, StoreConnectThunk>,
24}
25
26impl InternalStreamsBuilder {
27 pub fn new() -> Self {
28 Self {
29 graph: LogicalGraph::default(),
30 index: 0,
31 store_thunks: std::collections::HashMap::new(),
32 }
33 }
34
35 pub fn new_processor_name(&mut self, prefix: &str) -> String {
38 let n = format!("{prefix}{:010}", self.index);
39 self.index += 1;
40 n
41 }
42
43 pub fn store_thunk(&self, name: &str) -> Option<StoreConnectThunk> {
46 self.store_thunks.get(name).cloned()
47 }
48}
49
50pub struct StreamsBuilder {
53 pub(crate) internal: Rc<RefCell<InternalStreamsBuilder>>,
54}
55
56impl StreamsBuilder {
57 #[must_use]
58 pub fn new() -> Self {
59 Self {
60 internal: Rc::new(RefCell::new(InternalStreamsBuilder::new())),
61 }
62 }
63
64 pub fn stream_explicit<KS, VS>(
72 &self,
73 topics: impl IntoIterator<Item = impl Into<String>>,
74 consumed: impl Into<Consumed<KS, VS>>,
75 ) -> crate::dsl::kstream::KStream<KS::Target, VS::Target, KS, VS>
76 where
77 KS: SerdeAssociate + Serde<KS::Target> + Clone,
78 VS: SerdeAssociate + Serde<VS::Target> + Clone,
79 KS::Target: std::any::Any + Send + Clone,
80 VS::Target: std::any::Any + Send + Clone,
81 {
82 let consumed = consumed.into();
83 let topics: Vec<String> = topics.into_iter().map(Into::into).collect();
84 let single_source_topic = match topics.as_slice() {
88 [only] => Some(only.clone()),
89 _ => None,
90 };
91 let mut g = self.internal.borrow_mut();
92 let name = g.new_processor_name(crate::dsl::names::SOURCE);
93 let id = g.graph.add(
94 name.clone(),
95 GraphNodeKind::StreamSource {
96 topics: topics.clone(),
97 },
98 vec![],
99 );
100 let consumed_for_lower = consumed.clone();
104 g.graph.nodes[id].lower = Some(Box::new(
105 move |state: &mut crate::dsl::graph::LowerState| {
106 let h = state
107 .topology
108 .add_source_explicit::<KS::Target, VS::Target, KS, VS>(
109 name,
110 topics,
111 consumed_for_lower,
112 );
113 state.handle_name.insert(id, h.name().to_string());
114 },
115 ));
116 drop(g);
117 crate::dsl::kstream::KStream::new(
118 Rc::clone(&self.internal),
119 id,
120 consumed.key_serde.clone(),
121 consumed.value_serde.clone(),
122 )
123 .with_source_topic(single_source_topic)
124 }
125
126 pub fn table_explicit<KS, VS>(
142 &self,
143 topic: impl Into<String>,
144 consumed: impl Into<Consumed<KS, VS>>,
145 materialized: impl Into<crate::dsl::config::Materialized<KS, VS>>,
146 ) -> crate::dsl::ktable::KTable<KS::Target, VS::Target, KS, VS>
147 where
148 KS: SerdeAssociate + Serde<KS::Target> + Clone + 'static,
149 VS: SerdeAssociate + Serde<VS::Target> + Clone + 'static,
150 KS::Target: std::any::Any + Send + Sync + Clone,
151 VS::Target: std::any::Any + Send + Clone,
152 {
153 let consumed = consumed.into();
154 let materialized = materialized.into();
155 let topic: String = topic.into();
156 let topic_for_ktable = topic.clone();
158 let suppress_factory =
161 crate::dsl::ktable::kv_suppress_factory::<KS::Target, VS::Target, KS, VS>(
162 materialized.key_serde.clone(),
163 materialized.value_serde.clone(),
164 );
165 let mut g = self.internal.borrow_mut();
166 let store_name = match &materialized.store_name {
168 Some(name) => name.clone(),
169 None => g.new_processor_name(crate::dsl::names::TABLE_SOURCE),
170 };
171 let source_name = g.new_processor_name(crate::dsl::names::SOURCE);
172 let proc_name = g.new_processor_name(crate::dsl::names::TABLE_SOURCE);
173 let id = g.graph.add(
174 source_name.clone(),
175 GraphNodeKind::TableSource {
176 topic: topic.clone(),
177 store_name: store_name.clone(),
178 reuse_source_for_changelog: false,
179 },
180 vec![],
181 );
182 let versioned_cfg = materialized.versioned;
184 let versioned_retention = versioned_cfg.map(|v| v.history_retention);
187 let crate::dsl::config::Materialized {
188 key_serde,
189 value_serde,
190 ..
191 } = materialized;
192 let store_for_thunk = store_name.clone();
193 let key_serde_for_lower = key_serde.clone();
194 let value_serde_for_lower = value_serde.clone();
195 g.graph.nodes[id].lower = Some(Box::new(
196 move |state: &mut crate::dsl::graph::LowerState| {
197 let src = state
198 .topology
199 .add_source_explicit::<KS::Target, VS::Target, KS, VS>(
200 source_name,
201 [topic],
202 consumed,
203 );
204 let store_for_proc = store_for_thunk.clone();
205 if let Some(vc) = versioned_cfg {
206 let h = state
208 .topology
209 .add_processor::<KS::Target, VS::Target, KS::Target, crate::dsl::processors::change::Change<VS::Target>, _, _, _>(
210 proc_name,
211 move || crate::dsl::processors::table::VersionedKTableSourceProcessor {
212 store_name: store_for_proc.clone(),
213 _pd: std::marker::PhantomData,
214 },
215 [&src],
216 );
217 match state.reuse_changelog.get(&id).cloned() {
221 Some(changelog_topic) => {
222 state
223 .topology
224 .add_versioned_store_with_changelog::<KS::Target, VS::Target, KS, VS>(
225 store_for_thunk.clone(),
226 key_serde_for_lower,
227 value_serde_for_lower,
228 vc.history_retention,
229 [h.name().to_string()],
230 changelog_topic,
231 );
232 }
233 None => {
234 state
235 .topology
236 .add_versioned_store::<KS::Target, VS::Target, KS, VS>(
237 store_for_thunk.clone(),
238 key_serde_for_lower,
239 value_serde_for_lower,
240 vc.history_retention,
241 [h.name().to_string()],
242 );
243 }
244 }
245 state.handle_name.insert(id, h.name().to_string());
246 } else {
247 let h = state
250 .topology
251 .add_processor::<KS::Target, VS::Target, KS::Target, crate::dsl::processors::change::Change<VS::Target>, _, _, _>(
252 proc_name,
253 move || crate::dsl::processors::table::KTableSourceProcessor {
254 store_name: store_for_proc.clone(),
255 forwarder:
256 crate::dsl::processors::tuple_forwarder::TupleForwarder::default(
257 ),
258 _pd: std::marker::PhantomData,
259 },
260 [&src],
261 );
262 match state.reuse_changelog.get(&id).cloned() {
266 Some(changelog_topic) => {
267 state
268 .topology
269 .add_state_store_with_changelog::<KS::Target, VS::Target, KS, VS>(
270 store_for_thunk.clone(),
271 key_serde_for_lower,
272 value_serde_for_lower,
273 [h.name().to_string()],
274 changelog_topic,
275 );
276 }
277 None => {
278 state
279 .topology
280 .add_state_store::<KS::Target, VS::Target, KS, VS>(
281 store_for_thunk.clone(),
282 key_serde_for_lower,
283 value_serde_for_lower,
284 [h.name().to_string()],
285 );
286 }
287 }
288 state.handle_name.insert(id, h.name().to_string());
290 }
291 },
292 ));
293 drop(g);
294 crate::dsl::ktable::KTable::new(
295 Rc::clone(&self.internal),
296 id,
297 Some(store_name),
298 Some(topic_for_ktable),
299 key_serde.clone(),
300 value_serde.clone(),
301 )
302 .with_suppress_factory(Some(suppress_factory))
303 .with_versioned_retention(versioned_retention)
304 }
305
306 pub fn global_table_explicit<KS, VS>(
329 &self,
330 topic: impl Into<String>,
331 consumed: impl Into<Consumed<KS, VS>>,
332 materialized: impl Into<crate::dsl::config::Materialized<KS, VS>>,
333 ) -> crate::dsl::global_table::GlobalKTable<KS::Target, VS::Target, KS, VS>
334 where
335 KS: SerdeAssociate + Serde<KS::Target> + Clone + 'static,
336 VS: SerdeAssociate + Serde<VS::Target> + Clone + 'static,
337 KS::Target: std::any::Any + Send + Sync + Clone,
338 VS::Target: std::any::Any + Send + Clone,
339 {
340 let consumed = consumed.into();
341 let materialized = materialized.into();
342 let topic: String = topic.into();
343 let topic_for_handle = topic.clone();
344 let mut g = self.internal.borrow_mut();
345 let store_name = match &materialized.store_name {
347 Some(name) => name.clone(),
348 None => g.new_processor_name(crate::dsl::names::TABLE_SOURCE),
349 };
350 let source_name = g.new_processor_name(crate::dsl::names::GLOBAL_SOURCE);
351 let proc_name = g.new_processor_name(crate::dsl::names::GLOBAL_PROCESSOR);
352 let id = g.graph.add(
353 source_name.clone(),
354 GraphNodeKind::GlobalSource {
355 topic: topic.clone(),
356 store_name: store_name.clone(),
357 source_name: source_name.clone(),
358 processor_name: proc_name.clone(),
359 },
360 vec![],
361 );
362 let key_serde = materialized.key_serde.clone();
368 let value_serde = materialized.value_serde.clone();
369 drop(materialized);
370 let store_for_handle = store_name.clone();
371 g.graph.nodes[id].lower = Some(Box::new(
372 move |state: &mut crate::dsl::graph::LowerState| {
373 state
374 .topology
375 .add_global_store::<KS::Target, VS::Target, KS, VS>(
376 store_name,
377 source_name,
378 topic,
379 proc_name,
380 consumed,
381 );
382 },
383 ));
384 drop(g);
385 crate::dsl::global_table::GlobalKTable::new(
386 Rc::clone(&self.internal),
387 id,
388 store_for_handle,
389 topic_for_handle,
390 key_serde,
391 value_serde,
392 )
393 }
394
395 pub fn stream<K, V>(
398 &self,
399 topics: impl IntoIterator<Item = impl Into<String>>,
400 ) -> crate::dsl::kstream::KStream<K, V>
401 where
402 K: DefaultSerde,
403 V: DefaultSerde,
404 K::Serde: SerdeAssociate<Target = K> + Clone,
405 V::Serde: SerdeAssociate<Target = V> + Clone,
406 K: std::any::Any + Send + Clone,
407 V: std::any::Any + Send + Clone,
408 {
409 self.stream_explicit(
410 topics,
411 Consumed::with(K::Serde::default(), V::Serde::default()),
412 )
413 }
414
415 pub fn table<K, V>(
419 &self,
420 topic: impl Into<String>,
421 store_name: impl Into<String>,
422 ) -> crate::dsl::ktable::KTable<K, V>
423 where
424 K: DefaultSerde,
425 V: DefaultSerde,
426 K::Serde: SerdeAssociate<Target = K> + Clone,
427 V::Serde: SerdeAssociate<Target = V> + Clone,
428 K: std::any::Any + Send + Sync + Clone,
429 V: std::any::Any + Send + Clone,
430 {
431 self.table_explicit(
432 topic,
433 Consumed::with(K::Serde::default(), V::Serde::default()),
434 crate::dsl::config::Materialized::with(K::Serde::default(), V::Serde::default())
435 .as_store(store_name),
436 )
437 }
438
439 pub fn global_table<K, V>(
444 &self,
445 topic: impl Into<String>,
446 store_name: impl Into<String>,
447 ) -> crate::dsl::global_table::GlobalKTable<K, V>
448 where
449 K: DefaultSerde,
450 V: DefaultSerde,
451 K::Serde: SerdeAssociate<Target = K> + Clone,
452 V::Serde: SerdeAssociate<Target = V> + Clone,
453 K: std::any::Any + Send + Sync + Clone,
454 V: std::any::Any + Send + Clone,
455 {
456 self.global_table_explicit(
457 topic,
458 Consumed::with(K::Serde::default(), V::Serde::default()),
459 crate::dsl::config::Materialized::with(K::Serde::default(), V::Serde::default())
460 .as_store(store_name),
461 )
462 }
463
464 pub fn add_state_store<KS, VS>(
479 &self,
480 name: impl Into<String>,
481 key_serde: KS,
482 value_serde: VS,
483 ) -> &Self
484 where
485 KS: SerdeAssociate + Serde<KS::Target> + Clone + 'static,
486 VS: SerdeAssociate + Serde<VS::Target> + Clone + 'static,
487 KS::Target: std::any::Any + Send + Sync + Clone,
488 VS::Target: std::any::Any + Send + Clone,
489 {
490 let name: String = name.into();
491 let key = name.clone();
493 let thunk: StoreConnectThunk = std::sync::Arc::new(
494 move |state: &mut crate::dsl::graph::LowerState, processor: &str| {
495 state
496 .topology
497 .add_state_store::<KS::Target, VS::Target, KS, VS>(
498 name.clone(),
499 key_serde.clone(),
500 value_serde.clone(),
501 [processor.to_string()],
502 );
503 },
504 );
505 self.internal.borrow_mut().store_thunks.insert(key, thunk);
506 self
507 }
508
509 #[tracing::instrument(
526 name = "streams.dsl.build",
527 level = "info",
528 skip_all,
529 fields(app_id = %app_id),
530 err,
531 )]
532 pub fn build(
535 self,
536 app_id: &str,
537 ) -> Result<crate::topology::BuiltTopology, crate::topology::TopologyError> {
538 let graph = self.into_graph("build");
539 let topology = crate::dsl::lower::lower(graph, app_id);
540 topology.build(app_id)
541 }
542
543 #[tracing::instrument(
557 name = "streams.dsl.build_optimized",
558 level = "info",
559 skip_all,
560 fields(app_id = %app_id),
561 err,
562 )]
563 pub fn build_optimized(
566 self,
567 app_id: &str,
568 ) -> Result<crate::topology::BuiltTopology, crate::topology::TopologyError> {
569 let mut graph = self.into_graph("build_optimized");
570 crate::dsl::optimizer::merge_repartition_topics(&mut graph);
571 crate::dsl::optimizer::reuse_ktable_source_topics(&mut graph);
572 let topology = crate::dsl::lower::lower(graph, app_id);
573 topology.build(app_id)
574 }
575
576 fn into_graph(self, who: &str) -> LogicalGraph {
586 Rc::try_unwrap(self.internal)
587 .unwrap_or_else(|_| panic!("StreamsBuilder::{who}: outstanding KStream/KTable handles"))
588 .into_inner()
589 .graph
590 }
591}
592
593impl Default for StreamsBuilder {
594 fn default() -> Self {
595 Self::new()
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use assert2::check;
602
603 use super::*;
604
605 #[test]
606 fn counter_assigns_jvm_names_in_call_order() {
607 let mut b = InternalStreamsBuilder::new();
608 check!(b.new_processor_name(crate::dsl::names::SOURCE) == "KSTREAM-SOURCE-0000000000");
609 check!(
610 b.new_processor_name(crate::dsl::names::MAPVALUES) == "KSTREAM-MAPVALUES-0000000001"
611 );
612 check!(b.new_processor_name(crate::dsl::names::FILTER) == "KSTREAM-FILTER-0000000002");
613 }
614
615 #[test]
616 fn stream_records_a_source_node() {
617 let builder = StreamsBuilder::new();
618 let _s = builder.stream::<String, String>(["in"]);
619 let g = builder.internal.borrow();
620 check!(g.graph.nodes.len() == 1);
621 check!(matches!(
622 g.graph.nodes[0].kind,
623 GraphNodeKind::StreamSource { .. }
624 ));
625 check!(g.graph.nodes[0].name == "KSTREAM-SOURCE-0000000000");
626 }
627
628 #[test]
629 fn build_lowers_source_sink_to_wire_topology() {
630 let b = StreamsBuilder::new();
631 b.stream::<String, String>(["in"]).to("out");
632 let built = b.build("app").unwrap();
633 let wire = built.to_wire();
634 check!(wire.epoch == 0);
635 check!(wire.subtopologies.len() == 1);
636 check!(wire.subtopologies[0].source_topics == vec!["in".to_string()]);
637 check!(built.list_sink_topics() == vec!["out".to_string()]);
639 }
640
641 #[test]
642 fn build_optimized_matches_build_for_stateless_chain() {
643 let b = StreamsBuilder::new();
644 b.stream::<String, String>(["in"])
645 .map_values(|v: &String| v.clone())
646 .to("out");
647 let wire = b.build_optimized("app").unwrap().to_wire();
648 check!(wire.subtopologies.len() == 1);
649 check!(wire.subtopologies[0].source_topics == vec!["in".to_string()]);
650 }
651
652 #[test]
653 fn table_build_keeps_derived_changelog_name() {
654 let b = StreamsBuilder::new();
658 b.table::<String, String>("in", "store")
659 .to_stream()
660 .to("out");
661 let wire = b.build("app").unwrap().to_wire();
662 let cl: Vec<&str> = wire.subtopologies[0]
663 .state_changelog_topics
664 .iter()
665 .map(|t| t.name.as_str())
666 .collect();
667 check!(cl == vec!["app-store-changelog"]);
668 }
669
670 #[test]
671 fn table_optimized_build_reuses_source_topic_as_changelog() {
672 let b = StreamsBuilder::new();
675 b.table::<String, String>("in", "store")
676 .to_stream()
677 .to("out");
678 let wire = b.build_optimized("app").unwrap().to_wire();
679 let cl: Vec<&str> = wire.subtopologies[0]
680 .state_changelog_topics
681 .iter()
682 .map(|t| t.name.as_str())
683 .collect();
684 check!(cl == vec!["in"]);
685 }
686
687 #[test]
688 fn global_table_returns_handle_with_store_name() {
689 let builder = StreamsBuilder::new();
690 let gt = builder.global_table::<String, String>("global", "g-store");
691 check!(gt.store_name() == "g-store");
692 check!(gt.source_topic == "global");
693 let g = builder.internal.borrow();
696 check!(matches!(
697 g.graph.nodes[0].kind,
698 GraphNodeKind::GlobalSource { .. }
699 ));
700 }
701
702 #[test]
703 fn global_table_before_stream_bumps_stream_subtopology_to_one() {
704 let b = StreamsBuilder::new();
705 let gt = b.global_table::<String, String>("global", "g-store");
708 drop(gt);
711 b.stream::<String, String>(["in"]).to("out");
712 let wire = b.build("app").unwrap().to_wire();
713 check!(wire.subtopologies.len() == 1);
714 check!(wire.subtopologies[0].subtopology_id == "1");
715 check!(wire.subtopologies[0].source_topics == vec!["in".to_string()]);
716 check!(
718 wire.subtopologies
719 .iter()
720 .all(|s| s.state_changelog_topics.is_empty())
721 );
722 }
723
724 #[test]
725 fn add_state_store_records_a_connect_thunk() {
726 use crate::processor::serde::{I64Serde, StringSerde};
727 let b = StreamsBuilder::new();
728 b.add_state_store("counts", StringSerde, I64Serde);
730 check!(b.internal.borrow().store_thunk("counts").is_some());
731 check!(b.internal.borrow().store_thunk("missing").is_none());
732 }
733
734 #[test]
735 #[should_panic(expected = "outstanding KStream/KTable handles")]
736 fn build_panics_with_outstanding_handle() {
737 let b = StreamsBuilder::new();
738 let _held = b.stream::<String, String>(["in"]);
741 let _ = b.build("app");
742 }
743}