1use crate::dlq::DlqConfig;
41use crate::error::FaucetError;
42use crate::join::HashJoin;
43use crate::observability::{Labels, RunStreamOptions, instrumented_apply_stages};
44use crate::pipeline::{DEFAULT_BATCH_SIZE, StreamPage, run_stream};
45use crate::replication::json_gt;
46use crate::stage::CompiledStage;
47use crate::state::StateStore;
48use crate::traits::{Sink, Source};
49use futures::StreamExt;
50use metrics::{Label, SharedString, counter, histogram};
51use serde_json::Value;
52use std::collections::{HashMap, HashSet};
53use std::future::Future;
54use std::pin::Pin;
55use std::sync::Arc;
56use tokio::sync::mpsc;
57use tokio_util::sync::CancellationToken;
58
59pub use crate::join::{JoinConfig, JoinMode, KeyNormalize, OnCollision, OnDuplicate, Projection};
60
61pub const DEFAULT_CHANNEL_CAPACITY: usize = 4;
63
64#[derive(Debug, Clone)]
68pub struct JoinNode {
69 pub config: JoinConfig,
71 pub build_edge: String,
73 pub probe_edge: String,
75}
76
77pub enum NodeKind {
79 Source(Box<dyn Source>),
81 Transform(Vec<CompiledStage>),
83 Tee {
85 capacity: usize,
87 fanout: Option<usize>,
89 },
90 Merge,
92 Join(JoinNode),
94 Sink(Box<dyn Sink>),
96}
97
98impl std::fmt::Debug for NodeKind {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.write_str(self.kind_str())
101 }
102}
103
104impl NodeKind {
105 pub fn kind_str(&self) -> &'static str {
107 match self {
108 NodeKind::Source(_) => "source",
109 NodeKind::Transform(_) => "transform",
110 NodeKind::Tee { .. } => "tee",
111 NodeKind::Merge => "merge",
112 NodeKind::Join(_) => "join",
113 NodeKind::Sink(_) => "sink",
114 }
115 }
116
117 fn is_source(&self) -> bool {
118 matches!(self, NodeKind::Source(_))
119 }
120
121 fn is_sink(&self) -> bool {
122 matches!(self, NodeKind::Sink(_))
123 }
124}
125
126#[derive(Debug)]
128pub struct Node {
129 pub id: String,
131 pub kind: NodeKind,
133}
134
135#[derive(Debug, Clone)]
137pub struct Edge {
138 pub from: String,
140 pub to: String,
142 pub label: Option<String>,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149pub enum TopologyOnError {
150 #[default]
152 Propagate,
153 Continue,
156}
157
158#[derive(Clone)]
160pub struct TopologyOptions {
161 pub pipeline_name: String,
163 pub run_id: String,
165 pub batch_size: usize,
167 pub state_store: Option<Arc<dyn StateStore>>,
169 pub dlq: Option<DlqConfig>,
171 pub cancel: Option<CancellationToken>,
173 pub on_error: TopologyOnError,
175 pub default_channel_capacity: usize,
177}
178
179impl Default for TopologyOptions {
180 fn default() -> Self {
181 Self {
182 pipeline_name: "unnamed".into(),
183 run_id: String::new(),
184 batch_size: DEFAULT_BATCH_SIZE,
185 state_store: None,
186 dlq: None,
187 cancel: None,
188 on_error: TopologyOnError::default(),
189 default_channel_capacity: DEFAULT_CHANNEL_CAPACITY,
190 }
191 }
192}
193
194impl TopologyOptions {
195 pub fn new(pipeline_name: impl Into<String>) -> Self {
197 Self {
198 pipeline_name: pipeline_name.into(),
199 ..Default::default()
200 }
201 }
202
203 pub fn with_state_store(mut self, store: Arc<dyn StateStore>) -> Self {
205 self.state_store = Some(store);
206 self
207 }
208
209 pub fn with_dlq(mut self, dlq: DlqConfig) -> Self {
211 self.dlq = Some(dlq);
212 self
213 }
214
215 pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
217 self.cancel = Some(cancel);
218 self
219 }
220
221 pub fn with_on_error(mut self, on_error: TopologyOnError) -> Self {
223 self.on_error = on_error;
224 self
225 }
226
227 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
229 self.batch_size = batch_size;
230 self
231 }
232}
233
234#[derive(Debug, Clone, Default)]
236pub struct TopologyResult {
237 pub records_written: usize,
239 pub per_sink: HashMap<String, usize>,
241 pub bookmarks: HashMap<String, Option<Value>>,
243 pub errors: Vec<String>,
246}
247
248struct InEdge {
251 label: Option<String>,
252 rx: mpsc::Receiver<StreamPage>,
253}
254
255fn take_single(mut ins: Vec<InEdge>) -> Option<mpsc::Receiver<StreamPage>> {
257 ins.drain(..).next().map(|ie| ie.rx)
258}
259
260fn take_by_label(ins: &mut Vec<InEdge>, label: &str) -> Option<mpsc::Receiver<StreamPage>> {
262 ins.iter()
263 .position(|ie| ie.label.as_deref() == Some(label))
264 .map(|pos| ins.remove(pos).rx)
265}
266
267enum NodeOutcome {
269 Sink {
270 node_id: String,
271 records: usize,
272 bookmark: Option<Value>,
273 },
274 Other,
275}
276
277#[derive(Debug)]
281pub struct Topology {
282 nodes: Vec<Node>,
283 edges: Vec<Edge>,
284}
285
286impl Topology {
287 pub fn builder() -> TopologyBuilder {
289 TopologyBuilder::default()
290 }
291
292 pub fn nodes(&self) -> &[Node] {
294 &self.nodes
295 }
296
297 pub fn edges(&self) -> &[Edge] {
299 &self.edges
300 }
301
302 pub fn validate(&self) -> Result<(), FaucetError> {
307 if self.nodes.is_empty() {
308 return Err(cfg("topology has no nodes"));
309 }
310
311 let mut seen = HashSet::new();
313 for n in &self.nodes {
314 if !seen.insert(n.id.as_str()) {
315 return Err(cfg(format!("duplicate node id '{}'", n.id)));
316 }
317 }
318 let ids: HashSet<&str> = seen;
319
320 for e in &self.edges {
322 if !ids.contains(e.from.as_str()) {
323 return Err(cfg(format!(
324 "edge references unknown 'from' node '{}'",
325 e.from
326 )));
327 }
328 if !ids.contains(e.to.as_str()) {
329 return Err(cfg(format!("edge references unknown 'to' node '{}'", e.to)));
330 }
331 }
332
333 let mut in_deg: HashMap<&str, usize> = HashMap::new();
335 let mut out_deg: HashMap<&str, usize> = HashMap::new();
336 for e in &self.edges {
337 *out_deg.entry(e.from.as_str()).or_default() += 1;
338 *in_deg.entry(e.to.as_str()).or_default() += 1;
339 }
340
341 let mut has_source = false;
342 let mut has_sink = false;
343 for n in &self.nodes {
344 let i = in_deg.get(n.id.as_str()).copied().unwrap_or(0);
345 let o = out_deg.get(n.id.as_str()).copied().unwrap_or(0);
346 match &n.kind {
347 NodeKind::Source(_) => {
348 has_source = true;
349 arity(&n.id, "source", i == 0, o == 1, "0 in, exactly 1 out")?;
350 }
351 NodeKind::Transform(_) => {
352 arity(&n.id, "transform", i == 1, o == 1, "exactly 1 in, 1 out")?;
353 }
354 NodeKind::Tee { fanout, .. } => {
355 arity(&n.id, "tee", i == 1, o >= 2, "exactly 1 in, 2+ out")?;
356 if let Some(f) = fanout
357 && *f != o
358 {
359 return Err(cfg(format!(
360 "tee '{}' declares fanout {f} but has {o} outgoing edges",
361 n.id
362 )));
363 }
364 }
365 NodeKind::Merge => {
366 arity(&n.id, "merge", i >= 2, o == 1, "2+ in, exactly 1 out")?;
367 }
368 NodeKind::Join(j) => {
369 arity(&n.id, "join", i == 2, o == 1, "exactly 2 in, 1 out")?;
370 self.validate_join_edges(&n.id, j)?;
371 }
372 NodeKind::Sink(_) => {
373 has_sink = true;
374 arity(&n.id, "sink", i == 1, o == 0, "exactly 1 in, 0 out")?;
375 }
376 }
377 }
378
379 if !has_source {
380 return Err(cfg("topology has no source node"));
381 }
382 if !has_sink {
383 return Err(cfg("topology has no sink node"));
384 }
385
386 self.detect_cycle()?;
387 self.check_reachability()?;
388 Ok(())
389 }
390
391 fn validate_join_edges(&self, node_id: &str, j: &JoinNode) -> Result<(), FaucetError> {
392 let labels: Vec<&str> = self
393 .edges
394 .iter()
395 .filter(|e| e.to == node_id)
396 .filter_map(|e| e.label.as_deref())
397 .collect();
398 for want in [j.build_edge.as_str(), j.probe_edge.as_str()] {
399 if !labels.contains(&want) {
400 return Err(cfg(format!(
401 "join '{node_id}' has no incoming edge labelled '{want}' (known labels: {labels:?})"
402 )));
403 }
404 }
405 if j.build_edge == j.probe_edge {
406 return Err(cfg(format!(
407 "join '{node_id}' build_edge and probe_edge must differ"
408 )));
409 }
410 Ok(())
411 }
412
413 fn detect_cycle(&self) -> Result<(), FaucetError> {
415 let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
416 for e in &self.edges {
417 adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
418 }
419 #[derive(Clone, Copy, PartialEq)]
420 enum Color {
421 White,
422 Gray,
423 Black,
424 }
425 let mut color: HashMap<&str, Color> = self
426 .nodes
427 .iter()
428 .map(|n| (n.id.as_str(), Color::White))
429 .collect();
430
431 for start in self.nodes.iter().map(|n| n.id.as_str()) {
433 if color[start] != Color::White {
434 continue;
435 }
436 let mut stack: Vec<(&str, usize)> = vec![(start, 0)];
437 *color.get_mut(start).unwrap() = Color::Gray;
438 while let Some((node, idx)) = stack.last().copied() {
439 let neighbours = adj.get(node).map(|v| v.as_slice()).unwrap_or(&[]);
440 if idx < neighbours.len() {
441 stack.last_mut().unwrap().1 += 1;
442 let next = neighbours[idx];
443 match color[next] {
444 Color::Gray => {
445 return Err(cfg(format!("topology has a cycle through node '{next}'")));
446 }
447 Color::White => {
448 *color.get_mut(next).unwrap() = Color::Gray;
449 stack.push((next, 0));
450 }
451 Color::Black => {}
452 }
453 } else {
454 *color.get_mut(node).unwrap() = Color::Black;
455 stack.pop();
456 }
457 }
458 }
459 Ok(())
460 }
461
462 fn check_reachability(&self) -> Result<(), FaucetError> {
465 let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
466 let mut radj: HashMap<&str, Vec<&str>> = HashMap::new();
467 for e in &self.edges {
468 adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
469 radj.entry(e.to.as_str()).or_default().push(e.from.as_str());
470 }
471 let sink_ids: HashSet<&str> = self
472 .nodes
473 .iter()
474 .filter(|n| n.kind.is_sink())
475 .map(|n| n.id.as_str())
476 .collect();
477 let source_ids: HashSet<&str> = self
478 .nodes
479 .iter()
480 .filter(|n| n.kind.is_source())
481 .map(|n| n.id.as_str())
482 .collect();
483
484 for src in &source_ids {
485 if !reaches_any(src, &adj, &sink_ids) {
486 return Err(cfg(format!("source '{src}' does not reach any sink node")));
487 }
488 }
489 for sink in &sink_ids {
490 if !reaches_any(sink, &radj, &source_ids) {
491 return Err(cfg(format!(
492 "sink '{sink}' is not reachable from any source node"
493 )));
494 }
495 }
496 Ok(())
497 }
498
499 pub async fn run(self, opts: TopologyOptions) -> Result<TopologyResult, FaucetError> {
501 self.validate()?;
502 let Topology { nodes, edges } = self;
503
504 let tee_cap: HashMap<&str, usize> = nodes
507 .iter()
508 .filter_map(|n| match &n.kind {
509 NodeKind::Tee { capacity, .. } => Some((n.id.as_str(), *capacity)),
510 _ => None,
511 })
512 .collect();
513
514 let sink_ids: Vec<String> = nodes
516 .iter()
517 .filter(|n| n.kind.is_sink())
518 .map(|n| n.id.clone())
519 .collect();
520 let start_bookmark = compute_start_bookmark(&opts, &sink_ids).await;
521
522 let mut outs: HashMap<String, Vec<mpsc::Sender<StreamPage>>> = HashMap::new();
524 let mut ins: HashMap<String, Vec<InEdge>> = HashMap::new();
525 for e in &edges {
526 let cap = tee_cap
527 .get(e.from.as_str())
528 .copied()
529 .unwrap_or(opts.default_channel_capacity)
530 .max(1);
531 let (tx, rx) = mpsc::channel(cap);
532 outs.entry(e.from.clone()).or_default().push(tx);
533 ins.entry(e.to.clone()).or_default().push(InEdge {
534 label: e.label.clone(),
535 rx,
536 });
537 }
538
539 type NodeFut = Pin<Box<dyn Future<Output = Result<NodeOutcome, FaucetError>>>>;
541 let mut futs: Vec<NodeFut> = Vec::with_capacity(nodes.len());
542
543 for node in nodes {
544 let node_outs = outs.remove(&node.id).unwrap_or_default();
545 let mut node_ins = ins.remove(&node.id).unwrap_or_default();
546 let pipeline = opts.pipeline_name.clone();
547 let cancel = opts.cancel.clone();
548 let Node { id, kind } = node;
549
550 let fut: NodeFut = match kind {
551 NodeKind::Source(source) => {
552 let sb = start_bookmark.clone();
553 let bs = opts.batch_size;
554 Box::pin(run_source_node(source, sb, bs, node_outs, cancel))
555 }
556 NodeKind::Transform(stages) => {
557 let rx = take_single(node_ins)
558 .ok_or_else(|| cfg(format!("transform '{id}' has no input edge")))?;
559 let labels = Labels::new(pipeline.clone(), id.clone(), opts.run_id.clone());
560 Box::pin(run_transform_node(stages, labels, rx, node_outs, cancel))
561 }
562 NodeKind::Tee { .. } => {
563 let rx = take_single(node_ins)
564 .ok_or_else(|| cfg(format!("tee '{id}' has no input edge")))?;
565 Box::pin(run_tee_node(id, pipeline, rx, node_outs, cancel))
566 }
567 NodeKind::Merge => {
568 let rxs: Vec<mpsc::Receiver<StreamPage>> =
569 node_ins.into_iter().map(|ie| ie.rx).collect();
570 Box::pin(run_merge_node(id, pipeline, rxs, node_outs, cancel))
571 }
572 NodeKind::Join(j) => {
573 let build_rx = take_by_label(&mut node_ins, &j.build_edge);
574 let probe_rx = take_by_label(&mut node_ins, &j.probe_edge);
575 match (build_rx, probe_rx) {
576 (Some(b), Some(p)) => {
577 Box::pin(run_join_node(id, pipeline, j, b, p, node_outs, cancel))
578 }
579 _ => {
580 return Err(cfg(format!(
581 "join '{id}' is missing its build/probe input edges"
582 )));
583 }
584 }
585 }
586 NodeKind::Sink(sink) => {
587 let rx = take_single(node_ins)
588 .ok_or_else(|| cfg(format!("sink '{id}' has no input edge")))?;
589 let sopts = SinkNodeOpts {
590 pipeline_name: pipeline,
591 run_id: opts.run_id.clone(),
592 state_store: opts.state_store.clone(),
593 dlq: opts.dlq.clone(),
594 cancel: cancel.clone(),
595 };
596 Box::pin(run_sink_node(id, sink, rx, sopts))
597 }
598 };
599 futs.push(fut);
600 }
601
602 drop(outs);
604 drop(ins);
605
606 match opts.on_error {
607 TopologyOnError::Propagate => {
608 let outcomes = futures::future::try_join_all(futs).await?;
609 Ok(aggregate(outcomes))
610 }
611 TopologyOnError::Continue => {
612 let results = futures::future::join_all(futs).await;
613 let mut ok = Vec::new();
614 let mut errs = Vec::new();
615 for r in results {
616 match r {
617 Ok(o) => ok.push(o),
618 Err(e) => {
619 tracing::error!(error = %e, "topology node failed (on_error: continue)");
620 errs.push(e.to_string());
621 }
622 }
623 }
624 let mut result = aggregate(ok);
625 result.errors = errs;
626 Ok(result)
627 }
628 }
629 }
630}
631
632fn aggregate(outcomes: Vec<NodeOutcome>) -> TopologyResult {
634 let mut result = TopologyResult::default();
635 for o in outcomes {
636 if let NodeOutcome::Sink {
637 node_id,
638 records,
639 bookmark,
640 } = o
641 {
642 result.records_written += records;
643 result.per_sink.insert(node_id.clone(), records);
644 result.bookmarks.insert(node_id, bookmark);
645 }
646 }
647 result
648}
649
650fn cfg(msg: impl Into<String>) -> FaucetError {
651 FaucetError::Config(format!("topology: {}", msg.into()))
652}
653
654fn arity(
655 node_id: &str,
656 kind: &str,
657 in_ok: bool,
658 out_ok: bool,
659 expected: &str,
660) -> Result<(), FaucetError> {
661 if in_ok && out_ok {
662 Ok(())
663 } else {
664 Err(cfg(format!(
665 "{kind} '{node_id}' has the wrong edge arity (expected {expected})"
666 )))
667 }
668}
669
670fn reaches_any(start: &str, adj: &HashMap<&str, Vec<&str>>, targets: &HashSet<&str>) -> bool {
671 let mut stack = vec![start];
672 let mut seen = HashSet::new();
673 while let Some(n) = stack.pop() {
674 if targets.contains(n) {
675 return true;
676 }
677 if !seen.insert(n) {
678 continue;
679 }
680 if let Some(ns) = adj.get(n) {
681 stack.extend(ns.iter().copied());
682 }
683 }
684 false
685}
686
687async fn compute_start_bookmark(opts: &TopologyOptions, sink_ids: &[String]) -> Option<Value> {
690 let store = opts.state_store.as_ref()?;
691 if sink_ids.is_empty() {
692 return None;
693 }
694 let mut values = Vec::with_capacity(sink_ids.len());
695 for id in sink_ids {
696 let key = format!("{}::{}", opts.pipeline_name, id);
697 match store.get(&key).await {
698 Ok(Some(v)) => values.push(v),
699 _ => return None, }
701 }
702 min_bookmark(&values)
703}
704
705fn min_bookmark(vals: &[Value]) -> Option<Value> {
707 let mut min: Option<&Value> = None;
708 for v in vals {
709 match min {
710 None => min = Some(v),
711 Some(m) if json_gt(m, v) => min = Some(v),
712 _ => {}
713 }
714 }
715 min.cloned()
716}
717
718async fn broadcast(page: StreamPage, outs: &mut Vec<mpsc::Sender<StreamPage>>) -> bool {
722 if outs.is_empty() {
723 return false;
724 }
725 let last = outs.len() - 1;
726 let mut closed: Vec<usize> = Vec::new();
727 for (i, tx) in outs.iter().enumerate().take(last) {
728 if tx.send(page.clone()).await.is_err() {
729 closed.push(i);
730 }
731 }
732 if outs[last].send(page).await.is_err() {
733 closed.push(last);
734 }
735 for &i in closed.iter().rev() {
736 outs.remove(i);
737 }
738 !outs.is_empty()
739}
740
741fn cancelled(cancel: &Option<CancellationToken>) -> bool {
742 cancel.as_ref().is_some_and(|c| c.is_cancelled())
743}
744
745async fn run_source_node(
746 source: Box<dyn Source>,
747 start_bookmark: Option<Value>,
748 batch_size: usize,
749 mut outs: Vec<mpsc::Sender<StreamPage>>,
750 cancel: Option<CancellationToken>,
751) -> Result<NodeOutcome, FaucetError> {
752 if let Some(bm) = start_bookmark {
753 source.apply_start_bookmark(bm).await?;
754 }
755 let ctx = std::collections::HashMap::new();
756 let mut pages = source.stream_pages(&ctx, batch_size);
757 while let Some(item) = pages.next().await {
758 if cancelled(&cancel) {
759 break;
760 }
761 let page = item?;
762 if !broadcast(page, &mut outs).await {
763 break;
764 }
765 }
766 Ok(NodeOutcome::Other)
767}
768
769async fn run_transform_node(
770 stages: Vec<CompiledStage>,
771 labels: Labels,
772 mut rx: mpsc::Receiver<StreamPage>,
773 mut outs: Vec<mpsc::Sender<StreamPage>>,
774 cancel: Option<CancellationToken>,
775) -> Result<NodeOutcome, FaucetError> {
776 while let Some(page) = rx.recv().await {
777 if cancelled(&cancel) {
778 break;
779 }
780 let records = instrumented_apply_stages(page.records, &stages, &labels)?;
781 let out = StreamPage {
782 records,
783 bookmark: page.bookmark,
784 };
785 if !broadcast(out, &mut outs).await {
786 break;
787 }
788 }
789 Ok(NodeOutcome::Other)
790}
791
792fn node_labels(pipeline: &str, node: &str) -> Vec<Label> {
793 vec![
794 Label::new("pipeline", SharedString::from(pipeline.to_string())),
795 Label::new("node", SharedString::from(node.to_string())),
796 ]
797}
798
799async fn run_tee_node(
800 node_id: String,
801 pipeline: String,
802 mut rx: mpsc::Receiver<StreamPage>,
803 mut outs: Vec<mpsc::Sender<StreamPage>>,
804 cancel: Option<CancellationToken>,
805) -> Result<NodeOutcome, FaucetError> {
806 let labels = node_labels(&pipeline, &node_id);
807 while let Some(page) = rx.recv().await {
808 if cancelled(&cancel) {
809 break;
810 }
811 counter!("faucet_tee_records_total", labels.clone()).increment(page.records.len() as u64);
812 if !broadcast(page, &mut outs).await {
813 break;
814 }
815 }
816 Ok(NodeOutcome::Other)
817}
818
819async fn run_merge_node(
820 node_id: String,
821 pipeline: String,
822 rxs: Vec<mpsc::Receiver<StreamPage>>,
823 mut outs: Vec<mpsc::Sender<StreamPage>>,
824 cancel: Option<CancellationToken>,
825) -> Result<NodeOutcome, FaucetError> {
826 let labels = node_labels(&pipeline, &node_id);
827 let streams = rxs.into_iter().map(|mut rx| {
828 Box::pin(async_stream::stream! {
829 while let Some(p) = rx.recv().await {
830 yield p;
831 }
832 }) as Pin<Box<dyn futures::Stream<Item = StreamPage> + Send>>
833 });
834 let mut sel = futures::stream::select_all(streams);
835 while let Some(page) = sel.next().await {
836 if cancelled(&cancel) {
837 break;
838 }
839 counter!("faucet_merge_records_total", labels.clone()).increment(page.records.len() as u64);
840 if !broadcast(page, &mut outs).await {
841 break;
842 }
843 }
844 Ok(NodeOutcome::Other)
845}
846
847#[allow(clippy::too_many_arguments)]
848async fn run_join_node(
849 node_id: String,
850 pipeline: String,
851 j: JoinNode,
852 mut build_rx: mpsc::Receiver<StreamPage>,
853 mut probe_rx: mpsc::Receiver<StreamPage>,
854 mut outs: Vec<mpsc::Sender<StreamPage>>,
855 cancel: Option<CancellationToken>,
856) -> Result<NodeOutcome, FaucetError> {
857 let mode = j.config.mode;
858 let mut join = HashJoin::new(j.config);
859
860 let build_start = std::time::Instant::now();
862 while let Some(page) = build_rx.recv().await {
863 if cancelled(&cancel) {
864 return Ok(NodeOutcome::Other);
865 }
866 join.add_build_page(page.records)?;
867 }
868 let labels = node_labels(&pipeline, &node_id);
869 histogram!("faucet_join_build_duration_seconds", labels.clone())
870 .record(build_start.elapsed().as_secs_f64());
871
872 while let Some(page) = probe_rx.recv().await {
874 if cancelled(&cancel) {
875 break;
876 }
877 let enriched = join.probe_page(page.records)?;
878 let out = StreamPage {
879 records: enriched,
880 bookmark: page.bookmark,
881 };
882 if !broadcast(out, &mut outs).await {
883 break;
884 }
885 }
886
887 emit_join_metrics(&labels, mode, join.stats());
888 Ok(NodeOutcome::Other)
889}
890
891fn emit_join_metrics(labels: &[Label], mode: JoinMode, stats: &crate::join::JoinStats) {
892 counter!("faucet_join_build_records_total", labels.to_vec()).increment(stats.build_records);
893 counter!("faucet_join_build_nulls_total", labels.to_vec()).increment(stats.build_nulls);
894 counter!("faucet_join_duplicates_total", labels.to_vec()).increment(stats.duplicates);
895 counter!("faucet_join_probe_records_total", labels.to_vec()).increment(stats.probe_records);
896 counter!("faucet_join_project_misses_total", labels.to_vec()).increment(stats.project_misses);
897 let mut match_labels = labels.to_vec();
898 match_labels.push(Label::new("kind", SharedString::from(mode.to_string())));
899 counter!("faucet_join_matches_total", match_labels.clone()).increment(stats.matches);
900 counter!("faucet_join_misses_total", match_labels).increment(stats.misses);
901}
902
903struct SinkNodeOpts {
904 pipeline_name: String,
905 run_id: String,
906 state_store: Option<Arc<dyn StateStore>>,
907 dlq: Option<DlqConfig>,
908 cancel: Option<CancellationToken>,
909}
910
911async fn run_sink_node(
912 node_id: String,
913 sink: Box<dyn Sink>,
914 mut rx: mpsc::Receiver<StreamPage>,
915 opts: SinkNodeOpts,
916) -> Result<NodeOutcome, FaucetError> {
917 let pages = Box::pin(async_stream::stream! {
918 while let Some(page) = rx.recv().await {
919 yield Ok::<StreamPage, FaucetError>(page);
920 }
921 });
922
923 let mut run_opts = RunStreamOptions::new()
924 .with_name(opts.pipeline_name.clone())
925 .with_row(node_id.clone())
926 .with_run_id(opts.run_id.clone());
927 if let Some(store) = opts.state_store {
928 let key = format!("{}::{}", opts.pipeline_name, node_id);
929 run_opts = run_opts.with_state(store, key);
930 }
931 if let Some(dlq) = opts.dlq {
932 run_opts = run_opts.with_dlq(dlq);
933 }
934 if let Some(cancel) = opts.cancel {
935 run_opts = run_opts.with_cancel(cancel);
936 }
937
938 let result = run_stream(pages, sink.as_ref(), run_opts).await?;
939 Ok(NodeOutcome::Sink {
940 node_id,
941 records: result.records_written,
942 bookmark: result.bookmark,
943 })
944}
945
946#[derive(Default)]
950pub struct TopologyBuilder {
951 nodes: Vec<Node>,
952 edges: Vec<Edge>,
953}
954
955impl TopologyBuilder {
956 pub fn node(mut self, id: impl Into<String>, kind: NodeKind) -> Self {
958 self.nodes.push(Node {
959 id: id.into(),
960 kind,
961 });
962 self
963 }
964
965 pub fn source(self, id: impl Into<String>, source: Box<dyn Source>) -> Self {
967 self.node(id, NodeKind::Source(source))
968 }
969
970 pub fn transform(self, id: impl Into<String>, stages: Vec<CompiledStage>) -> Self {
972 self.node(id, NodeKind::Transform(stages))
973 }
974
975 pub fn tee(self, id: impl Into<String>, capacity: usize, fanout: Option<usize>) -> Self {
977 self.node(id, NodeKind::Tee { capacity, fanout })
978 }
979
980 pub fn merge(self, id: impl Into<String>) -> Self {
982 self.node(id, NodeKind::Merge)
983 }
984
985 pub fn join(self, id: impl Into<String>, join: JoinNode) -> Self {
987 self.node(id, NodeKind::Join(join))
988 }
989
990 pub fn sink(self, id: impl Into<String>, sink: Box<dyn Sink>) -> Self {
992 self.node(id, NodeKind::Sink(sink))
993 }
994
995 pub fn edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
997 self.edges.push(Edge {
998 from: from.into(),
999 to: to.into(),
1000 label: None,
1001 });
1002 self
1003 }
1004
1005 pub fn labelled_edge(
1007 mut self,
1008 from: impl Into<String>,
1009 to: impl Into<String>,
1010 label: impl Into<String>,
1011 ) -> Self {
1012 self.edges.push(Edge {
1013 from: from.into(),
1014 to: to.into(),
1015 label: Some(label.into()),
1016 });
1017 self
1018 }
1019
1020 pub fn build(self) -> Result<Topology, FaucetError> {
1022 let t = Topology {
1023 nodes: self.nodes,
1024 edges: self.edges,
1025 };
1026 t.validate()?;
1027 Ok(t)
1028 }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034 use crate::join::{JoinConfig, JoinMode, Projection};
1035 use crate::state::MemoryStateStore;
1036 use async_trait::async_trait;
1037 use serde_json::json;
1038 use std::sync::Mutex;
1039
1040 struct VecSource {
1043 records: Vec<Value>,
1044 bookmark: Option<Value>,
1045 }
1046 impl VecSource {
1047 fn boxed(records: Vec<Value>) -> Box<dyn Source> {
1048 Box::new(VecSource {
1049 records,
1050 bookmark: None,
1051 })
1052 }
1053 fn boxed_bm(records: Vec<Value>, bm: Value) -> Box<dyn Source> {
1054 Box::new(VecSource {
1055 records,
1056 bookmark: Some(bm),
1057 })
1058 }
1059 }
1060 #[async_trait]
1061 impl Source for VecSource {
1062 async fn fetch_with_context(
1063 &self,
1064 _c: &std::collections::HashMap<String, Value>,
1065 ) -> Result<Vec<Value>, FaucetError> {
1066 Ok(self.records.clone())
1067 }
1068 async fn fetch_with_context_incremental(
1069 &self,
1070 _c: &std::collections::HashMap<String, Value>,
1071 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1072 Ok((self.records.clone(), self.bookmark.clone()))
1073 }
1074 }
1075
1076 struct FailingSource;
1077 #[async_trait]
1078 impl Source for FailingSource {
1079 async fn fetch_with_context(
1080 &self,
1081 _c: &std::collections::HashMap<String, Value>,
1082 ) -> Result<Vec<Value>, FaucetError> {
1083 Err(FaucetError::Source("boom".into()))
1084 }
1085 }
1086
1087 struct RecordingSource {
1089 records: Vec<Value>,
1090 applied: Arc<Mutex<Option<Value>>>,
1091 }
1092 #[async_trait]
1093 impl Source for RecordingSource {
1094 async fn fetch_with_context(
1095 &self,
1096 _c: &std::collections::HashMap<String, Value>,
1097 ) -> Result<Vec<Value>, FaucetError> {
1098 Ok(self.records.clone())
1099 }
1100 async fn apply_start_bookmark(&self, bm: Value) -> Result<(), FaucetError> {
1101 *self.applied.lock().unwrap() = Some(bm);
1102 Ok(())
1103 }
1104 }
1105
1106 #[derive(Clone)]
1107 struct CollectSink {
1108 store: Arc<Mutex<Vec<Value>>>,
1109 }
1110 impl CollectSink {
1111 fn new() -> (Self, Arc<Mutex<Vec<Value>>>) {
1112 let store = Arc::new(Mutex::new(Vec::new()));
1113 (
1114 Self {
1115 store: store.clone(),
1116 },
1117 store,
1118 )
1119 }
1120 }
1121 #[async_trait]
1122 impl Sink for CollectSink {
1123 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1124 self.store.lock().unwrap().extend_from_slice(records);
1125 Ok(records.len())
1126 }
1127 }
1128
1129 struct FailingSink;
1130 #[async_trait]
1131 impl Sink for FailingSink {
1132 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
1133 Err(FaucetError::Sink("sink boom".into()))
1134 }
1135 }
1136
1137 fn recs(n: usize) -> Vec<Value> {
1138 (0..n).map(|i| json!({ "i": i })).collect()
1139 }
1140
1141 #[test]
1144 fn validate_rejects_empty() {
1145 let err = Topology {
1146 nodes: vec![],
1147 edges: vec![],
1148 }
1149 .validate()
1150 .unwrap_err();
1151 assert!(err.to_string().contains("no nodes"));
1152 }
1153
1154 #[test]
1155 fn validate_rejects_duplicate_id() {
1156 let (sink, _) = CollectSink::new();
1157 let err = Topology::builder()
1158 .source("a", VecSource::boxed(recs(1)))
1159 .sink("a", Box::new(sink))
1160 .edge("a", "a")
1161 .build()
1162 .unwrap_err();
1163 assert!(err.to_string().contains("duplicate node id"));
1164 }
1165
1166 #[test]
1167 fn validate_rejects_unknown_endpoint() {
1168 let err = Topology::builder()
1169 .source("s", VecSource::boxed(recs(1)))
1170 .edge("s", "ghost")
1171 .build()
1172 .unwrap_err();
1173 assert!(err.to_string().contains("unknown 'to' node 'ghost'"));
1174 }
1175
1176 #[test]
1177 fn validate_rejects_unknown_from_endpoint() {
1178 let (sink, _) = CollectSink::new();
1179 let err = Topology::builder()
1180 .sink("k", Box::new(sink))
1181 .edge("ghost", "k")
1182 .build()
1183 .unwrap_err();
1184 assert!(err.to_string().contains("unknown 'from' node 'ghost'"));
1185 }
1186
1187 #[test]
1188 fn validate_rejects_source_with_incoming_edge() {
1189 let (sink, _) = CollectSink::new();
1190 let err = Topology::builder()
1191 .source("s", VecSource::boxed(recs(1)))
1192 .sink("k", Box::new(sink))
1193 .edge("s", "k")
1194 .edge("k", "s") .build()
1196 .unwrap_err();
1197 assert!(err.to_string().contains("arity") || err.to_string().contains("cycle"));
1199 }
1200
1201 #[test]
1202 fn validate_rejects_tee_fanout_mismatch() {
1203 let (s1, _) = CollectSink::new();
1204 let (s2, _) = CollectSink::new();
1205 let err = Topology::builder()
1206 .source("s", VecSource::boxed(recs(1)))
1207 .tee("t", 4, Some(3))
1208 .sink("a", Box::new(s1))
1209 .sink("b", Box::new(s2))
1210 .edge("s", "t")
1211 .edge("t", "a")
1212 .edge("t", "b")
1213 .build()
1214 .unwrap_err();
1215 assert!(err.to_string().contains("fanout 3 but has 2"));
1216 }
1217
1218 #[test]
1219 fn validate_rejects_tee_with_one_output() {
1220 let (s1, _) = CollectSink::new();
1221 let err = Topology::builder()
1222 .source("s", VecSource::boxed(recs(1)))
1223 .tee("t", 4, None)
1224 .sink("a", Box::new(s1))
1225 .edge("s", "t")
1226 .edge("t", "a")
1227 .build()
1228 .unwrap_err();
1229 assert!(err.to_string().contains("tee 't'"));
1230 }
1231
1232 #[test]
1233 fn validate_rejects_merge_with_one_input() {
1234 let (s1, _) = CollectSink::new();
1235 let err = Topology::builder()
1236 .source("s", VecSource::boxed(recs(1)))
1237 .merge("m")
1238 .sink("a", Box::new(s1))
1239 .edge("s", "m")
1240 .edge("m", "a")
1241 .build()
1242 .unwrap_err();
1243 assert!(err.to_string().contains("merge 'm'"));
1244 }
1245
1246 #[test]
1247 fn validate_rejects_join_missing_label() {
1248 let (s1, _) = CollectSink::new();
1249 let jn = JoinNode {
1250 config: JoinConfig::default(),
1251 build_edge: "build".into(),
1252 probe_edge: "probe".into(),
1253 };
1254 let err = Topology::builder()
1255 .source("b", VecSource::boxed(recs(1)))
1256 .source("p", VecSource::boxed(recs(1)))
1257 .join("j", jn)
1258 .sink("a", Box::new(s1))
1259 .labelled_edge("b", "j", "build")
1260 .edge("p", "j") .edge("j", "a")
1262 .build()
1263 .unwrap_err();
1264 assert!(err.to_string().contains("labelled 'probe'"));
1265 }
1266
1267 #[test]
1268 fn validate_rejects_join_same_labels() {
1269 let (s1, _) = CollectSink::new();
1270 let jn = JoinNode {
1271 config: JoinConfig::default(),
1272 build_edge: "x".into(),
1273 probe_edge: "x".into(),
1274 };
1275 let err = Topology::builder()
1276 .source("b", VecSource::boxed(recs(1)))
1277 .source("p", VecSource::boxed(recs(1)))
1278 .join("j", jn)
1279 .sink("a", Box::new(s1))
1280 .labelled_edge("b", "j", "x")
1281 .labelled_edge("p", "j", "x")
1282 .edge("j", "a")
1283 .build()
1284 .unwrap_err();
1285 assert!(err.to_string().contains("must differ"));
1286 }
1287
1288 #[test]
1289 fn validate_rejects_cycle() {
1290 let (sink, _) = CollectSink::new();
1293 let err = Topology::builder()
1294 .source("s", VecSource::boxed(recs(1)))
1295 .merge("m")
1296 .tee("t", 4, None)
1297 .sink("k", Box::new(sink))
1298 .edge("s", "m")
1299 .edge("m", "t")
1300 .edge("t", "m")
1301 .edge("t", "k")
1302 .build()
1303 .unwrap_err();
1304 assert!(err.to_string().contains("cycle"), "{err}");
1305 }
1306
1307 #[test]
1308 fn validate_rejects_no_source() {
1309 let err = Topology::builder()
1311 .transform("t1", vec![])
1312 .transform("t2", vec![])
1313 .edge("t1", "t2")
1314 .edge("t2", "t1")
1315 .build()
1316 .unwrap_err();
1317 assert!(err.to_string().contains("no source"), "{err}");
1318 }
1319
1320 #[test]
1321 fn validate_rejects_no_sink() {
1322 let err = Topology::builder()
1324 .source("s1", VecSource::boxed(recs(1)))
1325 .source("s2", VecSource::boxed(recs(1)))
1326 .merge("m")
1327 .edge("s1", "m")
1328 .edge("s2", "m")
1329 .edge("m", "m")
1330 .build()
1331 .unwrap_err();
1332 assert!(err.to_string().contains("no sink"), "{err}");
1333 }
1334
1335 #[tokio::test]
1338 async fn simple_source_to_sink() {
1339 let (sink, store) = CollectSink::new();
1340 let topo = Topology::builder()
1341 .source("s", VecSource::boxed(recs(5)))
1342 .sink("k", Box::new(sink))
1343 .edge("s", "k")
1344 .build()
1345 .unwrap();
1346 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1347 assert_eq!(result.records_written, 5);
1348 assert_eq!(store.lock().unwrap().len(), 5);
1349 assert_eq!(result.per_sink.get("k"), Some(&5));
1350 }
1351
1352 #[tokio::test]
1353 async fn source_transform_sink() {
1354 let (sink, store) = CollectSink::new();
1355 let topo = Topology::builder()
1356 .source("s", VecSource::boxed(recs(3)))
1357 .transform("t", vec![]) .sink("k", Box::new(sink))
1359 .edge("s", "t")
1360 .edge("t", "k")
1361 .build()
1362 .unwrap();
1363 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1364 assert_eq!(result.records_written, 3);
1365 assert_eq!(store.lock().unwrap().len(), 3);
1366 }
1367
1368 #[tokio::test]
1369 async fn tee_fans_out_to_three_sinks() {
1370 let (s1, st1) = CollectSink::new();
1371 let (s2, st2) = CollectSink::new();
1372 let (s3, st3) = CollectSink::new();
1373 let topo = Topology::builder()
1374 .source("s", VecSource::boxed(recs(10)))
1375 .tee("t", 4, Some(3))
1376 .sink("a", Box::new(s1))
1377 .sink("b", Box::new(s2))
1378 .sink("c", Box::new(s3))
1379 .edge("s", "t")
1380 .edge("t", "a")
1381 .edge("t", "b")
1382 .edge("t", "c")
1383 .build()
1384 .unwrap();
1385 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1386 assert_eq!(st1.lock().unwrap().len(), 10);
1387 assert_eq!(st2.lock().unwrap().len(), 10);
1388 assert_eq!(st3.lock().unwrap().len(), 10);
1389 assert_eq!(result.records_written, 30);
1390 }
1391
1392 #[tokio::test]
1393 async fn merge_fans_in_two_sources() {
1394 let (sink, store) = CollectSink::new();
1395 let topo = Topology::builder()
1396 .source("s1", VecSource::boxed(recs(4)))
1397 .source("s2", VecSource::boxed(recs(6)))
1398 .merge("m")
1399 .sink("k", Box::new(sink))
1400 .edge("s1", "m")
1401 .edge("s2", "m")
1402 .edge("m", "k")
1403 .build()
1404 .unwrap();
1405 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1406 assert_eq!(result.records_written, 10);
1407 assert_eq!(store.lock().unwrap().len(), 10);
1408 }
1409
1410 #[tokio::test]
1411 async fn join_enriches_end_to_end() {
1412 let (sink, store) = CollectSink::new();
1413 let customers = vec![
1414 json!({"id": 1, "tier": "gold"}),
1415 json!({"id": 2, "tier": "silver"}),
1416 ];
1417 let orders = vec![
1418 json!({"order": "A", "cust": 1}),
1419 json!({"order": "B", "cust": 2}),
1420 json!({"order": "C", "cust": 99}),
1421 ];
1422 let jn = JoinNode {
1423 config: JoinConfig {
1424 mode: JoinMode::Inner,
1425 build_key: "id".into(),
1426 probe_key: "cust".into(),
1427 projections: vec![Projection {
1428 from: "tier".into(),
1429 as_: "tier".into(),
1430 }],
1431 ..Default::default()
1432 },
1433 build_edge: "customers".into(),
1434 probe_edge: "orders".into(),
1435 };
1436 let topo = Topology::builder()
1437 .source("c", VecSource::boxed(customers))
1438 .source("o", VecSource::boxed(orders))
1439 .join("j", jn)
1440 .sink("k", Box::new(sink))
1441 .labelled_edge("c", "j", "customers")
1442 .labelled_edge("o", "j", "orders")
1443 .edge("j", "k")
1444 .build()
1445 .unwrap();
1446 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1447 assert_eq!(result.records_written, 2);
1449 let written = store.lock().unwrap();
1450 assert!(
1451 written
1452 .iter()
1453 .any(|r| r["order"] == json!("A") && r["tier"] == json!("gold"))
1454 );
1455 }
1456
1457 #[tokio::test]
1458 async fn propagate_aborts_on_sink_failure() {
1459 let topo = Topology::builder()
1460 .source("s", VecSource::boxed(recs(3)))
1461 .sink("k", Box::new(FailingSink))
1462 .edge("s", "k")
1463 .build()
1464 .unwrap();
1465 let err = topo.run(TopologyOptions::new("p")).await.unwrap_err();
1466 assert!(matches!(err, FaucetError::Sink(_)));
1467 }
1468
1469 #[tokio::test]
1470 async fn propagate_aborts_on_source_failure() {
1471 let (sink, _) = CollectSink::new();
1472 let topo = Topology::builder()
1473 .source("s", Box::new(FailingSource))
1474 .sink("k", Box::new(sink))
1475 .edge("s", "k")
1476 .build()
1477 .unwrap();
1478 let err = topo.run(TopologyOptions::new("p")).await.unwrap_err();
1479 assert!(matches!(err, FaucetError::Source(_)));
1480 }
1481
1482 #[tokio::test]
1483 async fn continue_lets_healthy_branch_finish() {
1484 let (good, store) = CollectSink::new();
1486 let topo = Topology::builder()
1487 .source("s", VecSource::boxed(recs(8)))
1488 .tee("t", 8, Some(2))
1489 .sink("bad", Box::new(FailingSink))
1490 .sink("good", Box::new(good))
1491 .edge("s", "t")
1492 .edge("t", "bad")
1493 .edge("t", "good")
1494 .build()
1495 .unwrap();
1496 let opts = TopologyOptions::new("p").with_on_error(TopologyOnError::Continue);
1497 let result = topo.run(opts).await.unwrap();
1498 assert_eq!(store.lock().unwrap().len(), 8);
1499 assert!(!result.errors.is_empty(), "failing sink should be recorded");
1500 }
1501
1502 #[tokio::test]
1503 async fn state_min_bookmark_applied_to_source() {
1504 let store = Arc::new(MemoryStateStore::new());
1505 store.put("p::a", &json!(250)).await.unwrap();
1507 store.put("p::b", &json!(100)).await.unwrap();
1508 let applied = Arc::new(Mutex::new(None));
1509 let src = RecordingSource {
1510 records: recs(1),
1511 applied: applied.clone(),
1512 };
1513 let (s1, _) = CollectSink::new();
1514 let (s2, _) = CollectSink::new();
1515 let topo = Topology::builder()
1516 .source("s", Box::new(src))
1517 .tee("t", 4, Some(2))
1518 .sink("a", Box::new(s1))
1519 .sink("b", Box::new(s2))
1520 .edge("s", "t")
1521 .edge("t", "a")
1522 .edge("t", "b")
1523 .build()
1524 .unwrap();
1525 let opts = TopologyOptions::new("p").with_state_store(store.clone());
1526 topo.run(opts).await.unwrap();
1527 assert_eq!(*applied.lock().unwrap(), Some(json!(100)));
1528 }
1529
1530 #[tokio::test]
1531 async fn state_no_bookmark_when_a_sink_is_missing() {
1532 let store = Arc::new(MemoryStateStore::new());
1533 store.put("p::a", &json!(100)).await.unwrap();
1534 let applied = Arc::new(Mutex::new(None));
1536 let src = RecordingSource {
1537 records: recs(1),
1538 applied: applied.clone(),
1539 };
1540 let (s1, _) = CollectSink::new();
1541 let (s2, _) = CollectSink::new();
1542 let topo = Topology::builder()
1543 .source("s", Box::new(src))
1544 .tee("t", 4, Some(2))
1545 .sink("a", Box::new(s1))
1546 .sink("b", Box::new(s2))
1547 .edge("s", "t")
1548 .edge("t", "a")
1549 .edge("t", "b")
1550 .build()
1551 .unwrap();
1552 let opts = TopologyOptions::new("p").with_state_store(store.clone());
1553 topo.run(opts).await.unwrap();
1554 assert_eq!(*applied.lock().unwrap(), None);
1555 }
1556
1557 #[tokio::test]
1558 async fn sink_persists_bookmark() {
1559 let store = Arc::new(MemoryStateStore::new());
1560 let (sink, _) = CollectSink::new();
1561 let topo = Topology::builder()
1562 .source("s", VecSource::boxed_bm(recs(2), json!("v9")))
1563 .sink("k", Box::new(sink))
1564 .edge("s", "k")
1565 .build()
1566 .unwrap();
1567 let opts = TopologyOptions::new("p").with_state_store(store.clone());
1568 let result = topo.run(opts).await.unwrap();
1569 assert_eq!(result.bookmarks.get("k"), Some(&Some(json!("v9"))));
1570 assert_eq!(store.get("p::k").await.unwrap(), Some(json!("v9")));
1571 }
1572
1573 #[tokio::test]
1574 async fn cancellation_stops_the_run() {
1575 let cancel = CancellationToken::new();
1576 cancel.cancel(); let (sink, store) = CollectSink::new();
1578 let topo = Topology::builder()
1579 .source("s", VecSource::boxed(recs(1000)))
1580 .sink("k", Box::new(sink))
1581 .edge("s", "k")
1582 .build()
1583 .unwrap();
1584 let opts = TopologyOptions::new("p").with_cancel(cancel);
1585 let result = topo.run(opts).await.unwrap();
1586 assert!(store.lock().unwrap().len() < 1000);
1588 let _ = result;
1589 }
1590
1591 #[test]
1592 fn min_bookmark_picks_smallest() {
1593 assert_eq!(
1594 min_bookmark(&[json!(250), json!(100), json!(300)]),
1595 Some(json!(100))
1596 );
1597 assert_eq!(min_bookmark(&[]), None);
1598 }
1599
1600 #[test]
1601 fn kind_str_matches() {
1602 assert_eq!(NodeKind::Merge.kind_str(), "merge");
1603 assert_eq!(
1604 NodeKind::Tee {
1605 capacity: 1,
1606 fanout: None
1607 }
1608 .kind_str(),
1609 "tee"
1610 );
1611 }
1612
1613 #[test]
1614 fn builder_exposes_nodes_and_edges() {
1615 let (sink, _) = CollectSink::new();
1616 let topo = Topology::builder()
1617 .source("s", VecSource::boxed(recs(1)))
1618 .sink("k", Box::new(sink))
1619 .edge("s", "k")
1620 .build()
1621 .unwrap();
1622 assert_eq!(topo.nodes().len(), 2);
1623 assert_eq!(topo.edges().len(), 1);
1624 }
1625
1626 #[cfg(feature = "transform-keys-case")]
1627 #[tokio::test]
1628 async fn transform_node_applies_stage() {
1629 use crate::stage::{TransformStage, compile_stage};
1630 use crate::transform::{KeyCaseMode, RecordTransform};
1631 let stage = compile_stage(&TransformStage::Map(RecordTransform::KeysCase {
1632 mode: KeyCaseMode::Snake,
1633 }))
1634 .unwrap();
1635 let (sink, store) = CollectSink::new();
1636 let topo = Topology::builder()
1637 .source("s", VecSource::boxed(vec![json!({"FooBar": 1})]))
1638 .transform("t", vec![stage])
1639 .sink("k", Box::new(sink))
1640 .edge("s", "t")
1641 .edge("t", "k")
1642 .build()
1643 .unwrap();
1644 topo.run(TopologyOptions::new("p")).await.unwrap();
1645 let w = store.lock().unwrap();
1646 assert!(w[0].get("foo_bar").is_some());
1647 }
1648}