1use crate::dlq::DlqConfig;
64use crate::error::FaucetError;
65use crate::join::HashJoin;
66use crate::observability::{Labels, RunStreamOptions, instrumented_apply_stages};
67use crate::pipeline::{DEFAULT_BATCH_SIZE, StreamPage, run_stream};
68use crate::stage::CompiledStage;
69use crate::state::StateStore;
70use crate::traits::{Sink, Source};
71use futures::StreamExt;
72use metrics::{Label, SharedString, counter, histogram};
73use serde_json::Value;
74use std::collections::{HashMap, HashSet};
75use std::future::Future;
76use std::pin::Pin;
77use std::sync::Arc;
78use std::time::Duration;
79use tokio::sync::mpsc;
80use tokio_util::sync::CancellationToken;
81
82pub use crate::join::{JoinConfig, JoinMode, KeyNormalize, OnCollision, OnDuplicate, Projection};
83
84pub const DEFAULT_CHANNEL_CAPACITY: usize = 4;
86
87#[derive(Debug, Clone)]
91pub struct JoinNode {
92 pub config: JoinConfig,
94 pub build_edge: String,
96 pub probe_edge: String,
98}
99
100pub enum NodeKind {
102 Source(Box<dyn Source>),
104 Transform(Vec<CompiledStage>),
106 Tee {
108 capacity: usize,
110 fanout: Option<usize>,
112 },
113 Merge,
115 Join(JoinNode),
117 Sink(Box<dyn Sink>),
119}
120
121impl std::fmt::Debug for NodeKind {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.write_str(self.kind_str())
124 }
125}
126
127impl NodeKind {
128 pub fn kind_str(&self) -> &'static str {
130 match self {
131 NodeKind::Source(_) => "source",
132 NodeKind::Transform(_) => "transform",
133 NodeKind::Tee { .. } => "tee",
134 NodeKind::Merge => "merge",
135 NodeKind::Join(_) => "join",
136 NodeKind::Sink(_) => "sink",
137 }
138 }
139
140 fn is_source(&self) -> bool {
141 matches!(self, NodeKind::Source(_))
142 }
143
144 fn is_sink(&self) -> bool {
145 matches!(self, NodeKind::Sink(_))
146 }
147}
148
149#[derive(Debug)]
151pub struct Node {
152 pub id: String,
154 pub kind: NodeKind,
156}
157
158#[derive(Debug, Clone)]
160pub struct Edge {
161 pub from: String,
163 pub to: String,
165 pub label: Option<String>,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172pub enum TopologyOnError {
173 #[default]
175 Propagate,
176 Continue,
179}
180
181#[derive(Clone, Default)]
194#[non_exhaustive]
195pub struct TopologyGovernance {
196 #[cfg(feature = "quality")]
198 pub quality: Option<Arc<crate::quality::CompiledQuality>>,
199 #[cfg(feature = "contract")]
201 pub contract: Option<Arc<crate::contract::CompiledContract>>,
202 #[cfg(feature = "masking")]
205 pub masking_by_sink: HashMap<String, Arc<crate::masking::CompiledMasking>>,
206 pub schema_drift: Option<crate::drift::SchemaDriftPolicy>,
208 pub resilience: Option<crate::resilience::ResiliencePolicy>,
211 pub delivery: crate::idempotency::DeliveryMode,
224}
225
226impl TopologyGovernance {
227 pub fn new() -> Self {
229 Self::default()
230 }
231}
232
233#[derive(Clone)]
235pub struct TopologyOptions {
236 pub pipeline_name: String,
238 pub run_id: String,
240 pub batch_size: usize,
242 pub state_store: Option<Arc<dyn StateStore>>,
244 pub dlq: Option<DlqConfig>,
246 pub cancel: Option<CancellationToken>,
248 pub on_error: TopologyOnError,
250 pub default_channel_capacity: usize,
252}
253
254pub const STOP_FLUSH_GRACE: Duration = Duration::from_secs(30);
262
263impl Default for TopologyOptions {
264 fn default() -> Self {
265 Self {
266 pipeline_name: "unnamed".into(),
267 run_id: String::new(),
268 batch_size: DEFAULT_BATCH_SIZE,
269 state_store: None,
270 dlq: None,
271 cancel: None,
272 on_error: TopologyOnError::default(),
273 default_channel_capacity: DEFAULT_CHANNEL_CAPACITY,
274 }
275 }
276}
277
278impl TopologyOptions {
279 pub fn new(pipeline_name: impl Into<String>) -> Self {
281 Self {
282 pipeline_name: pipeline_name.into(),
283 ..Default::default()
284 }
285 }
286
287 pub fn with_state_store(mut self, store: Arc<dyn StateStore>) -> Self {
289 self.state_store = Some(store);
290 self
291 }
292
293 pub fn with_dlq(mut self, dlq: DlqConfig) -> Self {
295 self.dlq = Some(dlq);
296 self
297 }
298
299 pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
301 self.cancel = Some(cancel);
302 self
303 }
304
305 pub fn with_on_error(mut self, on_error: TopologyOnError) -> Self {
307 self.on_error = on_error;
308 self
309 }
310
311 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
313 self.batch_size = batch_size;
314 self
315 }
316}
317
318#[derive(Debug, Clone)]
322#[non_exhaustive]
323pub struct NodeReport {
324 pub node_id: String,
326 pub kind: &'static str,
328 pub records: usize,
330 pub bookmark: Option<Value>,
332 pub error: Option<String>,
334}
335
336#[derive(Debug, Clone, Default)]
342#[non_exhaustive]
343pub struct TopologyRun {
344 pub result: TopologyResult,
346 pub nodes: Vec<NodeReport>,
348 pub per_source: HashMap<String, usize>,
351}
352
353#[derive(Debug, Clone, Default)]
355pub struct TopologyResult {
356 pub records_written: usize,
358 pub per_sink: HashMap<String, usize>,
360 pub bookmarks: HashMap<String, Option<Value>>,
362 pub errors: Vec<String>,
365}
366
367struct InEdge {
370 label: Option<String>,
371 rx: mpsc::Receiver<StreamPage>,
372}
373
374fn take_single(mut ins: Vec<InEdge>) -> Option<mpsc::Receiver<StreamPage>> {
376 ins.drain(..).next().map(|ie| ie.rx)
377}
378
379fn take_by_label(ins: &mut Vec<InEdge>, label: &str) -> Option<mpsc::Receiver<StreamPage>> {
381 ins.iter()
382 .position(|ie| ie.label.as_deref() == Some(label))
383 .map(|pos| ins.remove(pos).rx)
384}
385
386enum NodeOutcome {
388 Sink {
389 node_id: String,
390 records: usize,
391 bookmark: Option<Value>,
392 },
393 Source {
397 node_id: String,
398 records: usize,
399 },
400 Other,
401}
402
403#[derive(Debug)]
407pub struct Topology {
408 nodes: Vec<Node>,
409 edges: Vec<Edge>,
410}
411
412impl Topology {
413 pub fn builder() -> TopologyBuilder {
415 TopologyBuilder::default()
416 }
417
418 pub fn nodes(&self) -> &[Node] {
420 &self.nodes
421 }
422
423 pub fn edges(&self) -> &[Edge] {
425 &self.edges
426 }
427
428 pub fn validate(&self) -> Result<(), FaucetError> {
433 if self.nodes.is_empty() {
434 return Err(cfg("topology has no nodes"));
435 }
436
437 let mut seen = HashSet::new();
439 for n in &self.nodes {
440 if !seen.insert(n.id.as_str()) {
441 return Err(cfg(format!("duplicate node id '{}'", n.id)));
442 }
443 }
444 let ids: HashSet<&str> = seen;
445
446 for e in &self.edges {
448 if !ids.contains(e.from.as_str()) {
449 return Err(cfg(format!(
450 "edge references unknown 'from' node '{}'",
451 e.from
452 )));
453 }
454 if !ids.contains(e.to.as_str()) {
455 return Err(cfg(format!("edge references unknown 'to' node '{}'", e.to)));
456 }
457 }
458
459 let mut in_deg: HashMap<&str, usize> = HashMap::new();
461 let mut out_deg: HashMap<&str, usize> = HashMap::new();
462 for e in &self.edges {
463 *out_deg.entry(e.from.as_str()).or_default() += 1;
464 *in_deg.entry(e.to.as_str()).or_default() += 1;
465 }
466
467 let mut has_source = false;
468 let mut has_sink = false;
469 for n in &self.nodes {
470 let i = in_deg.get(n.id.as_str()).copied().unwrap_or(0);
471 let o = out_deg.get(n.id.as_str()).copied().unwrap_or(0);
472 match &n.kind {
473 NodeKind::Source(_) => {
474 has_source = true;
475 arity(&n.id, "source", i == 0, o == 1, "0 in, exactly 1 out")?;
476 }
477 NodeKind::Transform(_) => {
478 arity(&n.id, "transform", i == 1, o == 1, "exactly 1 in, 1 out")?;
479 }
480 NodeKind::Tee { fanout, .. } => {
481 arity(&n.id, "tee", i == 1, o >= 2, "exactly 1 in, 2+ out")?;
482 if let Some(f) = fanout
483 && *f != o
484 {
485 return Err(cfg(format!(
486 "tee '{}' declares fanout {f} but has {o} outgoing edges",
487 n.id
488 )));
489 }
490 }
491 NodeKind::Merge => {
492 arity(&n.id, "merge", i >= 2, o == 1, "2+ in, exactly 1 out")?;
493 }
494 NodeKind::Join(j) => {
495 arity(&n.id, "join", i == 2, o == 1, "exactly 2 in, 1 out")?;
496 self.validate_join_edges(&n.id, j)?;
497 }
498 NodeKind::Sink(_) => {
499 has_sink = true;
500 arity(&n.id, "sink", i == 1, o == 0, "exactly 1 in, 0 out")?;
501 }
502 }
503 }
504
505 if !has_source {
506 return Err(cfg("topology has no source node"));
507 }
508 if !has_sink {
509 return Err(cfg("topology has no sink node"));
510 }
511
512 self.detect_cycle()?;
513 self.check_reachability()?;
514 Ok(())
515 }
516
517 fn validate_join_edges(&self, node_id: &str, j: &JoinNode) -> Result<(), FaucetError> {
518 let labels: Vec<&str> = self
519 .edges
520 .iter()
521 .filter(|e| e.to == node_id)
522 .filter_map(|e| e.label.as_deref())
523 .collect();
524 for want in [j.build_edge.as_str(), j.probe_edge.as_str()] {
525 if !labels.contains(&want) {
526 return Err(cfg(format!(
527 "join '{node_id}' has no incoming edge labelled '{want}' (known labels: {labels:?})"
528 )));
529 }
530 }
531 if j.build_edge == j.probe_edge {
532 return Err(cfg(format!(
533 "join '{node_id}' build_edge and probe_edge must differ"
534 )));
535 }
536 Ok(())
537 }
538
539 fn detect_cycle(&self) -> Result<(), FaucetError> {
541 let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
542 for e in &self.edges {
543 adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
544 }
545 #[derive(Clone, Copy, PartialEq)]
546 enum Color {
547 White,
548 Gray,
549 Black,
550 }
551 let mut color: HashMap<&str, Color> = self
552 .nodes
553 .iter()
554 .map(|n| (n.id.as_str(), Color::White))
555 .collect();
556
557 for start in self.nodes.iter().map(|n| n.id.as_str()) {
559 if color[start] != Color::White {
560 continue;
561 }
562 let mut stack: Vec<(&str, usize)> = vec![(start, 0)];
563 *color.get_mut(start).unwrap() = Color::Gray;
564 while let Some((node, idx)) = stack.last().copied() {
565 let neighbours = adj.get(node).map(|v| v.as_slice()).unwrap_or(&[]);
566 if idx < neighbours.len() {
567 stack.last_mut().unwrap().1 += 1;
568 let next = neighbours[idx];
569 match color[next] {
570 Color::Gray => {
571 return Err(cfg(format!("topology has a cycle through node '{next}'")));
572 }
573 Color::White => {
574 *color.get_mut(next).unwrap() = Color::Gray;
575 stack.push((next, 0));
576 }
577 Color::Black => {}
578 }
579 } else {
580 *color.get_mut(node).unwrap() = Color::Black;
581 stack.pop();
582 }
583 }
584 }
585 Ok(())
586 }
587
588 fn check_reachability(&self) -> Result<(), FaucetError> {
591 let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
592 let mut radj: HashMap<&str, Vec<&str>> = HashMap::new();
593 for e in &self.edges {
594 adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
595 radj.entry(e.to.as_str()).or_default().push(e.from.as_str());
596 }
597 let sink_ids: HashSet<&str> = self
598 .nodes
599 .iter()
600 .filter(|n| n.kind.is_sink())
601 .map(|n| n.id.as_str())
602 .collect();
603 let source_ids: HashSet<&str> = self
604 .nodes
605 .iter()
606 .filter(|n| n.kind.is_source())
607 .map(|n| n.id.as_str())
608 .collect();
609
610 for src in &source_ids {
611 if !reaches_any(src, &adj, &sink_ids) {
612 return Err(cfg(format!("source '{src}' does not reach any sink node")));
613 }
614 }
615 for sink in &sink_ids {
616 if !reaches_any(sink, &radj, &source_ids) {
617 return Err(cfg(format!(
618 "sink '{sink}' is not reachable from any source node"
619 )));
620 }
621 }
622 Ok(())
623 }
624
625 pub async fn run(self, opts: TopologyOptions) -> Result<TopologyResult, FaucetError> {
630 self.run_with(opts, TopologyGovernance::default()).await
631 }
632
633 pub async fn run_with(
640 self,
641 opts: TopologyOptions,
642 governance: TopologyGovernance,
643 ) -> Result<TopologyResult, FaucetError> {
644 self.run_reported(opts, governance).await.map(|r| r.result)
645 }
646
647 pub async fn run_reported(
654 self,
655 opts: TopologyOptions,
656 governance: TopologyGovernance,
657 ) -> Result<TopologyRun, FaucetError> {
658 self.validate()?;
659 let Topology { nodes, edges } = self;
660
661 let tee_cap: HashMap<&str, usize> = nodes
664 .iter()
665 .filter_map(|n| match &n.kind {
666 NodeKind::Tee { capacity, .. } => Some((n.id.as_str(), *capacity)),
667 _ => None,
668 })
669 .collect();
670
671 let sink_ids: Vec<String> = nodes
674 .iter()
675 .filter(|n| n.kind.is_sink())
676 .map(|n| n.id.clone())
677 .collect();
678 let source_count = nodes.iter().filter(|n| n.kind.is_source()).count();
679 let source_replay = if source_count == 1 {
683 nodes.iter().find_map(|n| match &n.kind {
684 NodeKind::Source(src) => Some(src.replay_guarantee()),
685 _ => None,
686 })
687 } else {
688 None
689 };
690 let start_bookmark =
691 compute_start_bookmark(&opts, &sink_ids, source_count, governance.delivery).await;
692
693 let mut outs: HashMap<String, Vec<mpsc::Sender<StreamPage>>> = HashMap::new();
695 let mut ins: HashMap<String, Vec<InEdge>> = HashMap::new();
696 for e in &edges {
697 let cap = tee_cap
698 .get(e.from.as_str())
699 .copied()
700 .unwrap_or(opts.default_channel_capacity)
701 .max(1);
702 let (tx, rx) = mpsc::channel(cap);
703 outs.entry(e.from.clone()).or_default().push(tx);
704 ins.entry(e.to.clone()).or_default().push(InEdge {
705 label: e.label.clone(),
706 rx,
707 });
708 }
709
710 type NodeFut = Pin<Box<dyn Future<Output = Result<NodeOutcome, FaucetError>> + Send>>;
713 let mut futs: Vec<NodeFut> = Vec::with_capacity(nodes.len());
714 let mut order: Vec<(String, &'static str)> = Vec::with_capacity(nodes.len());
716
717 for node in nodes {
718 let node_outs = outs.remove(&node.id).unwrap_or_default();
719 let mut node_ins = ins.remove(&node.id).unwrap_or_default();
720 let pipeline = opts.pipeline_name.clone();
721 let cancel = opts.cancel.clone();
722 let Node { id, kind } = node;
723 order.push((id.clone(), kind.kind_str()));
724
725 let fut: NodeFut = match kind {
726 NodeKind::Source(source) => {
727 let sb = start_bookmark.clone();
728 let bs = opts.batch_size;
729 Box::pin(run_source_node(id, source, sb, bs, node_outs, cancel))
730 }
731 NodeKind::Transform(stages) => {
732 let rx = take_single(node_ins)
733 .ok_or_else(|| cfg(format!("transform '{id}' has no input edge")))?;
734 let labels = Labels::new(pipeline.clone(), id.clone(), opts.run_id.clone());
735 Box::pin(run_transform_node(stages, labels, rx, node_outs, cancel))
736 }
737 NodeKind::Tee { .. } => {
738 let rx = take_single(node_ins)
739 .ok_or_else(|| cfg(format!("tee '{id}' has no input edge")))?;
740 Box::pin(run_tee_node(id, pipeline, rx, node_outs, cancel))
741 }
742 NodeKind::Merge => {
743 let rxs: Vec<mpsc::Receiver<StreamPage>> =
744 node_ins.into_iter().map(|ie| ie.rx).collect();
745 Box::pin(run_merge_node(id, pipeline, rxs, node_outs, cancel))
746 }
747 NodeKind::Join(j) => {
748 let build_rx = take_by_label(&mut node_ins, &j.build_edge);
749 let probe_rx = take_by_label(&mut node_ins, &j.probe_edge);
750 match (build_rx, probe_rx) {
751 (Some(b), Some(p)) => {
752 Box::pin(run_join_node(id, pipeline, j, b, p, node_outs, cancel))
753 }
754 _ => {
755 return Err(cfg(format!(
756 "join '{id}' is missing its build/probe input edges"
757 )));
758 }
759 }
760 }
761 NodeKind::Sink(sink) => {
762 let rx = take_single(node_ins)
763 .ok_or_else(|| cfg(format!("sink '{id}' has no input edge")))?;
764 let sopts = SinkNodeOpts {
765 pipeline_name: pipeline,
766 run_id: opts.run_id.clone(),
767 state_store: opts.state_store.clone(),
768 dlq: opts.dlq.clone(),
769 cancel: cancel.clone(),
770 #[cfg(feature = "masking")]
774 masking: governance.masking_by_sink.get(&id).cloned(),
775 #[cfg(feature = "quality")]
776 quality: governance.quality.clone(),
777 #[cfg(feature = "contract")]
778 contract: governance.contract.clone(),
779 schema_drift: governance.schema_drift,
780 resilience: governance.resilience.clone(),
781 delivery: governance.delivery,
782 replay: source_replay,
783 };
784 Box::pin(run_sink_node(id, sink, rx, sopts))
785 }
786 };
787 futs.push(fut);
788 }
789
790 drop(outs);
792 drop(ins);
793
794 let handles: Vec<tokio::task::JoinHandle<Result<NodeOutcome, FaucetError>>> =
801 futs.into_iter().map(tokio::spawn).collect();
802 let aborts: Vec<tokio::task::AbortHandle> =
805 handles.iter().map(|h| h.abort_handle()).collect();
806 let abort_all = || {
807 for a in &aborts {
808 a.abort();
809 }
810 };
811 let joined = handles.into_iter().map(|h| async move {
812 match h.await {
813 Ok(r) => r,
814 Err(e) if e.is_panic() => {
815 Err(FaucetError::Source(format!("topology node panicked: {e}")))
816 }
817 Err(e) => Err(FaucetError::Source(format!("topology node aborted: {e}"))),
818 }
819 });
820
821 match opts.on_error {
822 TopologyOnError::Propagate => {
823 let coop = opts.cancel.clone().unwrap_or_default().child_token();
833 let first_err: Arc<std::sync::Mutex<Option<FaucetError>>> =
834 Arc::new(std::sync::Mutex::new(None));
835 let failures: Arc<std::sync::Mutex<Vec<(String, String)>>> =
836 Arc::new(std::sync::Mutex::new(Vec::new()));
837 let wrapped = joined.zip(order.clone()).map(|(f, (node_id, _))| {
838 let coop = coop.clone();
839 let slot = Arc::clone(&first_err);
840 let failed = Arc::clone(&failures);
841 async move {
842 match f.await {
843 Ok(o) => Some(o),
844 Err(e) => {
845 failed
846 .lock()
847 .unwrap_or_else(|p| p.into_inner())
848 .push((node_id, e.to_string()));
849 tracing::error!(
850 error = %e,
851 "topology node failed; cancelling siblings so they flush"
852 );
853 let mut guard = slot.lock().unwrap_or_else(|p| p.into_inner());
854 if guard.is_none() {
855 *guard = Some(e);
856 }
857 coop.cancel();
858 None
859 }
860 }
861 }
862 });
863 let all = futures::future::join_all(wrapped);
866 let grace = STOP_FLUSH_GRACE;
867 let deadline = {
868 let coop = coop.clone();
869 async move {
870 coop.cancelled().await;
871 tokio::time::sleep(grace).await;
872 }
873 };
874 let outcomes = tokio::select! {
875 biased;
876 v = all => v,
877 () = deadline => {
878 tracing::warn!(
879 grace_secs = grace.as_secs(),
880 "topology: nodes did not stop within the flush grace after a failure; \
881 aborting them"
882 );
883 abort_all();
884 Vec::new()
885 }
886 };
887 if let Some(e) = first_err.lock().unwrap_or_else(|p| p.into_inner()).take() {
888 return Err(e);
889 }
890 let (result, per_source) = aggregate(outcomes.into_iter().flatten().collect());
891 let failed = failures.lock().unwrap_or_else(|p| p.into_inner()).clone();
892 let nodes = reports(&order, &result, &per_source, &failed);
893 Ok(TopologyRun {
894 result,
895 nodes,
896 per_source,
897 })
898 }
899 TopologyOnError::Continue => {
900 let results = futures::future::join_all(joined).await;
901 let mut ok = Vec::new();
902 let mut errs = Vec::new();
903 let mut failed: Vec<(String, String)> = Vec::new();
904 for (r, (node_id, _)) in results.into_iter().zip(order.clone()) {
905 match r {
906 Ok(o) => ok.push(o),
907 Err(e) => {
908 tracing::error!(
909 node = %node_id,
910 error = %e,
911 "topology node failed (on_error: continue)"
912 );
913 errs.push(e.to_string());
914 failed.push((node_id, e.to_string()));
915 }
916 }
917 }
918 let (mut result, per_source) = aggregate(ok);
919 result.errors = errs;
920 let nodes = reports(&order, &result, &per_source, &failed);
921 Ok(TopologyRun {
922 result,
923 nodes,
924 per_source,
925 })
926 }
927 }
928 }
929}
930
931fn reports(
933 order: &[(String, &'static str)],
934 sinks: &TopologyResult,
935 per_source: &HashMap<String, usize>,
936 errors: &[(String, String)],
937) -> Vec<NodeReport> {
938 order
939 .iter()
940 .map(|(id, kind)| NodeReport {
941 node_id: id.clone(),
942 kind,
943 records: sinks
944 .per_sink
945 .get(id)
946 .or_else(|| per_source.get(id))
947 .copied()
948 .unwrap_or(0),
949 bookmark: sinks.bookmarks.get(id).cloned().flatten(),
950 error: errors
951 .iter()
952 .find(|(nid, _)| nid == id)
953 .map(|(_, e)| e.clone()),
954 })
955 .collect()
956}
957
958fn aggregate(outcomes: Vec<NodeOutcome>) -> (TopologyResult, HashMap<String, usize>) {
961 let mut result = TopologyResult::default();
962 let mut per_source = HashMap::new();
963 for o in outcomes {
964 match o {
965 NodeOutcome::Sink {
966 node_id,
967 records,
968 bookmark,
969 } => {
970 result.records_written += records;
971 result.per_sink.insert(node_id.clone(), records);
972 result.bookmarks.insert(node_id, bookmark);
973 }
974 NodeOutcome::Source { node_id, records } => {
975 per_source.insert(node_id, records);
976 }
977 NodeOutcome::Other => {}
978 }
979 }
980 (result, per_source)
981}
982
983fn cfg(msg: impl Into<String>) -> FaucetError {
984 FaucetError::Config(format!("topology: {}", msg.into()))
985}
986
987fn arity(
988 node_id: &str,
989 kind: &str,
990 in_ok: bool,
991 out_ok: bool,
992 expected: &str,
993) -> Result<(), FaucetError> {
994 if in_ok && out_ok {
995 Ok(())
996 } else {
997 Err(cfg(format!(
998 "{kind} '{node_id}' has the wrong edge arity (expected {expected})"
999 )))
1000 }
1001}
1002
1003fn reaches_any(start: &str, adj: &HashMap<&str, Vec<&str>>, targets: &HashSet<&str>) -> bool {
1004 let mut stack = vec![start];
1005 let mut seen = HashSet::new();
1006 while let Some(n) = stack.pop() {
1007 if targets.contains(n) {
1008 return true;
1009 }
1010 if !seen.insert(n) {
1011 continue;
1012 }
1013 if let Some(ns) = adj.get(n) {
1014 stack.extend(ns.iter().copied());
1015 }
1016 }
1017 false
1018}
1019
1020async fn compute_start_bookmark(
1026 opts: &TopologyOptions,
1027 sink_ids: &[String],
1028 source_count: usize,
1029 delivery: crate::idempotency::DeliveryMode,
1030) -> Option<Value> {
1031 let store = opts.state_store.as_ref()?;
1032 if sink_ids.is_empty() {
1033 return None;
1034 }
1035 let mut values = Vec::with_capacity(sink_ids.len());
1036 for id in sink_ids {
1037 let key = format!("{}::{}", opts.pipeline_name, id);
1038 match store.get(&key).await {
1039 Ok(Some(v)) => values.push(v),
1040 _ => return None, }
1042 }
1043 if delivery == crate::idempotency::DeliveryMode::ExactlyOnce {
1044 let ranked: Vec<(u64, Option<Value>)> = values
1050 .iter()
1051 .map(|v| {
1052 let (bm, seq) = crate::idempotency::unwrap_state(v);
1053 (seq, bm)
1054 })
1055 .collect();
1056 return eo_start_bookmark(&ranked, source_count);
1057 }
1058 start_bookmark(&values, source_count)
1059}
1060
1061pub fn eo_start_bookmark(ranked: &[(u64, Option<Value>)], source_count: usize) -> Option<Value> {
1071 if ranked.is_empty() || source_count != 1 {
1072 if source_count > 1 && !ranked.is_empty() {
1073 tracing::warn!(
1074 sources = source_count,
1075 "topology: multi-source graph cannot attribute a sink bookmark to a source; \
1076 replaying every source in full"
1077 );
1078 }
1079 return None;
1080 }
1081 ranked
1082 .iter()
1083 .min_by_key(|(seq, _)| *seq)
1084 .and_then(|(_, bm)| bm.clone())
1085}
1086
1087pub fn start_bookmark(sink_bookmarks: &[Value], source_count: usize) -> Option<Value> {
1105 if sink_bookmarks.is_empty() || source_count != 1 {
1106 if source_count > 1 && !sink_bookmarks.is_empty() {
1107 tracing::warn!(
1108 sources = source_count,
1109 "topology: multi-source graph cannot attribute a sink bookmark to a source; \
1110 replaying every source in full. Make the sinks idempotent \
1111 (`write_mode: upsert`) or split the graph into one pipeline per source."
1112 );
1113 }
1114 return None;
1115 }
1116 let first = &sink_bookmarks[0];
1117 if sink_bookmarks.iter().any(|v| v != first) {
1118 tracing::warn!(
1119 "topology: sink bookmarks have diverged and resume positions are not safely \
1120 ordered; replaying the source in full so no sink is skipped past. Faster sinks \
1121 will re-see already-written pages — make them idempotent."
1122 );
1123 return None;
1124 }
1125 Some(first.clone())
1126}
1127
1128async fn broadcast(page: StreamPage, outs: &mut Vec<mpsc::Sender<StreamPage>>) -> bool {
1132 if outs.is_empty() {
1133 return false;
1134 }
1135 let last = outs.len() - 1;
1136 let mut closed: Vec<usize> = Vec::new();
1137 for (i, tx) in outs.iter().enumerate().take(last) {
1138 if tx.send(page.clone()).await.is_err() {
1139 closed.push(i);
1140 }
1141 }
1142 if outs[last].send(page).await.is_err() {
1143 closed.push(last);
1144 }
1145 for &i in closed.iter().rev() {
1146 outs.remove(i);
1147 }
1148 !outs.is_empty()
1149}
1150
1151fn cancelled(cancel: &Option<CancellationToken>) -> bool {
1152 cancel.as_ref().is_some_and(|c| c.is_cancelled())
1153}
1154
1155async fn run_source_node(
1156 node_id: String,
1157 source: Box<dyn Source>,
1158 start_bookmark: Option<Value>,
1159 batch_size: usize,
1160 mut outs: Vec<mpsc::Sender<StreamPage>>,
1161 cancel: Option<CancellationToken>,
1162) -> Result<NodeOutcome, FaucetError> {
1163 if let Some(bm) = start_bookmark {
1164 source.apply_start_bookmark(bm).await?;
1165 }
1166 let ctx = std::collections::HashMap::new();
1167 let mut pages = source.stream_pages(&ctx, batch_size);
1168 let mut records = 0usize;
1169 while let Some(item) = pages.next().await {
1170 if cancelled(&cancel) {
1171 break;
1172 }
1173 let page = item?;
1174 records += page.records.len();
1175 if !broadcast(page, &mut outs).await {
1176 break;
1177 }
1178 }
1179 Ok(NodeOutcome::Source { node_id, records })
1180}
1181
1182async fn run_transform_node(
1183 stages: Vec<CompiledStage>,
1184 labels: Labels,
1185 mut rx: mpsc::Receiver<StreamPage>,
1186 mut outs: Vec<mpsc::Sender<StreamPage>>,
1187 cancel: Option<CancellationToken>,
1188) -> Result<NodeOutcome, FaucetError> {
1189 while let Some(page) = rx.recv().await {
1190 if cancelled(&cancel) {
1191 break;
1192 }
1193 let records = instrumented_apply_stages(page.records, &stages, &labels)?;
1194 let out = StreamPage {
1195 records,
1196 bookmark: page.bookmark,
1197 };
1198 if !broadcast(out, &mut outs).await {
1199 break;
1200 }
1201 }
1202 Ok(NodeOutcome::Other)
1203}
1204
1205fn node_labels(pipeline: &str, node: &str) -> Vec<Label> {
1206 vec![
1207 Label::new("pipeline", SharedString::from(pipeline.to_string())),
1208 Label::new("node", SharedString::from(node.to_string())),
1209 ]
1210}
1211
1212async fn run_tee_node(
1213 node_id: String,
1214 pipeline: String,
1215 mut rx: mpsc::Receiver<StreamPage>,
1216 mut outs: Vec<mpsc::Sender<StreamPage>>,
1217 cancel: Option<CancellationToken>,
1218) -> Result<NodeOutcome, FaucetError> {
1219 let labels = node_labels(&pipeline, &node_id);
1220 while let Some(page) = rx.recv().await {
1221 if cancelled(&cancel) {
1222 break;
1223 }
1224 counter!("faucet_tee_records_total", labels.clone()).increment(page.records.len() as u64);
1225 if !broadcast(page, &mut outs).await {
1226 break;
1227 }
1228 }
1229 Ok(NodeOutcome::Other)
1230}
1231
1232async fn run_merge_node(
1233 node_id: String,
1234 pipeline: String,
1235 rxs: Vec<mpsc::Receiver<StreamPage>>,
1236 mut outs: Vec<mpsc::Sender<StreamPage>>,
1237 cancel: Option<CancellationToken>,
1238) -> Result<NodeOutcome, FaucetError> {
1239 let labels = node_labels(&pipeline, &node_id);
1240 let streams = rxs.into_iter().map(|mut rx| {
1241 Box::pin(async_stream::stream! {
1242 while let Some(p) = rx.recv().await {
1243 yield p;
1244 }
1245 }) as Pin<Box<dyn futures::Stream<Item = StreamPage> + Send>>
1246 });
1247 let mut sel = futures::stream::select_all(streams);
1248 while let Some(page) = sel.next().await {
1249 if cancelled(&cancel) {
1250 break;
1251 }
1252 counter!("faucet_merge_records_total", labels.clone()).increment(page.records.len() as u64);
1253 if !broadcast(page, &mut outs).await {
1254 break;
1255 }
1256 }
1257 Ok(NodeOutcome::Other)
1258}
1259
1260#[allow(clippy::too_many_arguments)]
1261async fn run_join_node(
1262 node_id: String,
1263 pipeline: String,
1264 j: JoinNode,
1265 mut build_rx: mpsc::Receiver<StreamPage>,
1266 mut probe_rx: mpsc::Receiver<StreamPage>,
1267 mut outs: Vec<mpsc::Sender<StreamPage>>,
1268 cancel: Option<CancellationToken>,
1269) -> Result<NodeOutcome, FaucetError> {
1270 let mode = j.config.mode;
1271 let mut join = HashJoin::new(j.config);
1272
1273 let build_start = std::time::Instant::now();
1275 while let Some(page) = build_rx.recv().await {
1276 if cancelled(&cancel) {
1277 return Ok(NodeOutcome::Other);
1278 }
1279 join.add_build_page(page.records)?;
1280 }
1281 let labels = node_labels(&pipeline, &node_id);
1282 histogram!("faucet_join_build_duration_seconds", labels.clone())
1283 .record(build_start.elapsed().as_secs_f64());
1284
1285 while let Some(page) = probe_rx.recv().await {
1287 if cancelled(&cancel) {
1288 break;
1289 }
1290 let enriched = join.probe_page(page.records)?;
1291 let out = StreamPage {
1292 records: enriched,
1293 bookmark: page.bookmark,
1294 };
1295 if !broadcast(out, &mut outs).await {
1296 break;
1297 }
1298 }
1299
1300 emit_join_metrics(&labels, mode, join.stats());
1301 Ok(NodeOutcome::Other)
1302}
1303
1304fn emit_join_metrics(labels: &[Label], mode: JoinMode, stats: &crate::join::JoinStats) {
1305 counter!("faucet_join_build_records_total", labels.to_vec()).increment(stats.build_records);
1306 counter!("faucet_join_build_nulls_total", labels.to_vec()).increment(stats.build_nulls);
1307 counter!("faucet_join_duplicates_total", labels.to_vec()).increment(stats.duplicates);
1308 counter!("faucet_join_probe_records_total", labels.to_vec()).increment(stats.probe_records);
1309 counter!("faucet_join_project_misses_total", labels.to_vec()).increment(stats.project_misses);
1310 let mut match_labels = labels.to_vec();
1311 match_labels.push(Label::new("kind", SharedString::from(mode.to_string())));
1312 counter!("faucet_join_matches_total", match_labels.clone()).increment(stats.matches);
1313 counter!("faucet_join_misses_total", match_labels).increment(stats.misses);
1314}
1315
1316struct SinkNodeOpts {
1317 pipeline_name: String,
1318 run_id: String,
1319 state_store: Option<Arc<dyn StateStore>>,
1320 dlq: Option<DlqConfig>,
1321 cancel: Option<CancellationToken>,
1322 #[cfg(feature = "masking")]
1324 masking: Option<Arc<crate::masking::CompiledMasking>>,
1325 #[cfg(feature = "quality")]
1326 quality: Option<Arc<crate::quality::CompiledQuality>>,
1327 #[cfg(feature = "contract")]
1328 contract: Option<Arc<crate::contract::CompiledContract>>,
1329 schema_drift: Option<crate::drift::SchemaDriftPolicy>,
1330 resilience: Option<crate::resilience::ResiliencePolicy>,
1331 delivery: crate::idempotency::DeliveryMode,
1333 replay: Option<crate::idempotency::ReplayGuarantee>,
1337}
1338
1339async fn run_sink_node(
1340 node_id: String,
1341 sink: Box<dyn Sink>,
1342 mut rx: mpsc::Receiver<StreamPage>,
1343 opts: SinkNodeOpts,
1344) -> Result<NodeOutcome, FaucetError> {
1345 let pages = Box::pin(async_stream::stream! {
1346 while let Some(page) = rx.recv().await {
1347 yield Ok::<StreamPage, FaucetError>(page);
1348 }
1349 });
1350
1351 let mut run_opts = RunStreamOptions::new()
1352 .with_name(opts.pipeline_name.clone())
1353 .with_row(node_id.clone())
1354 .with_run_id(opts.run_id.clone());
1355 if let Some(store) = opts.state_store {
1356 let key = format!("{}::{}", opts.pipeline_name, node_id);
1357 if opts.delivery == crate::idempotency::DeliveryMode::ExactlyOnce {
1361 let seq = match store.get(&key).await {
1362 Ok(Some(prior)) => crate::idempotency::unwrap_state(&prior).1,
1363 Ok(None) => 0,
1364 Err(e) => return Err(e),
1365 };
1366 run_opts = run_opts.with_delivery(opts.delivery).with_start_seq(seq);
1367 if let Some(replay) = opts.replay {
1368 run_opts = run_opts.with_replay_guarantee(replay);
1369 }
1370 }
1371 run_opts = run_opts.with_state(store, key);
1372 }
1373 if let Some(dlq) = opts.dlq {
1374 run_opts = run_opts.with_dlq(dlq);
1375 }
1376 if let Some(cancel) = opts.cancel {
1377 run_opts = run_opts.with_cancel(cancel);
1378 }
1379 #[cfg(feature = "masking")]
1383 if let Some(m) = opts.masking {
1384 run_opts = run_opts.with_masking(m);
1385 }
1386 #[cfg(feature = "quality")]
1387 if let Some(q) = opts.quality {
1388 run_opts = run_opts.with_quality(q);
1389 }
1390 #[cfg(feature = "contract")]
1391 if let Some(c) = opts.contract {
1392 run_opts = run_opts.with_contract(c);
1393 }
1394 if let Some(d) = opts.schema_drift {
1395 run_opts.schema_drift = Some(d);
1396 }
1397 if let Some(r) = opts.resilience {
1398 run_opts.resilience = Some(r);
1399 }
1400
1401 let result = run_stream(pages, sink.as_ref(), run_opts).await?;
1402 Ok(NodeOutcome::Sink {
1403 node_id,
1404 records: result.records_written,
1405 bookmark: result.bookmark,
1406 })
1407}
1408
1409#[derive(Default)]
1413pub struct TopologyBuilder {
1414 nodes: Vec<Node>,
1415 edges: Vec<Edge>,
1416}
1417
1418impl TopologyBuilder {
1419 pub fn node(mut self, id: impl Into<String>, kind: NodeKind) -> Self {
1421 self.nodes.push(Node {
1422 id: id.into(),
1423 kind,
1424 });
1425 self
1426 }
1427
1428 pub fn source(self, id: impl Into<String>, source: Box<dyn Source>) -> Self {
1430 self.node(id, NodeKind::Source(source))
1431 }
1432
1433 pub fn transform(self, id: impl Into<String>, stages: Vec<CompiledStage>) -> Self {
1435 self.node(id, NodeKind::Transform(stages))
1436 }
1437
1438 pub fn tee(self, id: impl Into<String>, capacity: usize, fanout: Option<usize>) -> Self {
1440 self.node(id, NodeKind::Tee { capacity, fanout })
1441 }
1442
1443 pub fn merge(self, id: impl Into<String>) -> Self {
1445 self.node(id, NodeKind::Merge)
1446 }
1447
1448 pub fn join(self, id: impl Into<String>, join: JoinNode) -> Self {
1450 self.node(id, NodeKind::Join(join))
1451 }
1452
1453 pub fn sink(self, id: impl Into<String>, sink: Box<dyn Sink>) -> Self {
1455 self.node(id, NodeKind::Sink(sink))
1456 }
1457
1458 pub fn edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
1460 self.edges.push(Edge {
1461 from: from.into(),
1462 to: to.into(),
1463 label: None,
1464 });
1465 self
1466 }
1467
1468 pub fn labelled_edge(
1470 mut self,
1471 from: impl Into<String>,
1472 to: impl Into<String>,
1473 label: impl Into<String>,
1474 ) -> Self {
1475 self.edges.push(Edge {
1476 from: from.into(),
1477 to: to.into(),
1478 label: Some(label.into()),
1479 });
1480 self
1481 }
1482
1483 pub fn build(self) -> Result<Topology, FaucetError> {
1485 let t = Topology {
1486 nodes: self.nodes,
1487 edges: self.edges,
1488 };
1489 t.validate()?;
1490 Ok(t)
1491 }
1492}
1493
1494#[cfg(test)]
1495mod tests {
1496 use super::*;
1497 use crate::join::{JoinConfig, JoinMode, Projection};
1498 use crate::state::MemoryStateStore;
1499 use async_trait::async_trait;
1500 use serde_json::json;
1501 use std::sync::Mutex;
1502
1503 pub(super) struct VecSource {
1506 records: Vec<Value>,
1507 bookmark: Option<Value>,
1508 }
1509 impl VecSource {
1510 pub(super) fn boxed(records: Vec<Value>) -> Box<dyn Source> {
1511 Box::new(VecSource {
1512 records,
1513 bookmark: None,
1514 })
1515 }
1516 fn boxed_bm(records: Vec<Value>, bm: Value) -> Box<dyn Source> {
1517 Box::new(VecSource {
1518 records,
1519 bookmark: Some(bm),
1520 })
1521 }
1522 }
1523 #[async_trait]
1524 impl Source for VecSource {
1525 async fn fetch_with_context(
1526 &self,
1527 _c: &std::collections::HashMap<String, Value>,
1528 ) -> Result<Vec<Value>, FaucetError> {
1529 Ok(self.records.clone())
1530 }
1531 async fn fetch_with_context_incremental(
1532 &self,
1533 _c: &std::collections::HashMap<String, Value>,
1534 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1535 Ok((self.records.clone(), self.bookmark.clone()))
1536 }
1537 }
1538
1539 struct FailingSource;
1540 #[async_trait]
1541 impl Source for FailingSource {
1542 async fn fetch_with_context(
1543 &self,
1544 _c: &std::collections::HashMap<String, Value>,
1545 ) -> Result<Vec<Value>, FaucetError> {
1546 Err(FaucetError::Source("boom".into()))
1547 }
1548 }
1549
1550 struct RecordingSource {
1552 records: Vec<Value>,
1553 applied: Arc<Mutex<Option<Value>>>,
1554 }
1555 #[async_trait]
1556 impl Source for RecordingSource {
1557 async fn fetch_with_context(
1558 &self,
1559 _c: &std::collections::HashMap<String, Value>,
1560 ) -> Result<Vec<Value>, FaucetError> {
1561 Ok(self.records.clone())
1562 }
1563 async fn apply_start_bookmark(&self, bm: Value) -> Result<(), FaucetError> {
1564 *self.applied.lock().unwrap() = Some(bm);
1565 Ok(())
1566 }
1567 }
1568
1569 #[derive(Clone)]
1570 pub(super) struct CollectSink {
1571 store: Arc<Mutex<Vec<Value>>>,
1572 }
1573 impl CollectSink {
1574 pub(super) fn new() -> (Self, Arc<Mutex<Vec<Value>>>) {
1575 let store = Arc::new(Mutex::new(Vec::new()));
1576 (
1577 Self {
1578 store: store.clone(),
1579 },
1580 store,
1581 )
1582 }
1583 }
1584 #[async_trait]
1585 impl Sink for CollectSink {
1586 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1587 self.store.lock().unwrap().extend_from_slice(records);
1588 Ok(records.len())
1589 }
1590 }
1591
1592 pub(super) struct FailingSink;
1593 #[async_trait]
1594 impl Sink for FailingSink {
1595 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
1596 Err(FaucetError::Sink("sink boom".into()))
1597 }
1598 }
1599
1600 struct FlushTrackingSink {
1603 flushed: Arc<Mutex<bool>>,
1604 }
1605 #[async_trait]
1606 impl Sink for FlushTrackingSink {
1607 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1608 Ok(records.len())
1609 }
1610 async fn flush(&self) -> Result<(), FaucetError> {
1611 *self.flushed.lock().unwrap() = true;
1612 Ok(())
1613 }
1614 }
1615
1616 pub(super) fn recs(n: usize) -> Vec<Value> {
1617 (0..n).map(|i| json!({ "i": i })).collect()
1618 }
1619
1620 #[test]
1623 fn validate_rejects_empty() {
1624 let err = Topology {
1625 nodes: vec![],
1626 edges: vec![],
1627 }
1628 .validate()
1629 .unwrap_err();
1630 assert!(err.to_string().contains("no nodes"));
1631 }
1632
1633 #[test]
1634 fn validate_rejects_duplicate_id() {
1635 let (sink, _) = CollectSink::new();
1636 let err = Topology::builder()
1637 .source("a", VecSource::boxed(recs(1)))
1638 .sink("a", Box::new(sink))
1639 .edge("a", "a")
1640 .build()
1641 .unwrap_err();
1642 assert!(err.to_string().contains("duplicate node id"));
1643 }
1644
1645 #[test]
1646 fn validate_rejects_unknown_endpoint() {
1647 let err = Topology::builder()
1648 .source("s", VecSource::boxed(recs(1)))
1649 .edge("s", "ghost")
1650 .build()
1651 .unwrap_err();
1652 assert!(err.to_string().contains("unknown 'to' node 'ghost'"));
1653 }
1654
1655 #[test]
1656 fn validate_rejects_unknown_from_endpoint() {
1657 let (sink, _) = CollectSink::new();
1658 let err = Topology::builder()
1659 .sink("k", Box::new(sink))
1660 .edge("ghost", "k")
1661 .build()
1662 .unwrap_err();
1663 assert!(err.to_string().contains("unknown 'from' node 'ghost'"));
1664 }
1665
1666 #[test]
1667 fn validate_rejects_source_with_incoming_edge() {
1668 let (sink, _) = CollectSink::new();
1669 let err = Topology::builder()
1670 .source("s", VecSource::boxed(recs(1)))
1671 .sink("k", Box::new(sink))
1672 .edge("s", "k")
1673 .edge("k", "s") .build()
1675 .unwrap_err();
1676 assert!(err.to_string().contains("arity") || err.to_string().contains("cycle"));
1678 }
1679
1680 #[test]
1681 fn validate_rejects_tee_fanout_mismatch() {
1682 let (s1, _) = CollectSink::new();
1683 let (s2, _) = CollectSink::new();
1684 let err = Topology::builder()
1685 .source("s", VecSource::boxed(recs(1)))
1686 .tee("t", 4, Some(3))
1687 .sink("a", Box::new(s1))
1688 .sink("b", Box::new(s2))
1689 .edge("s", "t")
1690 .edge("t", "a")
1691 .edge("t", "b")
1692 .build()
1693 .unwrap_err();
1694 assert!(err.to_string().contains("fanout 3 but has 2"));
1695 }
1696
1697 #[test]
1698 fn validate_rejects_tee_with_one_output() {
1699 let (s1, _) = CollectSink::new();
1700 let err = Topology::builder()
1701 .source("s", VecSource::boxed(recs(1)))
1702 .tee("t", 4, None)
1703 .sink("a", Box::new(s1))
1704 .edge("s", "t")
1705 .edge("t", "a")
1706 .build()
1707 .unwrap_err();
1708 assert!(err.to_string().contains("tee 't'"));
1709 }
1710
1711 #[test]
1712 fn validate_rejects_merge_with_one_input() {
1713 let (s1, _) = CollectSink::new();
1714 let err = Topology::builder()
1715 .source("s", VecSource::boxed(recs(1)))
1716 .merge("m")
1717 .sink("a", Box::new(s1))
1718 .edge("s", "m")
1719 .edge("m", "a")
1720 .build()
1721 .unwrap_err();
1722 assert!(err.to_string().contains("merge 'm'"));
1723 }
1724
1725 #[test]
1726 fn validate_rejects_join_missing_label() {
1727 let (s1, _) = CollectSink::new();
1728 let jn = JoinNode {
1729 config: JoinConfig::default(),
1730 build_edge: "build".into(),
1731 probe_edge: "probe".into(),
1732 };
1733 let err = Topology::builder()
1734 .source("b", VecSource::boxed(recs(1)))
1735 .source("p", VecSource::boxed(recs(1)))
1736 .join("j", jn)
1737 .sink("a", Box::new(s1))
1738 .labelled_edge("b", "j", "build")
1739 .edge("p", "j") .edge("j", "a")
1741 .build()
1742 .unwrap_err();
1743 assert!(err.to_string().contains("labelled 'probe'"));
1744 }
1745
1746 #[test]
1747 fn validate_rejects_join_same_labels() {
1748 let (s1, _) = CollectSink::new();
1749 let jn = JoinNode {
1750 config: JoinConfig::default(),
1751 build_edge: "x".into(),
1752 probe_edge: "x".into(),
1753 };
1754 let err = Topology::builder()
1755 .source("b", VecSource::boxed(recs(1)))
1756 .source("p", VecSource::boxed(recs(1)))
1757 .join("j", jn)
1758 .sink("a", Box::new(s1))
1759 .labelled_edge("b", "j", "x")
1760 .labelled_edge("p", "j", "x")
1761 .edge("j", "a")
1762 .build()
1763 .unwrap_err();
1764 assert!(err.to_string().contains("must differ"));
1765 }
1766
1767 #[test]
1768 fn validate_rejects_cycle() {
1769 let (sink, _) = CollectSink::new();
1772 let err = Topology::builder()
1773 .source("s", VecSource::boxed(recs(1)))
1774 .merge("m")
1775 .tee("t", 4, None)
1776 .sink("k", Box::new(sink))
1777 .edge("s", "m")
1778 .edge("m", "t")
1779 .edge("t", "m")
1780 .edge("t", "k")
1781 .build()
1782 .unwrap_err();
1783 assert!(err.to_string().contains("cycle"), "{err}");
1784 }
1785
1786 #[test]
1787 fn validate_rejects_no_source() {
1788 let err = Topology::builder()
1790 .transform("t1", vec![])
1791 .transform("t2", vec![])
1792 .edge("t1", "t2")
1793 .edge("t2", "t1")
1794 .build()
1795 .unwrap_err();
1796 assert!(err.to_string().contains("no source"), "{err}");
1797 }
1798
1799 #[test]
1800 fn validate_rejects_no_sink() {
1801 let err = Topology::builder()
1803 .source("s1", VecSource::boxed(recs(1)))
1804 .source("s2", VecSource::boxed(recs(1)))
1805 .merge("m")
1806 .edge("s1", "m")
1807 .edge("s2", "m")
1808 .edge("m", "m")
1809 .build()
1810 .unwrap_err();
1811 assert!(err.to_string().contains("no sink"), "{err}");
1812 }
1813
1814 #[tokio::test]
1817 async fn simple_source_to_sink() {
1818 let (sink, store) = CollectSink::new();
1819 let topo = Topology::builder()
1820 .source("s", VecSource::boxed(recs(5)))
1821 .sink("k", Box::new(sink))
1822 .edge("s", "k")
1823 .build()
1824 .unwrap();
1825 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1826 assert_eq!(result.records_written, 5);
1827 assert_eq!(store.lock().unwrap().len(), 5);
1828 assert_eq!(result.per_sink.get("k"), Some(&5));
1829 }
1830
1831 #[tokio::test]
1832 async fn source_transform_sink() {
1833 let (sink, store) = CollectSink::new();
1834 let topo = Topology::builder()
1835 .source("s", VecSource::boxed(recs(3)))
1836 .transform("t", vec![]) .sink("k", Box::new(sink))
1838 .edge("s", "t")
1839 .edge("t", "k")
1840 .build()
1841 .unwrap();
1842 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1843 assert_eq!(result.records_written, 3);
1844 assert_eq!(store.lock().unwrap().len(), 3);
1845 }
1846
1847 #[tokio::test]
1848 async fn tee_fans_out_to_three_sinks() {
1849 let (s1, st1) = CollectSink::new();
1850 let (s2, st2) = CollectSink::new();
1851 let (s3, st3) = CollectSink::new();
1852 let topo = Topology::builder()
1853 .source("s", VecSource::boxed(recs(10)))
1854 .tee("t", 4, Some(3))
1855 .sink("a", Box::new(s1))
1856 .sink("b", Box::new(s2))
1857 .sink("c", Box::new(s3))
1858 .edge("s", "t")
1859 .edge("t", "a")
1860 .edge("t", "b")
1861 .edge("t", "c")
1862 .build()
1863 .unwrap();
1864 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1865 assert_eq!(st1.lock().unwrap().len(), 10);
1866 assert_eq!(st2.lock().unwrap().len(), 10);
1867 assert_eq!(st3.lock().unwrap().len(), 10);
1868 assert_eq!(result.records_written, 30);
1869 }
1870
1871 #[tokio::test]
1872 async fn merge_fans_in_two_sources() {
1873 let (sink, store) = CollectSink::new();
1874 let topo = Topology::builder()
1875 .source("s1", VecSource::boxed(recs(4)))
1876 .source("s2", VecSource::boxed(recs(6)))
1877 .merge("m")
1878 .sink("k", Box::new(sink))
1879 .edge("s1", "m")
1880 .edge("s2", "m")
1881 .edge("m", "k")
1882 .build()
1883 .unwrap();
1884 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1885 assert_eq!(result.records_written, 10);
1886 assert_eq!(store.lock().unwrap().len(), 10);
1887 }
1888
1889 #[tokio::test]
1890 async fn join_enriches_end_to_end() {
1891 let (sink, store) = CollectSink::new();
1892 let customers = vec![
1893 json!({"id": 1, "tier": "gold"}),
1894 json!({"id": 2, "tier": "silver"}),
1895 ];
1896 let orders = vec![
1897 json!({"order": "A", "cust": 1}),
1898 json!({"order": "B", "cust": 2}),
1899 json!({"order": "C", "cust": 99}),
1900 ];
1901 let jn = JoinNode {
1902 config: JoinConfig {
1903 mode: JoinMode::Inner,
1904 build_key: "id".into(),
1905 probe_key: "cust".into(),
1906 projections: vec![Projection {
1907 from: "tier".into(),
1908 as_: "tier".into(),
1909 }],
1910 ..Default::default()
1911 },
1912 build_edge: "customers".into(),
1913 probe_edge: "orders".into(),
1914 };
1915 let topo = Topology::builder()
1916 .source("c", VecSource::boxed(customers))
1917 .source("o", VecSource::boxed(orders))
1918 .join("j", jn)
1919 .sink("k", Box::new(sink))
1920 .labelled_edge("c", "j", "customers")
1921 .labelled_edge("o", "j", "orders")
1922 .edge("j", "k")
1923 .build()
1924 .unwrap();
1925 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1926 assert_eq!(result.records_written, 2);
1928 let written = store.lock().unwrap();
1929 assert!(
1930 written
1931 .iter()
1932 .any(|r| r["order"] == json!("A") && r["tier"] == json!("gold"))
1933 );
1934 }
1935
1936 #[tokio::test]
1937 async fn propagate_aborts_on_sink_failure() {
1938 let topo = Topology::builder()
1939 .source("s", VecSource::boxed(recs(3)))
1940 .sink("k", Box::new(FailingSink))
1941 .edge("s", "k")
1942 .build()
1943 .unwrap();
1944 let err = topo.run(TopologyOptions::new("p")).await.unwrap_err();
1945 assert!(matches!(err, FaucetError::Sink(_)));
1946 }
1947
1948 #[tokio::test]
1949 async fn propagate_aborts_on_source_failure() {
1950 let (sink, _) = CollectSink::new();
1951 let topo = Topology::builder()
1952 .source("s", Box::new(FailingSource))
1953 .sink("k", Box::new(sink))
1954 .edge("s", "k")
1955 .build()
1956 .unwrap();
1957 let err = topo.run(TopologyOptions::new("p")).await.unwrap_err();
1958 assert!(matches!(err, FaucetError::Source(_)));
1959 }
1960
1961 #[tokio::test]
1962 async fn continue_lets_healthy_branch_finish() {
1963 let (good, store) = CollectSink::new();
1965 let topo = Topology::builder()
1966 .source("s", VecSource::boxed(recs(8)))
1967 .tee("t", 8, Some(2))
1968 .sink("bad", Box::new(FailingSink))
1969 .sink("good", Box::new(good))
1970 .edge("s", "t")
1971 .edge("t", "bad")
1972 .edge("t", "good")
1973 .build()
1974 .unwrap();
1975 let opts = TopologyOptions::new("p").with_on_error(TopologyOnError::Continue);
1976 let result = topo.run(opts).await.unwrap();
1977 assert_eq!(store.lock().unwrap().len(), 8);
1978 assert!(!result.errors.is_empty(), "failing sink should be recorded");
1979 }
1980
1981 #[tokio::test]
1982 async fn state_agreeing_bookmarks_resume_the_source() {
1983 let store = Arc::new(MemoryStateStore::new());
1984 store.put("p::a", &json!(100)).await.unwrap();
1986 store.put("p::b", &json!(100)).await.unwrap();
1987 let applied = Arc::new(Mutex::new(None));
1988 let src = RecordingSource {
1989 records: recs(1),
1990 applied: applied.clone(),
1991 };
1992 let (s1, _) = CollectSink::new();
1993 let (s2, _) = CollectSink::new();
1994 let topo = Topology::builder()
1995 .source("s", Box::new(src))
1996 .tee("t", 4, Some(2))
1997 .sink("a", Box::new(s1))
1998 .sink("b", Box::new(s2))
1999 .edge("s", "t")
2000 .edge("t", "a")
2001 .edge("t", "b")
2002 .build()
2003 .unwrap();
2004 let opts = TopologyOptions::new("p").with_state_store(store.clone());
2005 topo.run(opts).await.unwrap();
2006 assert_eq!(*applied.lock().unwrap(), Some(json!(100)));
2007 }
2008
2009 #[tokio::test]
2010 async fn state_diverged_bookmarks_replay_in_full() {
2011 let store = Arc::new(MemoryStateStore::new());
2015 store.put("p::a", &json!(250)).await.unwrap();
2016 store.put("p::b", &json!(100)).await.unwrap();
2017 let applied = Arc::new(Mutex::new(None));
2018 let src = RecordingSource {
2019 records: recs(1),
2020 applied: applied.clone(),
2021 };
2022 let (s1, _) = CollectSink::new();
2023 let (s2, _) = CollectSink::new();
2024 let topo = Topology::builder()
2025 .source("s", Box::new(src))
2026 .tee("t", 4, Some(2))
2027 .sink("a", Box::new(s1))
2028 .sink("b", Box::new(s2))
2029 .edge("s", "t")
2030 .edge("t", "a")
2031 .edge("t", "b")
2032 .build()
2033 .unwrap();
2034 let opts = TopologyOptions::new("p").with_state_store(store.clone());
2035 topo.run(opts).await.unwrap();
2036 assert_eq!(
2037 *applied.lock().unwrap(),
2038 None,
2039 "diverged bookmarks must replay, never guess an order"
2040 );
2041 }
2042
2043 #[tokio::test]
2044 async fn state_multi_source_never_cross_applies_a_bookmark() {
2045 let store = Arc::new(MemoryStateStore::new());
2049 store.put("p::k", &json!(500)).await.unwrap();
2050 let a_applied = Arc::new(Mutex::new(None));
2051 let b_applied = Arc::new(Mutex::new(None));
2052 let a = RecordingSource {
2053 records: recs(1),
2054 applied: a_applied.clone(),
2055 };
2056 let b = RecordingSource {
2057 records: recs(1),
2058 applied: b_applied.clone(),
2059 };
2060 let (sink, _) = CollectSink::new();
2061 let topo = Topology::builder()
2062 .source("a", Box::new(a))
2063 .source("b", Box::new(b))
2064 .merge("m")
2065 .sink("k", Box::new(sink))
2066 .edge("a", "m")
2067 .edge("b", "m")
2068 .edge("m", "k")
2069 .build()
2070 .unwrap();
2071 let opts = TopologyOptions::new("p").with_state_store(store.clone());
2072 topo.run(opts).await.unwrap();
2073 assert_eq!(*a_applied.lock().unwrap(), None);
2074 assert_eq!(*b_applied.lock().unwrap(), None);
2075 }
2076
2077 #[tokio::test]
2078 async fn state_no_bookmark_when_a_sink_is_missing() {
2079 let store = Arc::new(MemoryStateStore::new());
2080 store.put("p::a", &json!(100)).await.unwrap();
2081 let applied = Arc::new(Mutex::new(None));
2083 let src = RecordingSource {
2084 records: recs(1),
2085 applied: applied.clone(),
2086 };
2087 let (s1, _) = CollectSink::new();
2088 let (s2, _) = CollectSink::new();
2089 let topo = Topology::builder()
2090 .source("s", Box::new(src))
2091 .tee("t", 4, Some(2))
2092 .sink("a", Box::new(s1))
2093 .sink("b", Box::new(s2))
2094 .edge("s", "t")
2095 .edge("t", "a")
2096 .edge("t", "b")
2097 .build()
2098 .unwrap();
2099 let opts = TopologyOptions::new("p").with_state_store(store.clone());
2100 topo.run(opts).await.unwrap();
2101 assert_eq!(*applied.lock().unwrap(), None);
2102 }
2103
2104 #[tokio::test]
2105 async fn sink_persists_bookmark() {
2106 let store = Arc::new(MemoryStateStore::new());
2107 let (sink, _) = CollectSink::new();
2108 let topo = Topology::builder()
2109 .source("s", VecSource::boxed_bm(recs(2), json!("v9")))
2110 .sink("k", Box::new(sink))
2111 .edge("s", "k")
2112 .build()
2113 .unwrap();
2114 let opts = TopologyOptions::new("p").with_state_store(store.clone());
2115 let result = topo.run(opts).await.unwrap();
2116 assert_eq!(result.bookmarks.get("k"), Some(&Some(json!("v9"))));
2117 assert_eq!(store.get("p::k").await.unwrap(), Some(json!("v9")));
2118 }
2119
2120 #[tokio::test]
2124 async fn propagate_lets_siblings_flush_before_returning_the_error() {
2125 let flushed = Arc::new(Mutex::new(false));
2126 let tracker = FlushTrackingSink {
2127 flushed: flushed.clone(),
2128 };
2129 let topo = Topology::builder()
2130 .source("s", VecSource::boxed(recs(64)))
2131 .tee("t", 4, Some(2))
2132 .sink("bad", Box::new(FailingSink))
2133 .sink("good", Box::new(tracker))
2134 .edge("s", "t")
2135 .edge("t", "bad")
2136 .edge("t", "good")
2137 .build()
2138 .unwrap();
2139 let opts = TopologyOptions::new("p").with_on_error(TopologyOnError::Propagate);
2140 let err = topo.run(opts).await.unwrap_err();
2141 assert!(matches!(err, FaucetError::Sink(_)), "{err:?}");
2142 assert!(
2143 *flushed.lock().unwrap(),
2144 "the healthy sink node must be flushed, not dropped mid-write"
2145 );
2146 }
2147
2148 #[cfg(feature = "masking")]
2151 #[tokio::test]
2152 async fn masking_applies_to_a_sink_node() {
2153 use crate::masking::{CompiledMasking, MaskingSpec};
2154
2155 let spec: MaskingSpec = serde_json::from_value(json!({
2156 "rules": [{
2157 "name": "hide-email",
2158 "match": { "fields": ["email"] },
2159 "action": { "type": "redact", "mask": "***" }
2160 }]
2161 }))
2162 .unwrap();
2163 let compiled = Arc::new(CompiledMasking::compile(&spec).unwrap());
2164
2165 let (sink, store) = CollectSink::new();
2166 let topo = Topology::builder()
2167 .source(
2168 "s",
2169 VecSource::boxed(vec![json!({"id": 1, "email": "a@b.c"})]),
2170 )
2171 .sink("k", Box::new(sink))
2172 .edge("s", "k")
2173 .build()
2174 .unwrap();
2175
2176 let mut governance = TopologyGovernance::new();
2177 governance.masking_by_sink.insert("k".to_string(), compiled);
2178 topo.run_with(TopologyOptions::new("p"), governance)
2179 .await
2180 .unwrap();
2181
2182 let written = store.lock().unwrap();
2183 assert_eq!(written.len(), 1);
2184 assert_eq!(written[0]["email"], json!("***"), "PII must be masked");
2185 assert_eq!(written[0]["id"], json!(1));
2186 }
2187
2188 #[tokio::test]
2189 async fn cancellation_stops_the_run() {
2190 let cancel = CancellationToken::new();
2191 cancel.cancel(); let (sink, store) = CollectSink::new();
2193 let topo = Topology::builder()
2194 .source("s", VecSource::boxed(recs(1000)))
2195 .sink("k", Box::new(sink))
2196 .edge("s", "k")
2197 .build()
2198 .unwrap();
2199 let opts = TopologyOptions::new("p").with_cancel(cancel);
2200 let result = topo.run(opts).await.unwrap();
2201 assert!(store.lock().unwrap().len() < 1000);
2203 let _ = result;
2204 }
2205
2206 #[test]
2207 fn start_bookmark_only_resumes_when_provably_safe() {
2208 assert_eq!(
2210 start_bookmark(&[json!(100), json!(100)], 1),
2211 Some(json!(100))
2212 );
2213 let lsn = json!({"slot": "s", "lsn": "0/16B3748"});
2215 assert_eq!(
2216 start_bookmark(&[lsn.clone(), lsn.clone()], 1),
2217 Some(lsn.clone())
2218 );
2219
2220 assert_eq!(start_bookmark(&[json!(250), json!(100)], 1), None);
2224 assert_eq!(
2227 start_bookmark(
2228 &[json!({"lsn": "0/9FFFFFF"}), json!({"lsn": "0/10000000"}),],
2229 1
2230 ),
2231 None
2232 );
2233
2234 assert_eq!(start_bookmark(&[json!(100), json!(100)], 2), None);
2236 assert_eq!(start_bookmark(&[], 1), None);
2238 assert_eq!(start_bookmark(&[], 3), None);
2239 }
2240
2241 #[test]
2242 fn kind_str_matches() {
2243 assert_eq!(NodeKind::Merge.kind_str(), "merge");
2244 assert_eq!(
2245 NodeKind::Tee {
2246 capacity: 1,
2247 fanout: None
2248 }
2249 .kind_str(),
2250 "tee"
2251 );
2252 }
2253
2254 #[test]
2255 fn builder_exposes_nodes_and_edges() {
2256 let (sink, _) = CollectSink::new();
2257 let topo = Topology::builder()
2258 .source("s", VecSource::boxed(recs(1)))
2259 .sink("k", Box::new(sink))
2260 .edge("s", "k")
2261 .build()
2262 .unwrap();
2263 assert_eq!(topo.nodes().len(), 2);
2264 assert_eq!(topo.edges().len(), 1);
2265 }
2266
2267 #[cfg(feature = "transform-keys-case")]
2268 #[tokio::test]
2269 async fn transform_node_applies_stage() {
2270 use crate::stage::{TransformStage, compile_stage};
2271 use crate::transform::{KeyCaseMode, RecordTransform};
2272 let stage = compile_stage(&TransformStage::Map(RecordTransform::KeysCase {
2273 mode: KeyCaseMode::Snake,
2274 }))
2275 .unwrap();
2276 let (sink, store) = CollectSink::new();
2277 let topo = Topology::builder()
2278 .source("s", VecSource::boxed(vec![json!({"FooBar": 1})]))
2279 .transform("t", vec![stage])
2280 .sink("k", Box::new(sink))
2281 .edge("s", "t")
2282 .edge("t", "k")
2283 .build()
2284 .unwrap();
2285 topo.run(TopologyOptions::new("p")).await.unwrap();
2286 let w = store.lock().unwrap();
2287 assert!(w[0].get("foo_bar").is_some());
2288 }
2289}
2290
2291#[cfg(test)]
2292mod delivery_and_report_tests {
2293 use super::tests::{CollectSink, FailingSink, VecSource, recs};
2294 use super::*;
2295 use crate::Stream;
2296 use crate::idempotency::{DeliveryMode, format_token_with_bookmark, wrap_state};
2297 use crate::state::{MemoryStateStore, StateStore};
2298 use async_trait::async_trait;
2299 use serde_json::json;
2300 use std::sync::Mutex;
2301
2302 struct TokenSink {
2304 rows: Arc<Mutex<Vec<Value>>>,
2305 tokens: Arc<Mutex<std::collections::HashMap<String, String>>>,
2306 }
2307 #[async_trait]
2308 impl Sink for TokenSink {
2309 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2310 self.rows.lock().unwrap().extend_from_slice(records);
2311 Ok(records.len())
2312 }
2313 fn supports_idempotent_writes(&self) -> bool {
2314 true
2315 }
2316 async fn write_batch_idempotent(
2317 &self,
2318 records: &[Value],
2319 scope: &str,
2320 token: &str,
2321 ) -> Result<usize, FaucetError> {
2322 self.rows.lock().unwrap().extend_from_slice(records);
2323 self.tokens
2324 .lock()
2325 .unwrap()
2326 .insert(scope.to_string(), token.to_string());
2327 Ok(records.len())
2328 }
2329 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
2330 Ok(self.tokens.lock().unwrap().get(scope).cloned())
2331 }
2332 }
2333
2334 #[tokio::test]
2338 async fn exactly_once_commits_a_token_per_sink_node_scope() {
2339 let rows = Arc::new(Mutex::new(Vec::new()));
2340 let tokens = Arc::new(Mutex::new(std::collections::HashMap::new()));
2341 let sink = TokenSink {
2342 rows: rows.clone(),
2343 tokens: tokens.clone(),
2344 };
2345 let store = Arc::new(MemoryStateStore::new());
2346 let topo = Topology::builder()
2347 .source("s", Box::new(EoSource(recs(3))))
2348 .sink("k", Box::new(sink))
2349 .edge("s", "k")
2350 .build()
2351 .unwrap();
2352
2353 let mut gov = TopologyGovernance::new();
2354 gov.delivery = DeliveryMode::ExactlyOnce;
2355 let opts = TopologyOptions::new("p").with_state_store(store.clone());
2356 topo.run_with(opts, gov).await.unwrap();
2357
2358 assert_eq!(rows.lock().unwrap().len(), 3);
2359 let committed = tokens.lock().unwrap();
2360 assert!(
2361 committed.contains_key("p::k"),
2362 "token must be scoped to the sink node's own state key, got {:?}",
2363 committed.keys().collect::<Vec<_>>()
2364 );
2365 }
2366
2367 struct EoSource(Vec<Value>);
2370 #[async_trait]
2371 impl Source for EoSource {
2372 async fn fetch_with_context(
2373 &self,
2374 _ctx: &std::collections::HashMap<String, Value>,
2375 ) -> Result<Vec<Value>, FaucetError> {
2376 Ok(self.0.clone())
2377 }
2378 fn stream_pages<'a>(
2379 &'a self,
2380 _ctx: &'a std::collections::HashMap<String, Value>,
2381 _batch: usize,
2382 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
2383 let rows = self.0.clone();
2384 Box::pin(async_stream::try_stream! {
2385 yield StreamPage { records: rows, bookmark: Some(json!({"pos": 1})) };
2386 })
2387 }
2388 fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
2389 crate::idempotency::ReplayGuarantee::Deterministic
2390 }
2391 fn supports_exactly_once(&self) -> bool {
2392 true
2393 }
2394 }
2395
2396 #[test]
2399 fn eo_resume_picks_the_lowest_sequence() {
2400 let a = (7u64, Some(json!({"pos": 7})));
2401 let b = (4u64, Some(json!({"pos": 4})));
2402 let c = (9u64, Some(json!({"pos": 9})));
2403 assert_eq!(
2404 eo_start_bookmark(&[a.clone(), b.clone(), c.clone()], 1),
2405 Some(json!({"pos": 4})),
2406 "resume from the laggard, not the leader"
2407 );
2408 assert_eq!(eo_start_bookmark(&[a, b, c], 2), None);
2410 assert_eq!(eo_start_bookmark(&[], 1), None);
2411 }
2412
2413 #[tokio::test]
2416 async fn eo_resume_unwraps_the_state_envelope() {
2417 let store = Arc::new(MemoryStateStore::new());
2418 store
2419 .put("p::k", &wrap_state(Some(&json!({"pos": 5})), 5))
2420 .await
2421 .unwrap();
2422 let opts = TopologyOptions::new("p").with_state_store(store.clone());
2423 let bm =
2424 compute_start_bookmark(&opts, &["k".to_string()], 1, DeliveryMode::ExactlyOnce).await;
2425 assert_eq!(bm, Some(json!({"pos": 5})), "envelope must be unwrapped");
2426 let t = format_token_with_bookmark(5, Some(&json!({"pos": 5})));
2428 assert!(t.contains('#'), "token embeds the bookmark: {t}");
2429 }
2430
2431 #[tokio::test]
2433 async fn run_reported_attributes_failures_to_their_node() {
2434 let (good, _) = CollectSink::new();
2435 let topo = Topology::builder()
2436 .source("s", VecSource::boxed(recs(4)))
2437 .tee("t", 4, Some(2))
2438 .sink("bad", Box::new(FailingSink))
2439 .sink("good", Box::new(good))
2440 .edge("s", "t")
2441 .edge("t", "bad")
2442 .edge("t", "good")
2443 .build()
2444 .unwrap();
2445 let run = topo
2446 .run_reported(
2447 TopologyOptions::new("p").with_on_error(TopologyOnError::Continue),
2448 TopologyGovernance::new(),
2449 )
2450 .await
2451 .unwrap();
2452
2453 let bad = run.nodes.iter().find(|n| n.node_id == "bad").unwrap();
2454 assert!(bad.error.is_some(), "the failing sink is attributed");
2455 let good = run.nodes.iter().find(|n| n.node_id == "good").unwrap();
2456 assert!(good.error.is_none(), "the healthy sink is not");
2457 assert_eq!(good.records, 4);
2458 assert_eq!(run.nodes.len(), 4);
2460 assert!(run.nodes.iter().any(|n| n.kind == "source"));
2461 assert!(run.nodes.iter().any(|n| n.kind == "tee"));
2462 }
2463}