1use crate::dlq::DlqConfig;
83use crate::error::FaucetError;
84use crate::join::HashJoin;
85use crate::observability::{Labels, RunStreamOptions, instrumented_apply_stages};
86use crate::pipeline::{DEFAULT_BATCH_SIZE, StreamPage, run_stream};
87use crate::stage::CompiledStage;
88use crate::state::StateStore;
89use crate::traits::{Sink, Source};
90use futures::StreamExt;
91use metrics::{Label, SharedString, counter, histogram};
92use serde_json::Value;
93use std::collections::{HashMap, HashSet};
94use std::future::Future;
95use std::pin::Pin;
96use std::sync::Arc;
97use std::time::Duration;
98use tokio::sync::mpsc;
99use tokio_util::sync::CancellationToken;
100
101pub use crate::join::{JoinConfig, JoinMode, KeyNormalize, OnCollision, OnDuplicate, Projection};
102
103pub const DEFAULT_CHANNEL_CAPACITY: usize = 4;
105
106#[derive(Debug, Clone)]
110pub struct JoinNode {
111 pub config: JoinConfig,
113 pub build_edge: String,
115 pub probe_edge: String,
117}
118
119pub enum NodeKind {
121 Source(Box<dyn Source>),
123 Transform(Vec<CompiledStage>),
125 Tee {
127 capacity: usize,
129 fanout: Option<usize>,
131 },
132 Merge,
134 Join(JoinNode),
136 Sink(Box<dyn Sink>),
138}
139
140impl std::fmt::Debug for NodeKind {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 f.write_str(self.kind_str())
143 }
144}
145
146impl NodeKind {
147 pub fn kind_str(&self) -> &'static str {
149 match self {
150 NodeKind::Source(_) => "source",
151 NodeKind::Transform(_) => "transform",
152 NodeKind::Tee { .. } => "tee",
153 NodeKind::Merge => "merge",
154 NodeKind::Join(_) => "join",
155 NodeKind::Sink(_) => "sink",
156 }
157 }
158
159 fn is_source(&self) -> bool {
160 matches!(self, NodeKind::Source(_))
161 }
162
163 fn is_sink(&self) -> bool {
164 matches!(self, NodeKind::Sink(_))
165 }
166}
167
168#[derive(Debug)]
170pub struct Node {
171 pub id: String,
173 pub kind: NodeKind,
175}
176
177#[derive(Debug, Clone)]
179pub struct Edge {
180 pub from: String,
182 pub to: String,
184 pub label: Option<String>,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
191pub enum TopologyOnError {
192 #[default]
194 Propagate,
195 Continue,
198}
199
200#[derive(Clone, Default)]
213#[non_exhaustive]
214pub struct TopologyGovernance {
215 #[cfg(feature = "quality")]
217 pub quality: Option<Arc<crate::quality::CompiledQuality>>,
218 #[cfg(feature = "contract")]
220 pub contract: Option<Arc<crate::contract::CompiledContract>>,
221 #[cfg(feature = "masking")]
224 pub masking_by_sink: HashMap<String, Arc<crate::masking::CompiledMasking>>,
225 pub schema_drift: Option<crate::drift::SchemaDriftPolicy>,
227 pub resilience: Option<crate::resilience::ResiliencePolicy>,
230 pub delivery: crate::idempotency::DeliveryMode,
243}
244
245impl TopologyGovernance {
246 pub fn new() -> Self {
248 Self::default()
249 }
250}
251
252#[derive(Clone)]
254pub struct TopologyOptions {
255 pub pipeline_name: String,
257 pub run_id: String,
259 pub batch_size: usize,
261 pub state_store: Option<Arc<dyn StateStore>>,
263 pub dlq: Option<DlqConfig>,
265 pub cancel: Option<CancellationToken>,
267 pub on_error: TopologyOnError,
269 pub default_channel_capacity: usize,
271}
272
273pub const STOP_FLUSH_GRACE: Duration = Duration::from_secs(30);
281
282impl Default for TopologyOptions {
283 fn default() -> Self {
284 Self {
285 pipeline_name: "unnamed".into(),
286 run_id: String::new(),
287 batch_size: DEFAULT_BATCH_SIZE,
288 state_store: None,
289 dlq: None,
290 cancel: None,
291 on_error: TopologyOnError::default(),
292 default_channel_capacity: DEFAULT_CHANNEL_CAPACITY,
293 }
294 }
295}
296
297impl TopologyOptions {
298 pub fn new(pipeline_name: impl Into<String>) -> Self {
300 Self {
301 pipeline_name: pipeline_name.into(),
302 ..Default::default()
303 }
304 }
305
306 pub fn with_state_store(mut self, store: Arc<dyn StateStore>) -> Self {
308 self.state_store = Some(store);
309 self
310 }
311
312 pub fn with_dlq(mut self, dlq: DlqConfig) -> Self {
314 self.dlq = Some(dlq);
315 self
316 }
317
318 pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
320 self.cancel = Some(cancel);
321 self
322 }
323
324 pub fn with_on_error(mut self, on_error: TopologyOnError) -> Self {
326 self.on_error = on_error;
327 self
328 }
329
330 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
332 self.batch_size = batch_size;
333 self
334 }
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343#[non_exhaustive]
344pub enum NodeErrorKind {
345 CircuitOpen,
349 Other,
352}
353
354impl NodeErrorKind {
355 pub fn classify(error: &FaucetError) -> Self {
360 match error {
361 FaucetError::CircuitOpen { .. } => Self::CircuitOpen,
362 _ => Self::Other,
363 }
364 }
365}
366
367#[derive(Debug, Clone)]
371#[non_exhaustive]
372pub struct NodeReport {
373 pub node_id: String,
375 pub kind: &'static str,
377 pub records: usize,
379 pub bookmark: Option<Value>,
381 pub error: Option<String>,
383 pub error_kind: Option<NodeErrorKind>,
389}
390
391#[derive(Debug, Clone, Default)]
397#[non_exhaustive]
398pub struct TopologyRun {
399 pub result: TopologyResult,
401 pub nodes: Vec<NodeReport>,
403 pub per_source: HashMap<String, usize>,
406}
407
408#[derive(Debug, Clone, Default)]
410pub struct TopologyResult {
411 pub records_written: usize,
413 pub per_sink: HashMap<String, usize>,
415 pub bookmarks: HashMap<String, Option<Value>>,
417 pub errors: Vec<String>,
420}
421
422struct InEdge {
425 label: Option<String>,
426 from: String,
427 rx: mpsc::Receiver<StreamPage>,
428}
429
430fn take_single(mut ins: Vec<InEdge>) -> Option<mpsc::Receiver<StreamPage>> {
432 ins.drain(..).next().map(|ie| ie.rx)
433}
434
435fn take_by_label(ins: &mut Vec<InEdge>, label: &str) -> Option<mpsc::Receiver<StreamPage>> {
437 ins.iter()
438 .position(|ie| ie.label.as_deref() == Some(label))
439 .map(|pos| ins.remove(pos).rx)
440}
441
442enum NodeOutcome {
444 Sink {
445 node_id: String,
446 records: usize,
447 bookmark: Option<Value>,
448 sink: Box<dyn Sink>,
450 deferred: Option<DeferredState>,
453 },
454 Source {
458 node_id: String,
459 records: usize,
460 },
461 Other,
462}
463
464#[derive(Debug)]
468pub struct Topology {
469 nodes: Vec<Node>,
470 edges: Vec<Edge>,
471}
472
473impl Topology {
474 pub fn builder() -> TopologyBuilder {
476 TopologyBuilder::default()
477 }
478
479 pub fn nodes(&self) -> &[Node] {
481 &self.nodes
482 }
483
484 pub fn edges(&self) -> &[Edge] {
486 &self.edges
487 }
488
489 pub fn validate(&self) -> Result<(), FaucetError> {
494 if self.nodes.is_empty() {
495 return Err(cfg("topology has no nodes"));
496 }
497
498 let mut seen = HashSet::new();
500 for n in &self.nodes {
501 if !seen.insert(n.id.as_str()) {
502 return Err(cfg(format!("duplicate node id '{}'", n.id)));
503 }
504 }
505 let ids: HashSet<&str> = seen;
506
507 for e in &self.edges {
509 if !ids.contains(e.from.as_str()) {
510 return Err(cfg(format!(
511 "edge references unknown 'from' node '{}'",
512 e.from
513 )));
514 }
515 if !ids.contains(e.to.as_str()) {
516 return Err(cfg(format!("edge references unknown 'to' node '{}'", e.to)));
517 }
518 }
519
520 let mut in_deg: HashMap<&str, usize> = HashMap::new();
522 let mut out_deg: HashMap<&str, usize> = HashMap::new();
523 for e in &self.edges {
524 *out_deg.entry(e.from.as_str()).or_default() += 1;
525 *in_deg.entry(e.to.as_str()).or_default() += 1;
526 }
527
528 let mut has_source = false;
529 let mut has_sink = false;
530 for n in &self.nodes {
531 let i = in_deg.get(n.id.as_str()).copied().unwrap_or(0);
532 let o = out_deg.get(n.id.as_str()).copied().unwrap_or(0);
533 match &n.kind {
534 NodeKind::Source(source) => {
535 has_source = true;
536 arity(&n.id, "source", i == 0, o == 1, "0 in, exactly 1 out")?;
537 if source.consumes_destructively() {
538 return Err(cfg(format!(
539 "source '{}' ({}) acknowledges messages as it is read past each \
540 page, and a graph polls its sources ahead of its sinks, so a \
541 failed or cancelled sink would lose messages that were already \
542 acked; run this source in a linear pipeline instead",
543 n.id,
544 source.connector_name()
545 )));
546 }
547 }
548 NodeKind::Transform(_) => {
549 arity(&n.id, "transform", i == 1, o == 1, "exactly 1 in, 1 out")?;
550 }
551 NodeKind::Tee { fanout, .. } => {
552 arity(&n.id, "tee", i == 1, o >= 2, "exactly 1 in, 2+ out")?;
553 if let Some(f) = fanout
554 && *f != o
555 {
556 return Err(cfg(format!(
557 "tee '{}' declares fanout {f} but has {o} outgoing edges",
558 n.id
559 )));
560 }
561 }
562 NodeKind::Merge => {
563 arity(&n.id, "merge", i >= 2, o == 1, "2+ in, exactly 1 out")?;
564 }
565 NodeKind::Join(j) => {
566 arity(&n.id, "join", i == 2, o == 1, "exactly 2 in, 1 out")?;
567 self.validate_join_edges(&n.id, j)?;
568 }
569 NodeKind::Sink(_) => {
570 has_sink = true;
571 arity(&n.id, "sink", i == 1, o == 0, "exactly 1 in, 0 out")?;
572 }
573 }
574 }
575
576 if !has_source {
577 return Err(cfg("topology has no source node"));
578 }
579 if !has_sink {
580 return Err(cfg("topology has no sink node"));
581 }
582
583 self.detect_cycle()?;
584 self.check_reachability()?;
585 self.check_join_inputs()?;
586 Ok(())
587 }
588
589 fn check_join_inputs(&self) -> Result<(), FaucetError> {
594 let mut rev: HashMap<&str, Vec<&Edge>> = HashMap::new();
595 for e in &self.edges {
596 rev.entry(e.to.as_str()).or_default().push(e);
597 }
598 for n in &self.nodes {
599 let NodeKind::Join(j) = &n.kind else {
600 continue;
601 };
602 let side = |label: &str| -> Option<HashSet<String>> {
603 let from = rev
604 .get(n.id.as_str())?
605 .iter()
606 .find(|e| e.label.as_deref() == Some(label))?
607 .from
608 .as_str();
609 let mut set = ancestors_of(from, &rev);
610 set.insert(from.to_string());
611 Some(set)
612 };
613 let (Some(build), Some(probe)) = (side(&j.build_edge), side(&j.probe_edge)) else {
614 continue;
615 };
616 let mut shared: Vec<&String> = build.intersection(&probe).collect();
617 shared.sort();
618 if let Some(node) = shared.first() {
619 return Err(cfg(format!(
620 "join '{}' reads both its build and probe sides from node '{node}'; the \
621 join drains its build side before reading the probe side, so a shared \
622 upstream blocks once the probe channel fills and the run never ends. \
623 Feed the two sides from separate source nodes",
624 n.id
625 )));
626 }
627 }
628 Ok(())
629 }
630
631 fn validate_join_edges(&self, node_id: &str, j: &JoinNode) -> Result<(), FaucetError> {
632 let labels: Vec<&str> = self
633 .edges
634 .iter()
635 .filter(|e| e.to == node_id)
636 .filter_map(|e| e.label.as_deref())
637 .collect();
638 for want in [j.build_edge.as_str(), j.probe_edge.as_str()] {
639 if !labels.contains(&want) {
640 return Err(cfg(format!(
641 "join '{node_id}' has no incoming edge labelled '{want}' (known labels: {labels:?})"
642 )));
643 }
644 }
645 if j.build_edge == j.probe_edge {
646 return Err(cfg(format!(
647 "join '{node_id}' build_edge and probe_edge must differ"
648 )));
649 }
650 Ok(())
651 }
652
653 fn detect_cycle(&self) -> Result<(), FaucetError> {
655 let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
656 for e in &self.edges {
657 adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
658 }
659 #[derive(Clone, Copy, PartialEq)]
660 enum Color {
661 White,
662 Gray,
663 Black,
664 }
665 let mut color: HashMap<&str, Color> = self
666 .nodes
667 .iter()
668 .map(|n| (n.id.as_str(), Color::White))
669 .collect();
670
671 for start in self.nodes.iter().map(|n| n.id.as_str()) {
673 if color[start] != Color::White {
674 continue;
675 }
676 let mut stack: Vec<(&str, usize)> = vec![(start, 0)];
677 *color.get_mut(start).unwrap() = Color::Gray;
678 while let Some((node, idx)) = stack.last().copied() {
679 let neighbours = adj.get(node).map(|v| v.as_slice()).unwrap_or(&[]);
680 if idx < neighbours.len() {
681 stack.last_mut().unwrap().1 += 1;
682 let next = neighbours[idx];
683 match color[next] {
684 Color::Gray => {
685 return Err(cfg(format!("topology has a cycle through node '{next}'")));
686 }
687 Color::White => {
688 *color.get_mut(next).unwrap() = Color::Gray;
689 stack.push((next, 0));
690 }
691 Color::Black => {}
692 }
693 } else {
694 *color.get_mut(node).unwrap() = Color::Black;
695 stack.pop();
696 }
697 }
698 }
699 Ok(())
700 }
701
702 fn check_reachability(&self) -> Result<(), FaucetError> {
705 let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
706 let mut radj: HashMap<&str, Vec<&str>> = HashMap::new();
707 for e in &self.edges {
708 adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
709 radj.entry(e.to.as_str()).or_default().push(e.from.as_str());
710 }
711 let sink_ids: HashSet<&str> = self
712 .nodes
713 .iter()
714 .filter(|n| n.kind.is_sink())
715 .map(|n| n.id.as_str())
716 .collect();
717 let source_ids: HashSet<&str> = self
718 .nodes
719 .iter()
720 .filter(|n| n.kind.is_source())
721 .map(|n| n.id.as_str())
722 .collect();
723
724 for src in &source_ids {
725 if !reaches_any(src, &adj, &sink_ids) {
726 return Err(cfg(format!("source '{src}' does not reach any sink node")));
727 }
728 }
729 for sink in &sink_ids {
730 if !reaches_any(sink, &radj, &source_ids) {
731 return Err(cfg(format!(
732 "sink '{sink}' is not reachable from any source node"
733 )));
734 }
735 }
736 Ok(())
737 }
738
739 pub async fn run(self, opts: TopologyOptions) -> Result<TopologyResult, FaucetError> {
744 self.run_with(opts, TopologyGovernance::default()).await
745 }
746
747 pub async fn run_with(
754 self,
755 opts: TopologyOptions,
756 governance: TopologyGovernance,
757 ) -> Result<TopologyResult, FaucetError> {
758 self.run_reported(opts, governance).await.map(|r| r.result)
759 }
760
761 pub async fn run_reported(
768 self,
769 opts: TopologyOptions,
770 governance: TopologyGovernance,
771 ) -> Result<TopologyRun, FaucetError> {
772 match self.run_attributed(opts, governance).await {
773 (_, Some(e)) => Err(e),
774 (run, None) => Ok(run),
775 }
776 }
777
778 pub async fn run_attributed(
789 self,
790 opts: TopologyOptions,
791 governance: TopologyGovernance,
792 ) -> (TopologyRun, Option<FaucetError>) {
793 if let Err(e) = self.validate() {
794 return (TopologyRun::default(), Some(e));
795 }
796 let facts = GraphFacts::of(&self.nodes, &self.edges);
797 let Topology { nodes, edges } = self;
798
799 let order: Vec<(String, &'static str)> = nodes
800 .iter()
801 .map(|n| (n.id.clone(), n.kind.kind_str()))
802 .collect();
803
804 let mut sources: HashMap<String, Arc<dyn Source>> = HashMap::new();
807 let mut rest: Vec<Node> = Vec::with_capacity(nodes.len());
808 for n in nodes {
809 match n.kind {
810 NodeKind::Source(s) => {
811 sources.insert(n.id, Arc::from(s));
812 }
813 kind => rest.push(Node { id: n.id, kind }),
814 }
815 }
816 let source_count = sources.len();
817 let only_source: Option<Arc<dyn Source>> = if source_count == 1 {
818 sources.values().next().cloned()
819 } else {
820 None
821 };
822
823 let tee_cap: HashMap<String, usize> = rest
826 .iter()
827 .filter_map(|n| match &n.kind {
828 NodeKind::Tee { capacity, .. } => Some((n.id.clone(), *capacity)),
829 _ => None,
830 })
831 .collect();
832
833 let source_replay = only_source.as_ref().map(|s| s.replay_guarantee());
836 let codec = only_source
840 .as_ref()
841 .map(|s| crate::state_version::StateCodec::for_source(s.as_ref(), false))
842 .unwrap_or(crate::state_version::StateCodec {
843 owner: "topology".into(),
844 schema: 0,
845 legacy: false,
846 });
847
848 let sink_refs: Vec<(String, Option<&dyn Sink>)> = rest
849 .iter()
850 .filter_map(|n| match &n.kind {
851 NodeKind::Sink(s) => Some((n.id.clone(), Some(s.as_ref()))),
852 _ => None,
853 })
854 .collect();
855 let plan = match compute_resume(
856 &opts,
857 &sink_refs,
858 source_count,
859 governance.delivery,
860 only_source.as_deref(),
861 )
862 .await
863 {
864 Ok(p) => p,
865 Err(e) => return (TopologyRun::default(), Some(e)),
866 };
867 drop(sink_refs);
868
869 let coop = opts.cancel.clone().unwrap_or_default().child_token();
875 let failed: FailedNodes = Arc::new(std::sync::Mutex::new(HashMap::new()));
876
877 let mut outs: HashMap<String, Vec<mpsc::Sender<StreamPage>>> = HashMap::new();
879 let mut ins: HashMap<String, Vec<InEdge>> = HashMap::new();
880 for e in &edges {
881 let cap = tee_cap
882 .get(e.from.as_str())
883 .copied()
884 .unwrap_or(opts.default_channel_capacity)
885 .max(1);
886 let (tx, rx) = mpsc::channel(cap);
887 outs.entry(e.from.clone()).or_default().push(tx);
888 ins.entry(e.to.clone()).or_default().push(InEdge {
889 label: e.label.clone(),
890 from: e.from.clone(),
891 rx,
892 });
893 }
894
895 type NodeFut = Pin<Box<dyn Future<Output = Result<NodeOutcome, FaucetError>> + Send>>;
898 let mut by_id: HashMap<String, NodeFut> = HashMap::new();
899 let source_kinds: HashMap<String, &'static str> = sources
900 .iter()
901 .map(|(id, s)| (id.clone(), s.connector_name()))
902 .collect();
903
904 for (id, source) in sources {
905 let node_outs = outs.remove(&id).unwrap_or_default();
906 let keep = node_outs.clone();
907 let fut: NodeFut = Box::pin(run_source_node(
908 Labels::new(opts.pipeline_name.clone(), id.clone(), opts.run_id.clone()),
909 source,
910 plan.start.clone(),
911 opts.batch_size,
912 node_outs,
913 coop.clone(),
914 ));
915 by_id.insert(
916 id.clone(),
917 track_failure(id, fut, keep, Arc::clone(&failed)),
918 );
919 }
920
921 for node in rest {
922 let node_outs = outs.remove(&node.id).unwrap_or_default();
923 let keep = node_outs.clone();
924 let mut node_ins = ins.remove(&node.id).unwrap_or_default();
925 let pipeline = opts.pipeline_name.clone();
926 let cancel = coop.clone();
927 let Node { id, kind } = node;
928
929 let built: Result<NodeFut, FaucetError> = match kind {
930 NodeKind::Source(_) => Err(cfg(format!("source '{id}' was not prepared"))),
931 NodeKind::Transform(stages) => take_single(node_ins)
932 .ok_or_else(|| cfg(format!("transform '{id}' has no input edge")))
933 .map(|rx| {
934 let labels = Labels::new(pipeline.clone(), id.clone(), opts.run_id.clone());
935 Box::pin(run_transform_node(stages, labels, rx, node_outs, cancel))
936 as NodeFut
937 }),
938 NodeKind::Tee { .. } => take_single(node_ins)
939 .ok_or_else(|| cfg(format!("tee '{id}' has no input edge")))
940 .map(|rx| {
941 Box::pin(run_tee_node(id.clone(), pipeline, rx, node_outs, cancel))
942 as NodeFut
943 }),
944 NodeKind::Merge => {
945 let rxs: Vec<mpsc::Receiver<StreamPage>> =
946 node_ins.into_iter().map(|ie| ie.rx).collect();
947 let mode = facts
948 .merge_modes
949 .get(&id)
950 .copied()
951 .unwrap_or(MergeMode::Passthrough);
952 Ok(Box::pin(run_merge_node(
953 id.clone(),
954 pipeline,
955 rxs,
956 node_outs,
957 cancel,
958 mode,
959 )) as NodeFut)
960 }
961 NodeKind::Join(j) => {
962 let build_upstream: HashSet<String> = node_ins
965 .iter()
966 .find(|ie| ie.label.as_deref() == Some(j.build_edge.as_str()))
967 .map(|ie| {
968 let mut set =
969 facts.ancestors.get(&ie.from).cloned().unwrap_or_default();
970 set.insert(ie.from.clone());
971 set
972 })
973 .unwrap_or_default();
974 let build_rx = take_by_label(&mut node_ins, &j.build_edge);
975 let probe_rx = take_by_label(&mut node_ins, &j.probe_edge);
976 match (build_rx, probe_rx) {
977 (Some(b), Some(p)) => Ok(Box::pin(run_join_node(
978 id.clone(),
979 pipeline,
980 j,
981 JoinInputs {
982 build_rx: b,
983 probe_rx: p,
984 build_upstream,
985 failed: Arc::clone(&failed),
986 },
987 node_outs,
988 cancel,
989 )) as NodeFut),
990 _ => Err(cfg(format!(
991 "join '{id}' is missing its build/probe input edges"
992 ))),
993 }
994 }
995 NodeKind::Sink(sink) => match take_single(node_ins) {
996 None => Err(cfg(format!("sink '{id}' has no input edge"))),
997 Some(rx) => {
998 let resume = plan.sinks.get(&id).cloned();
999 let sopts = SinkNodeOpts {
1000 pipeline_name: pipeline,
1001 run_id: opts.run_id.clone(),
1002 state_store: opts.state_store.clone(),
1003 dlq: opts.dlq.clone(),
1004 cancel: cancel.clone(),
1005 #[cfg(feature = "masking")]
1009 masking: governance.masking_by_sink.get(&id).cloned(),
1010 #[cfg(feature = "quality")]
1011 quality: governance.quality.clone(),
1012 #[cfg(feature = "contract")]
1013 contract: governance.contract.clone(),
1014 schema_drift: governance.schema_drift,
1015 resilience: governance.resilience.clone(),
1016 delivery: governance.delivery,
1017 replay: source_replay,
1018 codec: codec.clone(),
1019 resume,
1020 position_source: only_source.clone(),
1021 source_label: source_label(facts.ancestors.get(&id), &source_kinds),
1022 upstream: facts.ancestors.get(&id).cloned().unwrap_or_default(),
1023 failed: Arc::clone(&failed),
1024 };
1025 Ok(Box::pin(run_sink_node(id.clone(), sink, rx, sopts)) as NodeFut)
1026 }
1027 },
1028 };
1029 match built {
1030 Ok(fut) => {
1031 by_id.insert(
1032 id.clone(),
1033 track_failure(id, fut, keep, Arc::clone(&failed)),
1034 );
1035 }
1036 Err(e) => return (TopologyRun::default(), Some(e)),
1037 }
1038 }
1039
1040 drop(outs);
1042 drop(ins);
1043
1044 let futs: Vec<NodeFut> = order
1051 .iter()
1052 .filter_map(|(id, _)| by_id.remove(id))
1053 .collect();
1054 let handles: Vec<tokio::task::JoinHandle<Result<NodeOutcome, FaucetError>>> =
1055 futs.into_iter().map(tokio::spawn).collect();
1056 let aborts: Vec<tokio::task::AbortHandle> =
1059 handles.iter().map(|h| h.abort_handle()).collect();
1060 let _abort_on_drop = AbortOnDrop(aborts.clone());
1064 let abort_all = || {
1065 for a in &aborts {
1066 a.abort();
1067 }
1068 };
1069 let joined = handles.into_iter().map(|h| async move {
1070 match h.await {
1071 Ok(r) => r,
1072 Err(e) if e.is_panic() => {
1073 Err(FaucetError::Source(format!("topology node panicked: {e}")))
1074 }
1075 Err(e) => Err(FaucetError::Source(format!("topology node aborted: {e}"))),
1076 }
1077 });
1078
1079 let externally_cancelled = || cancelled(&opts.cancel);
1080 match opts.on_error {
1081 TopologyOnError::Propagate => {
1082 let first_err: Arc<std::sync::Mutex<Option<FaucetError>>> =
1092 Arc::new(std::sync::Mutex::new(None));
1093 let failures: Arc<std::sync::Mutex<Vec<(String, String, NodeErrorKind)>>> =
1094 Arc::new(std::sync::Mutex::new(Vec::new()));
1095 let wrapped = joined.zip(order.clone()).map(|(f, (node_id, _))| {
1096 let coop = coop.clone();
1097 let slot = Arc::clone(&first_err);
1098 let failed = Arc::clone(&failures);
1099 async move {
1100 match f.await {
1101 Ok(o) => Some(o),
1102 Err(e) => {
1103 failed.lock().unwrap_or_else(|p| p.into_inner()).push((
1104 node_id,
1105 e.to_string(),
1106 NodeErrorKind::classify(&e),
1107 ));
1108 tracing::error!(
1109 error = %e,
1110 "topology node failed; cancelling siblings so they flush"
1111 );
1112 let mut guard = slot.lock().unwrap_or_else(|p| p.into_inner());
1113 if guard.is_none() {
1114 *guard = Some(e);
1115 }
1116 coop.cancel();
1117 None
1118 }
1119 }
1120 }
1121 });
1122 let all = futures::future::join_all(wrapped);
1125 let grace = STOP_FLUSH_GRACE;
1126 let deadline = {
1127 let coop = coop.clone();
1128 async move {
1129 coop.cancelled().await;
1130 tokio::time::sleep(grace).await;
1131 }
1132 };
1133 let outcomes = tokio::select! {
1134 biased;
1135 v = all => v,
1136 () = deadline => {
1137 tracing::warn!(
1138 grace_secs = grace.as_secs(),
1139 "topology: nodes did not stop within the flush grace after a failure; \
1140 aborting them"
1141 );
1142 abort_all();
1143 Vec::new()
1144 }
1145 };
1146 let outcomes: Vec<NodeOutcome> = outcomes.into_iter().flatten().collect();
1147 let err = first_err.lock().unwrap_or_else(|p| p.into_inner()).take();
1148 let complete = err.is_none() && !externally_cancelled();
1149 let finish = finish_sinks(&outcomes, complete).await;
1150 let (result, per_source) = aggregate(outcomes);
1151 let failed = failures.lock().unwrap_or_else(|p| p.into_inner()).clone();
1152 let nodes = reports(&order, &result, &per_source, &failed, &facts.ancestors);
1153 let run = TopologyRun {
1154 result,
1155 nodes,
1156 per_source,
1157 };
1158 (run, err.or(finish.err()))
1159 }
1160 TopologyOnError::Continue => {
1161 let results = futures::future::join_all(joined).await;
1162 let mut ok = Vec::new();
1163 let mut errs = Vec::new();
1164 let mut failed: Vec<(String, String, NodeErrorKind)> = Vec::new();
1165 for (r, (node_id, _)) in results.into_iter().zip(order.clone()) {
1166 match r {
1167 Ok(o) => ok.push(o),
1168 Err(e) => {
1169 tracing::error!(
1170 node = %node_id,
1171 error = %e,
1172 "topology node failed (on_error: continue)"
1173 );
1174 errs.push(e.to_string());
1175 failed.push((node_id, e.to_string(), NodeErrorKind::classify(&e)));
1176 }
1177 }
1178 }
1179 let complete = errs.is_empty() && !externally_cancelled();
1180 let finish = finish_sinks(&ok, complete).await;
1181 let (mut result, per_source) = aggregate(ok);
1182 result.errors = errs;
1183 let nodes = reports(&order, &result, &per_source, &failed, &facts.ancestors);
1184 let run = TopologyRun {
1185 result,
1186 nodes,
1187 per_source,
1188 };
1189 (run, finish.err())
1190 }
1191 }
1192 }
1193}
1194
1195type FailedNodes = Arc<std::sync::Mutex<HashMap<String, (String, NodeErrorKind)>>>;
1199
1200fn track_failure(
1208 id: String,
1209 fut: Pin<Box<dyn Future<Output = Result<NodeOutcome, FaucetError>> + Send>>,
1210 keep: Vec<mpsc::Sender<StreamPage>>,
1211 failed: FailedNodes,
1212) -> Pin<Box<dyn Future<Output = Result<NodeOutcome, FaucetError>> + Send>> {
1213 Box::pin(async move {
1214 let result = fut.await;
1215 if let Err(e) = &result {
1216 failed
1217 .lock()
1218 .unwrap_or_else(|p| p.into_inner())
1219 .insert(id, (e.to_string(), NodeErrorKind::classify(e)));
1220 }
1221 drop(keep);
1222 result
1223 })
1224}
1225
1226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1228enum MergeMode {
1229 Passthrough,
1232 Gated,
1238 Strip,
1243}
1244
1245struct GraphFacts {
1247 ancestors: HashMap<String, HashSet<String>>,
1249 merge_modes: HashMap<String, MergeMode>,
1251}
1252
1253impl GraphFacts {
1254 fn of(nodes: &[Node], edges: &[Edge]) -> Self {
1255 let mut rev: HashMap<&str, Vec<&Edge>> = HashMap::new();
1256 for e in edges {
1257 rev.entry(e.to.as_str()).or_default().push(e);
1258 }
1259 let ancestors: HashMap<String, HashSet<String>> = nodes
1260 .iter()
1261 .map(|n| (n.id.clone(), ancestors_of(&n.id, &rev)))
1262 .collect();
1263
1264 let kinds: HashMap<&str, &NodeKind> =
1268 nodes.iter().map(|n| (n.id.as_str(), &n.kind)).collect();
1269 let mut origins: HashMap<String, HashSet<String>> = HashMap::new();
1270 for n in nodes {
1271 bookmark_origins(&n.id, &kinds, &rev, &mut origins);
1272 }
1273
1274 let mut merge_modes = HashMap::new();
1275 for n in nodes {
1276 if !matches!(n.kind, NodeKind::Merge) {
1277 continue;
1278 }
1279 let inputs: Vec<HashSet<String>> = rev
1280 .get(n.id.as_str())
1281 .map(|es| {
1282 es.iter()
1283 .map(|e| origins.get(&e.from).cloned().unwrap_or_default())
1284 .collect()
1285 })
1286 .unwrap_or_default();
1287 merge_modes.insert(n.id.clone(), merge_mode(&inputs));
1288 }
1289 Self {
1290 ancestors,
1291 merge_modes,
1292 }
1293 }
1294}
1295
1296fn ancestors_of(id: &str, rev: &HashMap<&str, Vec<&Edge>>) -> HashSet<String> {
1298 let mut seen: HashSet<String> = HashSet::new();
1299 let mut stack: Vec<&str> = rev
1300 .get(id)
1301 .map(|es| es.iter().map(|e| e.from.as_str()).collect())
1302 .unwrap_or_default();
1303 while let Some(n) = stack.pop() {
1304 if !seen.insert(n.to_string()) {
1305 continue;
1306 }
1307 if let Some(es) = rev.get(n) {
1308 stack.extend(es.iter().map(|e| e.from.as_str()));
1309 }
1310 }
1311 seen
1312}
1313
1314fn bookmark_origins(
1315 id: &str,
1316 kinds: &HashMap<&str, &NodeKind>,
1317 rev: &HashMap<&str, Vec<&Edge>>,
1318 memo: &mut HashMap<String, HashSet<String>>,
1319) -> HashSet<String> {
1320 if let Some(o) = memo.get(id) {
1321 return o.clone();
1322 }
1323 let inputs: Vec<&Edge> = rev.get(id).cloned().unwrap_or_default();
1324 let origins: HashSet<String> = match kinds.get(id) {
1325 Some(NodeKind::Source(_)) => HashSet::from([id.to_string()]),
1326 Some(NodeKind::Join(j)) => inputs
1327 .iter()
1328 .filter(|e| e.label.as_deref() == Some(j.probe_edge.as_str()))
1329 .flat_map(|e| bookmark_origins(&e.from, kinds, rev, memo))
1330 .collect(),
1331 _ => inputs
1332 .iter()
1333 .flat_map(|e| bookmark_origins(&e.from, kinds, rev, memo))
1334 .collect(),
1335 };
1336 memo.insert(id.to_string(), origins.clone());
1337 origins
1338}
1339
1340fn merge_mode(inputs: &[HashSet<String>]) -> MergeMode {
1342 let disjoint = inputs
1343 .iter()
1344 .enumerate()
1345 .all(|(i, a)| inputs[i + 1..].iter().all(|b| a.is_disjoint(b)));
1346 if disjoint {
1347 return MergeMode::Passthrough;
1348 }
1349 let first = &inputs[0];
1350 if first.len() == 1 && inputs.iter().all(|o| o == first) {
1351 MergeMode::Gated
1352 } else {
1353 MergeMode::Strip
1354 }
1355}
1356
1357#[derive(Debug)]
1366struct BookmarkGate {
1367 delivered: Vec<u64>,
1368 closed: Vec<bool>,
1369 released: u64,
1370 pending: std::collections::VecDeque<Value>,
1373}
1374
1375impl BookmarkGate {
1376 fn new(inputs: usize) -> Self {
1377 Self {
1378 delivered: vec![0; inputs],
1379 closed: vec![false; inputs],
1380 released: 0,
1381 pending: std::collections::VecDeque::new(),
1382 }
1383 }
1384
1385 fn arrive(&mut self, input: usize, bookmark: Value) -> Option<Value> {
1388 self.delivered[input] += 1;
1389 let seen = self.released + self.pending.len() as u64;
1390 if self.delivered[input] > seen {
1391 self.pending.push_back(bookmark);
1392 }
1393 self.release()
1394 }
1395
1396 fn close(&mut self, input: usize) -> Option<Value> {
1398 self.closed[input] = true;
1399 self.release()
1400 }
1401
1402 fn release(&mut self) -> Option<Value> {
1403 let live_min = self
1404 .delivered
1405 .iter()
1406 .zip(&self.closed)
1407 .filter(|(_, closed)| !**closed)
1408 .map(|(d, _)| *d)
1409 .min()
1410 .unwrap_or_else(|| self.released + self.pending.len() as u64);
1411 let mut out = None;
1412 while self.released < live_min {
1413 match self.pending.pop_front() {
1414 Some(bm) => {
1415 self.released += 1;
1416 out = Some(bm);
1417 }
1418 None => break,
1419 }
1420 }
1421 out
1422 }
1423}
1424
1425fn reports(
1431 order: &[(String, &'static str)],
1432 sinks: &TopologyResult,
1433 per_source: &HashMap<String, usize>,
1434 errors: &[(String, String, NodeErrorKind)],
1435 ancestors: &HashMap<String, HashSet<String>>,
1436) -> Vec<NodeReport> {
1437 order
1438 .iter()
1439 .map(|(id, kind)| {
1440 let own = errors.iter().find(|(nid, _, _)| nid == id);
1441 let upstream = (own.is_none() && *kind == "sink")
1442 .then(|| {
1443 errors
1444 .iter()
1445 .find(|(nid, _, _)| ancestors.get(id).is_some_and(|a| a.contains(nid)))
1446 })
1447 .flatten();
1448 let (error, error_kind) = match (own, upstream) {
1449 (Some((_, e, k)), _) => (Some(e.clone()), Some(*k)),
1450 (None, Some((nid, e, k))) => (
1451 Some(format!(
1452 "upstream node '{nid}' failed, so this sink did not receive its \
1453 complete input: {e}"
1454 )),
1455 Some(*k),
1456 ),
1457 (None, None) => (None, None),
1458 };
1459 NodeReport {
1460 node_id: id.clone(),
1461 kind,
1462 records: sinks
1463 .per_sink
1464 .get(id)
1465 .or_else(|| per_source.get(id))
1466 .copied()
1467 .unwrap_or(0),
1468 bookmark: sinks.bookmarks.get(id).cloned().flatten(),
1469 error,
1470 error_kind,
1471 }
1472 })
1473 .collect()
1474}
1475
1476async fn finish_sinks(outcomes: &[NodeOutcome], complete: bool) -> Result<(), FaucetError> {
1491 struct Finish<'a> {
1492 sink: &'a dyn Sink,
1493 deferred: Option<&'a DeferredState>,
1494 bookmark: Option<&'a Value>,
1495 }
1496 let sinks: Vec<Finish<'_>> = outcomes
1497 .iter()
1498 .filter_map(|o| match o {
1499 NodeOutcome::Sink {
1500 sink,
1501 deferred,
1502 bookmark,
1503 ..
1504 } => Some(Finish {
1505 sink: sink.as_ref(),
1506 deferred: deferred.as_ref(),
1507 bookmark: bookmark.as_ref(),
1508 }),
1509 _ => None,
1510 })
1511 .collect();
1512 if !complete {
1513 for f in &sinks {
1514 if f.sink.is_overwrite() {
1515 abort_quietly(f.sink).await;
1516 }
1517 }
1518 return Ok(());
1519 }
1520 for (i, f) in sinks.iter().enumerate() {
1521 if !f.sink.is_overwrite() {
1522 continue;
1523 }
1524 if let Err(e) = f.sink.commit_overwrite().await {
1525 for rest in &sinks[i + 1..] {
1526 if rest.sink.is_overwrite() {
1527 abort_quietly(rest.sink).await;
1528 }
1529 }
1530 return Err(e);
1531 }
1532 if let (Some(d), Some(bm)) = (f.deferred, f.bookmark) {
1533 d.store.put(&d.key, bm).await?;
1534 }
1535 }
1536 for f in &sinks {
1537 f.sink.complete_run().await?;
1538 }
1539 Ok(())
1540}
1541
1542async fn abort_quietly(sink: &dyn Sink) {
1543 if let Err(e) = sink.abort_overwrite().await {
1544 tracing::warn!(
1545 error = %e,
1546 "failed to discard overwrite staging after an unsuccessful or cancelled \
1547 topology run; the destination is unchanged"
1548 );
1549 }
1550}
1551
1552fn aggregate(outcomes: Vec<NodeOutcome>) -> (TopologyResult, HashMap<String, usize>) {
1555 let mut result = TopologyResult::default();
1556 let mut per_source = HashMap::new();
1557 for o in outcomes {
1558 match o {
1559 NodeOutcome::Sink {
1560 node_id,
1561 records,
1562 bookmark,
1563 ..
1564 } => {
1565 result.records_written += records;
1566 result.per_sink.insert(node_id.clone(), records);
1567 result.bookmarks.insert(node_id, bookmark);
1568 }
1569 NodeOutcome::Source { node_id, records } => {
1570 per_source.insert(node_id, records);
1571 }
1572 NodeOutcome::Other => {}
1573 }
1574 }
1575 (result, per_source)
1576}
1577
1578fn cfg(msg: impl Into<String>) -> FaucetError {
1579 FaucetError::Config(format!("topology: {}", msg.into()))
1580}
1581
1582fn arity(
1583 node_id: &str,
1584 kind: &str,
1585 in_ok: bool,
1586 out_ok: bool,
1587 expected: &str,
1588) -> Result<(), FaucetError> {
1589 if in_ok && out_ok {
1590 Ok(())
1591 } else {
1592 Err(cfg(format!(
1593 "{kind} '{node_id}' has the wrong edge arity (expected {expected})"
1594 )))
1595 }
1596}
1597
1598fn reaches_any(start: &str, adj: &HashMap<&str, Vec<&str>>, targets: &HashSet<&str>) -> bool {
1599 let mut stack = vec![start];
1600 let mut seen = HashSet::new();
1601 while let Some(n) = stack.pop() {
1602 if targets.contains(n) {
1603 return true;
1604 }
1605 if !seen.insert(n) {
1606 continue;
1607 }
1608 if let Some(ns) = adj.get(n) {
1609 stack.extend(ns.iter().copied());
1610 }
1611 }
1612 false
1613}
1614
1615#[derive(Debug, Clone, PartialEq)]
1617struct SinkResume {
1618 start_seq: u64,
1620 skip_through: Option<Value>,
1624}
1625
1626#[derive(Debug, Default)]
1628struct ResumePlan {
1629 start: Option<Value>,
1630 sinks: HashMap<String, SinkResume>,
1631}
1632
1633struct AbortOnDrop(Vec<tokio::task::AbortHandle>);
1635
1636impl Drop for AbortOnDrop {
1637 fn drop(&mut self) {
1638 for a in &self.0 {
1639 a.abort();
1640 }
1641 }
1642}
1643
1644#[cfg(test)]
1650async fn compute_start_bookmark(
1651 opts: &TopologyOptions,
1652 sink_ids: &[String],
1653 source_count: usize,
1654 delivery: crate::idempotency::DeliveryMode,
1655 source: Option<&dyn Source>,
1656) -> Result<Option<Value>, FaucetError> {
1657 let sinks: Vec<(String, Option<&dyn Sink>)> =
1658 sink_ids.iter().map(|id| (id.clone(), None)).collect();
1659 compute_resume(opts, &sinks, source_count, delivery, source)
1660 .await
1661 .map(|p| p.start)
1662}
1663
1664async fn compute_resume(
1687 opts: &TopologyOptions,
1688 sinks: &[(String, Option<&dyn Sink>)],
1689 source_count: usize,
1690 delivery: crate::idempotency::DeliveryMode,
1691 source: Option<&dyn Source>,
1692) -> Result<ResumePlan, FaucetError> {
1693 let Some(store) = opts.state_store.as_ref() else {
1694 return Ok(ResumePlan::default());
1695 };
1696 if sinks.is_empty() {
1697 return Ok(ResumePlan::default());
1698 }
1699 let mut stored: Vec<(String, Option<Value>)> = Vec::with_capacity(sinks.len());
1700 for (id, _) in sinks {
1701 let key = format!("{}::{}", opts.pipeline_name, id);
1702 let value = match store.get(&key).await {
1703 Ok(Some(v)) => Some(match source {
1705 Some(src) => crate::state_version::resolve_for_source(&key, &v, src)?.data,
1706 None => crate::state_version::peel_versioned(&v),
1707 }),
1708 Ok(None) => None,
1709 Err(e) => return Err(e),
1712 };
1713 stored.push((id.clone(), value));
1714 }
1715
1716 if delivery != crate::idempotency::DeliveryMode::ExactlyOnce {
1717 if stored.iter().any(|(_, v)| v.is_none()) {
1718 return Ok(ResumePlan::default());
1719 }
1720 let values: Vec<Value> = stored.into_iter().filter_map(|(_, v)| v).collect();
1721 return Ok(ResumePlan {
1722 start: start_bookmark(&values, source_count),
1723 sinks: HashMap::new(),
1724 });
1725 }
1726
1727 let mut positions: Vec<(String, Option<Value>, u64)> = Vec::with_capacity(sinks.len());
1728 for ((id, sink), (_, value)) in sinks.iter().zip(stored) {
1729 let (mut bm, mut seq) = value
1730 .as_ref()
1731 .map(crate::idempotency::unwrap_state)
1732 .unwrap_or((None, 0));
1733 let key = format!("{}::{}", opts.pipeline_name, id);
1734 if let (Some(sink), Some(src)) = (sink, source)
1735 && sink.supports_idempotent_writes()
1736 && src.replay_guarantee() == crate::idempotency::ReplayGuarantee::Deterministic
1737 && let Some(token) = sink.last_committed_token(&key).await?
1738 && let Some((token_seq, Some(token_bm))) = crate::idempotency::parse_token_parts(&token)
1739 && token_seq > seq
1740 {
1741 bm = Some(token_bm);
1742 seq = token_seq;
1743 }
1744 positions.push((id.clone(), bm, seq));
1745 }
1746 Ok(plan_exactly_once(&positions, source_count, source))
1747}
1748
1749fn plan_exactly_once(
1751 positions: &[(String, Option<Value>, u64)],
1752 source_count: usize,
1753 source: Option<&dyn Source>,
1754) -> ResumePlan {
1755 let own = |skip: &dyn Fn(&Option<Value>) -> Option<Value>| -> HashMap<String, SinkResume> {
1756 positions
1757 .iter()
1758 .map(|(id, bm, seq)| {
1759 (
1760 id.clone(),
1761 SinkResume {
1762 start_seq: *seq,
1763 skip_through: skip(bm),
1764 },
1765 )
1766 })
1767 .collect()
1768 };
1769 if source_count != 1 {
1770 if source_count > 1 {
1771 tracing::warn!(
1772 sources = source_count,
1773 "topology: multi-source graph cannot attribute a sink bookmark to a source; \
1774 replaying every source in full"
1775 );
1776 }
1777 return ResumePlan {
1778 start: None,
1779 sinks: own(&|_| None),
1780 };
1781 }
1782
1783 let known: Vec<Value> = positions
1784 .iter()
1785 .filter_map(|(_, bm, _)| bm.clone())
1786 .collect();
1787 let all_known = known.len() == positions.len();
1788 if all_known && known.iter().all(|b| b == &known[0]) {
1789 return ResumePlan {
1790 start: known.first().cloned(),
1791 sinks: own(&|_| None),
1792 };
1793 }
1794
1795 let ordering = source.filter(|src| {
1796 known
1797 .first()
1798 .is_some_and(|b| src.position_le(b, b).is_some())
1799 });
1800 if let Some(src) = ordering {
1801 let start = if all_known {
1802 src.position_min(&known)
1803 } else {
1804 None
1805 };
1806 if start.is_some() || !all_known {
1807 let skip = |bm: &Option<Value>| bm.clone().filter(|b| Some(b) != start.as_ref());
1808 return ResumePlan {
1809 start: start.clone(),
1810 sinks: own(&skip),
1811 };
1812 }
1813 }
1814
1815 let (start, base_seq) = if all_known {
1816 positions
1817 .iter()
1818 .min_by_key(|(_, _, seq)| *seq)
1819 .map(|(_, bm, seq)| (bm.clone(), *seq))
1820 .unwrap_or((None, 0))
1821 } else {
1822 (None, 0)
1823 };
1824 ResumePlan {
1825 start,
1826 sinks: positions
1827 .iter()
1828 .map(|(id, _, _)| {
1829 (
1830 id.clone(),
1831 SinkResume {
1832 start_seq: base_seq,
1833 skip_through: None,
1834 },
1835 )
1836 })
1837 .collect(),
1838 }
1839}
1840
1841pub fn eo_start_bookmark(ranked: &[(u64, Option<Value>)], source_count: usize) -> Option<Value> {
1852 if ranked.is_empty() || source_count != 1 {
1853 if source_count > 1 && !ranked.is_empty() {
1854 tracing::warn!(
1855 sources = source_count,
1856 "topology: multi-source graph cannot attribute a sink bookmark to a source; \
1857 replaying every source in full"
1858 );
1859 }
1860 return None;
1861 }
1862 ranked
1863 .iter()
1864 .min_by_key(|(seq, _)| *seq)
1865 .and_then(|(_, bm)| bm.clone())
1866}
1867
1868pub fn start_bookmark(sink_bookmarks: &[Value], source_count: usize) -> Option<Value> {
1886 if sink_bookmarks.is_empty() || source_count != 1 {
1887 if source_count > 1 && !sink_bookmarks.is_empty() {
1888 tracing::warn!(
1889 sources = source_count,
1890 "topology: multi-source graph cannot attribute a sink bookmark to a source; \
1891 replaying every source in full. Make the sinks idempotent \
1892 (`write_mode: upsert`) or split the graph into one pipeline per source."
1893 );
1894 }
1895 return None;
1896 }
1897 let first = &sink_bookmarks[0];
1898 if sink_bookmarks.iter().any(|v| v != first) {
1899 tracing::warn!(
1900 "topology: sink bookmarks have diverged and resume positions are not safely \
1901 ordered; replaying the source in full so no sink is skipped past. Faster sinks \
1902 will re-see already-written pages — make them idempotent."
1903 );
1904 return None;
1905 }
1906 Some(first.clone())
1907}
1908
1909async fn recv_or_cancel(
1911 rx: &mut mpsc::Receiver<StreamPage>,
1912 cancel: &CancellationToken,
1913) -> Option<StreamPage> {
1914 tokio::select! {
1915 biased;
1916 () = cancel.cancelled() => None,
1917 page = rx.recv() => page,
1918 }
1919}
1920
1921async fn broadcast(
1927 page: StreamPage,
1928 outs: &mut Vec<mpsc::Sender<StreamPage>>,
1929 cancel: &CancellationToken,
1930) -> bool {
1931 if outs.is_empty() {
1932 return false;
1933 }
1934 let last = outs.len() - 1;
1935 let mut page = Some(page);
1936 let mut closed: Vec<usize> = Vec::new();
1937 for (i, tx) in outs.iter().enumerate() {
1938 let item = if i == last { page.take() } else { page.clone() };
1939 let Some(item) = item else { break };
1940 tokio::select! {
1941 biased;
1942 () = cancel.cancelled() => return false,
1943 r = tx.send(item) => if r.is_err() { closed.push(i) },
1944 }
1945 }
1946 for &i in closed.iter().rev() {
1947 outs.remove(i);
1948 }
1949 !outs.is_empty()
1950}
1951
1952fn cancelled(cancel: &Option<CancellationToken>) -> bool {
1953 cancel.as_ref().is_some_and(|c| c.is_cancelled())
1954}
1955
1956async fn run_source_node(
1957 labels: Labels,
1958 source: Arc<dyn Source>,
1959 start_bookmark: Option<Value>,
1960 batch_size: usize,
1961 mut outs: Vec<mpsc::Sender<StreamPage>>,
1962 cancel: CancellationToken,
1963) -> Result<NodeOutcome, FaucetError> {
1964 let node_id = labels.row.to_string();
1965 source.set_roundtrip_recorder(Arc::new(crate::observability::RoundtripRecorder::new(
1968 crate::observability::RoundtripSide::Source,
1969 labels.pipeline.to_string(),
1970 node_id.clone(),
1971 source.connector_name(),
1972 )));
1973 let source = crate::observability::InstrumentedSource::new(source.as_ref(), labels);
1974 if let Some(bm) = start_bookmark {
1975 source.apply_start_bookmark(bm).await?;
1976 }
1977 let ctx = std::collections::HashMap::new();
1978 let mut pages = source.stream_pages(&ctx, batch_size);
1979 let mut records = 0usize;
1980 loop {
1981 let item = tokio::select! {
1982 biased;
1983 () = cancel.cancelled() => break,
1984 item = pages.next() => item,
1985 };
1986 let Some(item) = item else { break };
1987 let page = item?;
1988 records += page.records.len();
1989 if !broadcast(page, &mut outs, &cancel).await {
1990 break;
1991 }
1992 }
1993 Ok(NodeOutcome::Source { node_id, records })
1994}
1995
1996async fn run_transform_node(
1997 stages: Vec<CompiledStage>,
1998 labels: Labels,
1999 mut rx: mpsc::Receiver<StreamPage>,
2000 mut outs: Vec<mpsc::Sender<StreamPage>>,
2001 cancel: CancellationToken,
2002) -> Result<NodeOutcome, FaucetError> {
2003 while let Some(page) = recv_or_cancel(&mut rx, &cancel).await {
2004 let records = instrumented_apply_stages(page.records, &stages, &labels)?;
2005 let out = StreamPage {
2006 records,
2007 bookmark: page.bookmark,
2008 };
2009 if !broadcast(out, &mut outs, &cancel).await {
2010 break;
2011 }
2012 }
2013 Ok(NodeOutcome::Other)
2014}
2015
2016fn node_labels(pipeline: &str, node: &str) -> Vec<Label> {
2017 vec![
2018 Label::new("pipeline", SharedString::from(pipeline.to_string())),
2019 Label::new("node", SharedString::from(node.to_string())),
2020 ]
2021}
2022
2023async fn run_tee_node(
2024 node_id: String,
2025 pipeline: String,
2026 mut rx: mpsc::Receiver<StreamPage>,
2027 mut outs: Vec<mpsc::Sender<StreamPage>>,
2028 cancel: CancellationToken,
2029) -> Result<NodeOutcome, FaucetError> {
2030 let labels = node_labels(&pipeline, &node_id);
2031 while let Some(page) = recv_or_cancel(&mut rx, &cancel).await {
2032 counter!("faucet_tee_records_total", labels.clone()).increment(page.records.len() as u64);
2033 if !broadcast(page, &mut outs, &cancel).await {
2034 break;
2035 }
2036 }
2037 Ok(NodeOutcome::Other)
2038}
2039
2040async fn run_merge_node(
2041 node_id: String,
2042 pipeline: String,
2043 rxs: Vec<mpsc::Receiver<StreamPage>>,
2044 mut outs: Vec<mpsc::Sender<StreamPage>>,
2045 cancel: CancellationToken,
2046 mode: MergeMode,
2047) -> Result<NodeOutcome, FaucetError> {
2048 let labels = node_labels(&pipeline, &node_id);
2049 if mode == MergeMode::Strip {
2050 tracing::warn!(
2051 node = %node_id,
2052 "topology: merge '{node_id}' combines inputs that share a source with inputs that \
2053 do not, so their positions cannot be lined up; it forwards no bookmarks and the \
2054 next run replays in full"
2055 );
2056 }
2057 let mut gate = BookmarkGate::new(rxs.len());
2058 let streams = rxs.into_iter().enumerate().map(|(i, mut rx)| {
2059 Box::pin(async_stream::stream! {
2060 while let Some(p) = rx.recv().await {
2061 yield (i, Some(p));
2062 }
2063 yield (i, None);
2064 }) as Pin<Box<dyn futures::Stream<Item = (usize, Option<StreamPage>)> + Send>>
2065 });
2066 let mut sel = futures::stream::select_all(streams);
2067 loop {
2068 let next = tokio::select! {
2069 biased;
2070 () = cancel.cancelled() => break,
2071 next = sel.next() => next,
2072 };
2073 let Some((input, page)) = next else { break };
2074 let out = match (mode, page) {
2075 (MergeMode::Gated, None) => match gate.close(input) {
2076 Some(bm) => StreamPage {
2077 records: Vec::new(),
2078 bookmark: Some(bm),
2079 },
2080 None => continue,
2081 },
2082 (_, None) => continue,
2083 (MergeMode::Gated, Some(p)) => StreamPage {
2084 bookmark: p.bookmark.and_then(|bm| gate.arrive(input, bm)),
2085 records: p.records,
2086 },
2087 (MergeMode::Strip, Some(p)) => StreamPage {
2088 records: p.records,
2089 bookmark: None,
2090 },
2091 (MergeMode::Passthrough, Some(p)) => p,
2092 };
2093 counter!("faucet_merge_records_total", labels.clone()).increment(out.records.len() as u64);
2094 if !broadcast(out, &mut outs, &cancel).await {
2095 break;
2096 }
2097 }
2098 Ok(NodeOutcome::Other)
2099}
2100
2101struct JoinInputs {
2104 build_rx: mpsc::Receiver<StreamPage>,
2105 probe_rx: mpsc::Receiver<StreamPage>,
2106 build_upstream: HashSet<String>,
2107 failed: FailedNodes,
2108}
2109
2110async fn run_join_node(
2111 node_id: String,
2112 pipeline: String,
2113 j: JoinNode,
2114 inputs: JoinInputs,
2115 mut outs: Vec<mpsc::Sender<StreamPage>>,
2116 cancel: CancellationToken,
2117) -> Result<NodeOutcome, FaucetError> {
2118 let JoinInputs {
2119 mut build_rx,
2120 mut probe_rx,
2121 build_upstream,
2122 failed,
2123 } = inputs;
2124 let mode = j.config.mode;
2125 let mut join = HashJoin::new(j.config);
2126
2127 let build_start = std::time::Instant::now();
2129 while let Some(page) = recv_or_cancel(&mut build_rx, &cancel).await {
2130 join.add_build_page(page.records)?;
2131 }
2132 if let Some((node, error)) = failed_upstream(&failed, &build_upstream) {
2137 return Err(FaucetError::Source(format!(
2138 "join '{node_id}': build-side node '{node}' failed, so its hash table is \
2139 incomplete and nothing was probed: {error}"
2140 )));
2141 }
2142 if cancel.is_cancelled() {
2143 return Ok(NodeOutcome::Other);
2144 }
2145 let labels = node_labels(&pipeline, &node_id);
2146 histogram!("faucet_join_build_duration_seconds", labels.clone())
2147 .record(build_start.elapsed().as_secs_f64());
2148
2149 while let Some(page) = recv_or_cancel(&mut probe_rx, &cancel).await {
2151 let enriched = join.probe_page(page.records)?;
2152 let out = StreamPage {
2153 records: enriched,
2154 bookmark: page.bookmark,
2155 };
2156 if !broadcast(out, &mut outs, &cancel).await {
2157 break;
2158 }
2159 }
2160
2161 emit_join_metrics(&labels, mode, join.stats());
2162 Ok(NodeOutcome::Other)
2163}
2164
2165fn emit_join_metrics(labels: &[Label], mode: JoinMode, stats: &crate::join::JoinStats) {
2166 counter!("faucet_join_build_records_total", labels.to_vec()).increment(stats.build_records);
2167 counter!("faucet_join_build_nulls_total", labels.to_vec()).increment(stats.build_nulls);
2168 counter!("faucet_join_duplicates_total", labels.to_vec()).increment(stats.duplicates);
2169 counter!("faucet_join_probe_records_total", labels.to_vec()).increment(stats.probe_records);
2170 counter!("faucet_join_project_misses_total", labels.to_vec()).increment(stats.project_misses);
2171 let mut match_labels = labels.to_vec();
2172 match_labels.push(Label::new("kind", SharedString::from(mode.to_string())));
2173 counter!("faucet_join_matches_total", match_labels.clone()).increment(stats.matches);
2174 counter!("faucet_join_misses_total", match_labels).increment(stats.misses);
2175}
2176
2177struct DeferredState {
2179 store: Arc<dyn StateStore>,
2180 key: String,
2181}
2182
2183struct SinkNodeOpts {
2184 pipeline_name: String,
2185 run_id: String,
2186 state_store: Option<Arc<dyn StateStore>>,
2187 dlq: Option<DlqConfig>,
2188 cancel: CancellationToken,
2189 #[cfg(feature = "masking")]
2191 masking: Option<Arc<crate::masking::CompiledMasking>>,
2192 #[cfg(feature = "quality")]
2193 quality: Option<Arc<crate::quality::CompiledQuality>>,
2194 #[cfg(feature = "contract")]
2195 contract: Option<Arc<crate::contract::CompiledContract>>,
2196 schema_drift: Option<crate::drift::SchemaDriftPolicy>,
2197 resilience: Option<crate::resilience::ResiliencePolicy>,
2198 delivery: crate::idempotency::DeliveryMode,
2200 replay: Option<crate::idempotency::ReplayGuarantee>,
2204 codec: crate::state_version::StateCodec,
2206 resume: Option<SinkResume>,
2208 position_source: Option<Arc<dyn Source>>,
2210 source_label: String,
2213 upstream: HashSet<String>,
2215 failed: FailedNodes,
2217}
2218
2219fn count_node_run(mut labels: Vec<Label>, err: Option<&FaucetError>) {
2221 labels.push(Label::new(
2222 "status",
2223 SharedString::const_str(if err.is_some() { "err" } else { "ok" }),
2224 ));
2225 if let Some(e) = err {
2226 labels.push(Label::new(
2227 "kind",
2228 SharedString::const_str(crate::observability::decorator::error_kind(e)),
2229 ));
2230 }
2231 counter!("faucet_pipeline_runs_total", labels).increment(1);
2232}
2233
2234fn source_label(
2236 upstream: Option<&HashSet<String>>,
2237 kinds: &HashMap<String, &'static str>,
2238) -> String {
2239 let mut found: Vec<&str> = upstream
2240 .into_iter()
2241 .flatten()
2242 .filter_map(|id| kinds.get(id).copied())
2243 .collect();
2244 found.sort_unstable();
2245 found.dedup();
2246 match found.as_slice() {
2247 [one] => (*one).to_string(),
2248 [] => "unknown".to_string(),
2249 _ => "multiple".to_string(),
2250 }
2251}
2252
2253fn failed_upstream(failed: &FailedNodes, upstream: &HashSet<String>) -> Option<(String, String)> {
2255 let failed = failed.lock().unwrap_or_else(|p| p.into_inner());
2256 let mut hits: Vec<(&String, &(String, NodeErrorKind))> = failed
2257 .iter()
2258 .filter(|(id, _)| upstream.contains(*id))
2259 .collect();
2260 hits.sort_by(|a, b| a.0.cmp(b.0));
2261 hits.first()
2262 .map(|(id, (msg, _))| ((*id).clone(), msg.clone()))
2263}
2264
2265async fn run_sink_node(
2266 node_id: String,
2267 sink: Box<dyn Sink>,
2268 mut rx: mpsc::Receiver<StreamPage>,
2269 opts: SinkNodeOpts,
2270) -> Result<NodeOutcome, FaucetError> {
2271 let failed = Arc::clone(&opts.failed);
2272 let upstream = opts.upstream.clone();
2273 let position_source = opts.position_source.clone();
2274 let mut skip_through = opts.resume.as_ref().and_then(|r| r.skip_through.clone());
2275 let sink_id = node_id.clone();
2276 let pages = Box::pin(async_stream::stream! {
2277 loop {
2278 match rx.recv().await {
2279 Some(page) => {
2280 if let (Some(through), Some(src)) = (skip_through.as_ref(), position_source.as_ref()) {
2284 let committed = page
2285 .bookmark
2286 .as_ref()
2287 .and_then(|bm| src.position_le(bm, through))
2288 == Some(true);
2289 if committed {
2290 continue;
2291 }
2292 skip_through = None;
2293 }
2294 yield Ok::<StreamPage, FaucetError>(page);
2295 }
2296 None => {
2297 if let Some((node, error)) = failed_upstream(&failed, &upstream) {
2298 yield Err(FaucetError::Source(format!(
2299 "upstream node '{node}' failed, so sink '{sink_id}' did not \
2300 receive its complete input: {error}"
2301 )));
2302 }
2303 break;
2304 }
2305 }
2306 }
2307 });
2308
2309 let mut run_opts = RunStreamOptions::new()
2310 .with_name(opts.pipeline_name.clone())
2311 .with_row(node_id.clone())
2312 .with_run_id(opts.run_id.clone());
2313 let overwriting = sink.is_overwrite();
2314 let mut deferred = None;
2315 if let Some(store) = opts.state_store {
2316 let key = format!("{}::{}", opts.pipeline_name, node_id);
2317 let store: Arc<dyn StateStore> = Arc::new(crate::state_version::VersionedStateStore::new(
2318 store,
2319 key.clone(),
2320 opts.codec,
2321 ));
2322 if opts.delivery == crate::idempotency::DeliveryMode::ExactlyOnce {
2325 let seq = match opts.resume.as_ref() {
2326 Some(r) => r.start_seq,
2327 None => match store.get(&key).await? {
2328 Some(prior) => crate::idempotency::unwrap_state(&prior).1,
2329 None => 0,
2330 },
2331 };
2332 run_opts = run_opts.with_delivery(opts.delivery).with_start_seq(seq);
2333 if let Some(replay) = opts.replay {
2334 run_opts = run_opts.with_replay_guarantee(replay);
2335 }
2336 }
2337 if overwriting {
2338 deferred = Some(DeferredState {
2339 store: Arc::clone(&store),
2340 key: key.clone(),
2341 });
2342 }
2343 run_opts = run_opts.with_state(store, key);
2344 }
2345 if let Some(dlq) = opts.dlq {
2346 run_opts = run_opts.with_dlq(dlq);
2347 }
2348 run_opts = run_opts.with_cancel(opts.cancel);
2349 #[cfg(feature = "masking")]
2353 if let Some(m) = opts.masking {
2354 run_opts = run_opts.with_masking(m);
2355 }
2356 #[cfg(feature = "quality")]
2357 if let Some(q) = opts.quality {
2358 run_opts = run_opts.with_quality(q);
2359 }
2360 #[cfg(feature = "contract")]
2361 if let Some(c) = opts.contract {
2362 run_opts = run_opts.with_contract(c);
2363 }
2364 if let Some(d) = opts.schema_drift {
2365 run_opts.schema_drift = Some(d);
2366 }
2367 if let Some(r) = opts.resilience {
2368 run_opts.resilience = Some(r);
2369 }
2370
2371 if overwriting {
2376 sink.begin_overwrite().await?;
2377 }
2378 let labels = Labels::new(
2382 opts.pipeline_name.clone(),
2383 node_id.clone(),
2384 opts.run_id.clone(),
2385 );
2386 sink.set_roundtrip_recorder(Arc::new(crate::observability::RoundtripRecorder::new(
2387 crate::observability::RoundtripSide::Sink,
2388 opts.pipeline_name.clone(),
2389 node_id.clone(),
2390 sink.connector_name(),
2391 )));
2392 let run_labels = vec![
2393 Label::new("pipeline", SharedString::from(opts.pipeline_name.clone())),
2394 Label::new("row", SharedString::from(node_id.clone())),
2395 Label::new("source", SharedString::from(opts.source_label.clone())),
2396 Label::new("sink", SharedString::const_str(sink.connector_name())),
2397 ];
2398 let timer = crate::observability::DurationGuard::new(
2399 "faucet_pipeline_run_duration_seconds",
2400 run_labels.clone(),
2401 );
2402 let instrumented = crate::observability::InstrumentedSink::new(sink.as_ref(), labels);
2403 let outcome = run_stream(pages, &instrumented, run_opts).await;
2404 drop(instrumented);
2405 drop(timer);
2406 count_node_run(run_labels, outcome.as_ref().err());
2407 let result = match outcome {
2408 Ok(r) => r,
2409 Err(e) => {
2410 if overwriting {
2411 abort_quietly(sink.as_ref()).await;
2412 }
2413 return Err(e);
2414 }
2415 };
2416 Ok(NodeOutcome::Sink {
2417 node_id,
2418 records: result.records_written,
2419 bookmark: result.bookmark,
2420 sink,
2421 deferred,
2422 })
2423}
2424
2425#[derive(Default)]
2429pub struct TopologyBuilder {
2430 nodes: Vec<Node>,
2431 edges: Vec<Edge>,
2432}
2433
2434impl TopologyBuilder {
2435 pub fn node(mut self, id: impl Into<String>, kind: NodeKind) -> Self {
2437 self.nodes.push(Node {
2438 id: id.into(),
2439 kind,
2440 });
2441 self
2442 }
2443
2444 pub fn source(self, id: impl Into<String>, source: Box<dyn Source>) -> Self {
2446 self.node(id, NodeKind::Source(source))
2447 }
2448
2449 pub fn transform(self, id: impl Into<String>, stages: Vec<CompiledStage>) -> Self {
2451 self.node(id, NodeKind::Transform(stages))
2452 }
2453
2454 pub fn tee(self, id: impl Into<String>, capacity: usize, fanout: Option<usize>) -> Self {
2456 self.node(id, NodeKind::Tee { capacity, fanout })
2457 }
2458
2459 pub fn merge(self, id: impl Into<String>) -> Self {
2461 self.node(id, NodeKind::Merge)
2462 }
2463
2464 pub fn join(self, id: impl Into<String>, join: JoinNode) -> Self {
2466 self.node(id, NodeKind::Join(join))
2467 }
2468
2469 pub fn sink(self, id: impl Into<String>, sink: Box<dyn Sink>) -> Self {
2471 self.node(id, NodeKind::Sink(sink))
2472 }
2473
2474 pub fn edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
2476 self.edges.push(Edge {
2477 from: from.into(),
2478 to: to.into(),
2479 label: None,
2480 });
2481 self
2482 }
2483
2484 pub fn labelled_edge(
2486 mut self,
2487 from: impl Into<String>,
2488 to: impl Into<String>,
2489 label: impl Into<String>,
2490 ) -> Self {
2491 self.edges.push(Edge {
2492 from: from.into(),
2493 to: to.into(),
2494 label: Some(label.into()),
2495 });
2496 self
2497 }
2498
2499 pub fn build(self) -> Result<Topology, FaucetError> {
2501 let t = Topology {
2502 nodes: self.nodes,
2503 edges: self.edges,
2504 };
2505 t.validate()?;
2506 Ok(t)
2507 }
2508}
2509
2510#[cfg(test)]
2511mod tests {
2512 use super::*;
2513 use crate::join::{JoinConfig, JoinMode, Projection};
2514 use crate::state::MemoryStateStore;
2515 use async_trait::async_trait;
2516 use serde_json::json;
2517 use std::sync::Mutex;
2518
2519 pub(super) struct VecSource {
2522 records: Vec<Value>,
2523 bookmark: Option<Value>,
2524 }
2525 impl VecSource {
2526 pub(super) fn boxed(records: Vec<Value>) -> Box<dyn Source> {
2527 Box::new(VecSource {
2528 records,
2529 bookmark: None,
2530 })
2531 }
2532 fn boxed_bm(records: Vec<Value>, bm: Value) -> Box<dyn Source> {
2533 Box::new(VecSource {
2534 records,
2535 bookmark: Some(bm),
2536 })
2537 }
2538 }
2539 #[async_trait]
2540 impl Source for VecSource {
2541 async fn fetch_with_context(
2542 &self,
2543 _c: &std::collections::HashMap<String, Value>,
2544 ) -> Result<Vec<Value>, FaucetError> {
2545 Ok(self.records.clone())
2546 }
2547 async fn fetch_with_context_incremental(
2548 &self,
2549 _c: &std::collections::HashMap<String, Value>,
2550 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
2551 Ok((self.records.clone(), self.bookmark.clone()))
2552 }
2553 }
2554
2555 struct FailingSource;
2556 #[async_trait]
2557 impl Source for FailingSource {
2558 async fn fetch_with_context(
2559 &self,
2560 _c: &std::collections::HashMap<String, Value>,
2561 ) -> Result<Vec<Value>, FaucetError> {
2562 Err(FaucetError::Source("boom".into()))
2563 }
2564 }
2565
2566 struct RecordingSource {
2568 records: Vec<Value>,
2569 applied: Arc<Mutex<Option<Value>>>,
2570 }
2571 #[async_trait]
2572 impl Source for RecordingSource {
2573 async fn fetch_with_context(
2574 &self,
2575 _c: &std::collections::HashMap<String, Value>,
2576 ) -> Result<Vec<Value>, FaucetError> {
2577 Ok(self.records.clone())
2578 }
2579 async fn apply_start_bookmark(&self, bm: Value) -> Result<(), FaucetError> {
2580 *self.applied.lock().unwrap() = Some(bm);
2581 Ok(())
2582 }
2583 }
2584
2585 #[derive(Clone)]
2586 pub(super) struct CollectSink {
2587 store: Arc<Mutex<Vec<Value>>>,
2588 }
2589 impl CollectSink {
2590 pub(super) fn new() -> (Self, Arc<Mutex<Vec<Value>>>) {
2591 let store = Arc::new(Mutex::new(Vec::new()));
2592 (
2593 Self {
2594 store: store.clone(),
2595 },
2596 store,
2597 )
2598 }
2599 }
2600 #[async_trait]
2601 impl Sink for CollectSink {
2602 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2603 self.store.lock().unwrap().extend_from_slice(records);
2604 Ok(records.len())
2605 }
2606 }
2607
2608 pub(super) struct FailingSink;
2609 #[async_trait]
2610 impl Sink for FailingSink {
2611 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
2612 Err(FaucetError::Sink("sink boom".into()))
2613 }
2614 }
2615
2616 pub(super) struct BreakerSink;
2619 #[async_trait]
2620 impl Sink for BreakerSink {
2621 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
2622 Err(FaucetError::CircuitOpen {
2623 failures: 3,
2624 cooldown: std::time::Duration::from_secs(30),
2625 })
2626 }
2627 }
2628
2629 struct FlushTrackingSink {
2632 flushed: Arc<Mutex<bool>>,
2633 }
2634 #[async_trait]
2635 impl Sink for FlushTrackingSink {
2636 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2637 Ok(records.len())
2638 }
2639 async fn flush(&self) -> Result<(), FaucetError> {
2640 *self.flushed.lock().unwrap() = true;
2641 Ok(())
2642 }
2643 }
2644
2645 pub(super) fn recs(n: usize) -> Vec<Value> {
2646 (0..n).map(|i| json!({ "i": i })).collect()
2647 }
2648
2649 #[test]
2652 fn validate_rejects_a_source_that_consumes_destructively() {
2653 struct Queue;
2654 #[async_trait]
2655 impl Source for Queue {
2656 async fn fetch_with_context(
2657 &self,
2658 _: &std::collections::HashMap<String, Value>,
2659 ) -> Result<Vec<Value>, FaucetError> {
2660 Ok(Vec::new())
2661 }
2662 fn consumes_destructively(&self) -> bool {
2663 true
2664 }
2665 fn connector_name(&self) -> &'static str {
2666 "queue"
2667 }
2668 }
2669 let rt = tokio::runtime::Builder::new_current_thread()
2670 .build()
2671 .unwrap();
2672 assert!(rt.block_on(Queue.fetch_all()).unwrap().is_empty());
2673 let (sink, _) = CollectSink::new();
2674 let err = Topology::builder()
2675 .source("q", Box::new(Queue))
2676 .sink("out", Box::new(sink))
2677 .edge("q", "out")
2678 .build()
2679 .unwrap_err()
2680 .to_string();
2681 assert!(err.contains("source 'q' (queue)"), "{err}");
2682 }
2683
2684 #[test]
2685 fn validate_rejects_empty() {
2686 let err = Topology {
2687 nodes: vec![],
2688 edges: vec![],
2689 }
2690 .validate()
2691 .unwrap_err();
2692 assert!(err.to_string().contains("no nodes"));
2693 }
2694
2695 #[test]
2696 fn validate_rejects_duplicate_id() {
2697 let (sink, _) = CollectSink::new();
2698 let err = Topology::builder()
2699 .source("a", VecSource::boxed(recs(1)))
2700 .sink("a", Box::new(sink))
2701 .edge("a", "a")
2702 .build()
2703 .unwrap_err();
2704 assert!(err.to_string().contains("duplicate node id"));
2705 }
2706
2707 #[test]
2708 fn validate_rejects_unknown_endpoint() {
2709 let err = Topology::builder()
2710 .source("s", VecSource::boxed(recs(1)))
2711 .edge("s", "ghost")
2712 .build()
2713 .unwrap_err();
2714 assert!(err.to_string().contains("unknown 'to' node 'ghost'"));
2715 }
2716
2717 #[test]
2718 fn validate_rejects_unknown_from_endpoint() {
2719 let (sink, _) = CollectSink::new();
2720 let err = Topology::builder()
2721 .sink("k", Box::new(sink))
2722 .edge("ghost", "k")
2723 .build()
2724 .unwrap_err();
2725 assert!(err.to_string().contains("unknown 'from' node 'ghost'"));
2726 }
2727
2728 #[test]
2729 fn validate_rejects_source_with_incoming_edge() {
2730 let (sink, _) = CollectSink::new();
2731 let err = Topology::builder()
2732 .source("s", VecSource::boxed(recs(1)))
2733 .sink("k", Box::new(sink))
2734 .edge("s", "k")
2735 .edge("k", "s") .build()
2737 .unwrap_err();
2738 assert!(err.to_string().contains("arity") || err.to_string().contains("cycle"));
2740 }
2741
2742 #[test]
2743 fn validate_rejects_tee_fanout_mismatch() {
2744 let (s1, _) = CollectSink::new();
2745 let (s2, _) = CollectSink::new();
2746 let err = Topology::builder()
2747 .source("s", VecSource::boxed(recs(1)))
2748 .tee("t", 4, Some(3))
2749 .sink("a", Box::new(s1))
2750 .sink("b", Box::new(s2))
2751 .edge("s", "t")
2752 .edge("t", "a")
2753 .edge("t", "b")
2754 .build()
2755 .unwrap_err();
2756 assert!(err.to_string().contains("fanout 3 but has 2"));
2757 }
2758
2759 #[test]
2760 fn validate_rejects_tee_with_one_output() {
2761 let (s1, _) = CollectSink::new();
2762 let err = Topology::builder()
2763 .source("s", VecSource::boxed(recs(1)))
2764 .tee("t", 4, None)
2765 .sink("a", Box::new(s1))
2766 .edge("s", "t")
2767 .edge("t", "a")
2768 .build()
2769 .unwrap_err();
2770 assert!(err.to_string().contains("tee 't'"));
2771 }
2772
2773 #[test]
2774 fn validate_rejects_merge_with_one_input() {
2775 let (s1, _) = CollectSink::new();
2776 let err = Topology::builder()
2777 .source("s", VecSource::boxed(recs(1)))
2778 .merge("m")
2779 .sink("a", Box::new(s1))
2780 .edge("s", "m")
2781 .edge("m", "a")
2782 .build()
2783 .unwrap_err();
2784 assert!(err.to_string().contains("merge 'm'"));
2785 }
2786
2787 #[test]
2788 fn validate_rejects_join_missing_label() {
2789 let (s1, _) = CollectSink::new();
2790 let jn = JoinNode {
2791 config: JoinConfig::default(),
2792 build_edge: "build".into(),
2793 probe_edge: "probe".into(),
2794 };
2795 let err = Topology::builder()
2796 .source("b", VecSource::boxed(recs(1)))
2797 .source("p", VecSource::boxed(recs(1)))
2798 .join("j", jn)
2799 .sink("a", Box::new(s1))
2800 .labelled_edge("b", "j", "build")
2801 .edge("p", "j") .edge("j", "a")
2803 .build()
2804 .unwrap_err();
2805 assert!(err.to_string().contains("labelled 'probe'"));
2806 }
2807
2808 #[test]
2809 fn validate_rejects_join_same_labels() {
2810 let (s1, _) = CollectSink::new();
2811 let jn = JoinNode {
2812 config: JoinConfig::default(),
2813 build_edge: "x".into(),
2814 probe_edge: "x".into(),
2815 };
2816 let err = Topology::builder()
2817 .source("b", VecSource::boxed(recs(1)))
2818 .source("p", VecSource::boxed(recs(1)))
2819 .join("j", jn)
2820 .sink("a", Box::new(s1))
2821 .labelled_edge("b", "j", "x")
2822 .labelled_edge("p", "j", "x")
2823 .edge("j", "a")
2824 .build()
2825 .unwrap_err();
2826 assert!(err.to_string().contains("must differ"));
2827 }
2828
2829 #[test]
2830 fn validate_rejects_cycle() {
2831 let (sink, _) = CollectSink::new();
2834 let err = Topology::builder()
2835 .source("s", VecSource::boxed(recs(1)))
2836 .merge("m")
2837 .tee("t", 4, None)
2838 .sink("k", Box::new(sink))
2839 .edge("s", "m")
2840 .edge("m", "t")
2841 .edge("t", "m")
2842 .edge("t", "k")
2843 .build()
2844 .unwrap_err();
2845 assert!(err.to_string().contains("cycle"), "{err}");
2846 }
2847
2848 #[test]
2849 fn validate_rejects_no_source() {
2850 let err = Topology::builder()
2852 .transform("t1", vec![])
2853 .transform("t2", vec![])
2854 .edge("t1", "t2")
2855 .edge("t2", "t1")
2856 .build()
2857 .unwrap_err();
2858 assert!(err.to_string().contains("no source"), "{err}");
2859 }
2860
2861 #[test]
2862 fn validate_rejects_no_sink() {
2863 let err = Topology::builder()
2865 .source("s1", VecSource::boxed(recs(1)))
2866 .source("s2", VecSource::boxed(recs(1)))
2867 .merge("m")
2868 .edge("s1", "m")
2869 .edge("s2", "m")
2870 .edge("m", "m")
2871 .build()
2872 .unwrap_err();
2873 assert!(err.to_string().contains("no sink"), "{err}");
2874 }
2875
2876 #[tokio::test]
2879 async fn simple_source_to_sink() {
2880 let (sink, store) = CollectSink::new();
2881 let topo = Topology::builder()
2882 .source("s", VecSource::boxed(recs(5)))
2883 .sink("k", Box::new(sink))
2884 .edge("s", "k")
2885 .build()
2886 .unwrap();
2887 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
2888 assert_eq!(result.records_written, 5);
2889 assert_eq!(store.lock().unwrap().len(), 5);
2890 assert_eq!(result.per_sink.get("k"), Some(&5));
2891 }
2892
2893 #[tokio::test]
2894 async fn source_transform_sink() {
2895 let (sink, store) = CollectSink::new();
2896 let topo = Topology::builder()
2897 .source("s", VecSource::boxed(recs(3)))
2898 .transform("t", vec![]) .sink("k", Box::new(sink))
2900 .edge("s", "t")
2901 .edge("t", "k")
2902 .build()
2903 .unwrap();
2904 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
2905 assert_eq!(result.records_written, 3);
2906 assert_eq!(store.lock().unwrap().len(), 3);
2907 }
2908
2909 #[tokio::test]
2910 async fn tee_fans_out_to_three_sinks() {
2911 let (s1, st1) = CollectSink::new();
2912 let (s2, st2) = CollectSink::new();
2913 let (s3, st3) = CollectSink::new();
2914 let topo = Topology::builder()
2915 .source("s", VecSource::boxed(recs(10)))
2916 .tee("t", 4, Some(3))
2917 .sink("a", Box::new(s1))
2918 .sink("b", Box::new(s2))
2919 .sink("c", Box::new(s3))
2920 .edge("s", "t")
2921 .edge("t", "a")
2922 .edge("t", "b")
2923 .edge("t", "c")
2924 .build()
2925 .unwrap();
2926 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
2927 assert_eq!(st1.lock().unwrap().len(), 10);
2928 assert_eq!(st2.lock().unwrap().len(), 10);
2929 assert_eq!(st3.lock().unwrap().len(), 10);
2930 assert_eq!(result.records_written, 30);
2931 }
2932
2933 #[tokio::test]
2934 async fn merge_fans_in_two_sources() {
2935 let (sink, store) = CollectSink::new();
2936 let topo = Topology::builder()
2937 .source("s1", VecSource::boxed(recs(4)))
2938 .source("s2", VecSource::boxed(recs(6)))
2939 .merge("m")
2940 .sink("k", Box::new(sink))
2941 .edge("s1", "m")
2942 .edge("s2", "m")
2943 .edge("m", "k")
2944 .build()
2945 .unwrap();
2946 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
2947 assert_eq!(result.records_written, 10);
2948 assert_eq!(store.lock().unwrap().len(), 10);
2949 }
2950
2951 #[tokio::test]
2952 async fn join_enriches_end_to_end() {
2953 let (sink, store) = CollectSink::new();
2954 let customers = vec![
2955 json!({"id": 1, "tier": "gold"}),
2956 json!({"id": 2, "tier": "silver"}),
2957 ];
2958 let orders = vec![
2959 json!({"order": "A", "cust": 1}),
2960 json!({"order": "B", "cust": 2}),
2961 json!({"order": "C", "cust": 99}),
2962 ];
2963 let jn = JoinNode {
2964 config: JoinConfig {
2965 mode: JoinMode::Inner,
2966 build_key: "id".into(),
2967 probe_key: "cust".into(),
2968 projections: vec![Projection {
2969 from: "tier".into(),
2970 as_: "tier".into(),
2971 }],
2972 ..Default::default()
2973 },
2974 build_edge: "customers".into(),
2975 probe_edge: "orders".into(),
2976 };
2977 let topo = Topology::builder()
2978 .source("c", VecSource::boxed(customers))
2979 .source("o", VecSource::boxed(orders))
2980 .join("j", jn)
2981 .sink("k", Box::new(sink))
2982 .labelled_edge("c", "j", "customers")
2983 .labelled_edge("o", "j", "orders")
2984 .edge("j", "k")
2985 .build()
2986 .unwrap();
2987 let result = topo.run(TopologyOptions::new("p")).await.unwrap();
2988 assert_eq!(result.records_written, 2);
2990 let written = store.lock().unwrap();
2991 assert!(
2992 written
2993 .iter()
2994 .any(|r| r["order"] == json!("A") && r["tier"] == json!("gold"))
2995 );
2996 }
2997
2998 #[tokio::test]
2999 #[allow(clippy::await_holding_lock)]
3000 async fn source_and_sink_nodes_are_instrumented() {
3001 use metrics_util::debugging::DebugValue;
3002 let _g = crate::observability::decorator::source_tests::LOCK
3003 .lock()
3004 .unwrap_or_else(|e| e.into_inner());
3005 let snap = crate::observability::decorator::source_tests::snapshotter();
3006 let probe = crate::sink_forwarding::HookSink::default();
3007 let topo = Topology::builder()
3008 .source("src-node", VecSource::boxed(recs(3)))
3009 .sink("sink-node", Box::new(probe.clone()))
3010 .edge("src-node", "sink-node")
3011 .build()
3012 .unwrap();
3013 topo.run(TopologyOptions::new("topo-metrics"))
3014 .await
3015 .unwrap();
3016 assert!(probe.reached().contains(&"set_roundtrip_recorder"));
3017 type Counter = (String, Vec<(String, String)>, u64);
3018 let counters: Vec<Counter> = snap
3019 .snapshot()
3020 .into_vec()
3021 .into_iter()
3022 .filter_map(|(k, _, _, v)| match v {
3023 DebugValue::Counter(c) => Some((
3024 k.key().name().to_string(),
3025 k.key()
3026 .labels()
3027 .map(|l| (l.key().to_string(), l.value().to_string()))
3028 .collect::<Vec<_>>(),
3029 c,
3030 )),
3031 _ => None,
3032 })
3033 .filter(|(_, l, _)| l.contains(&("pipeline".into(), "topo-metrics".into())))
3034 .collect();
3035 let has = |name: &str, label: (&str, &str), n: u64| {
3036 counters.iter().any(|(m, l, c)| {
3037 m == name && l.contains(&(label.0.into(), label.1.into())) && *c == n
3038 })
3039 };
3040 assert!(
3041 has("faucet_source_records_total", ("row", "src-node"), 3),
3042 "{counters:?}"
3043 );
3044 assert!(
3045 has("faucet_sink_records_total", ("row", "sink-node"), 3),
3046 "{counters:?}"
3047 );
3048 assert!(
3049 has("faucet_pipeline_runs_total", ("source", "VecSource"), 1),
3050 "{counters:?}"
3051 );
3052 assert!(
3053 has("faucet_pipeline_runs_total", ("status", "ok"), 1),
3054 "{counters:?}"
3055 );
3056 }
3057
3058 #[test]
3059 fn a_node_fed_by_several_sources_is_labelled_multiple() {
3060 let kinds: HashMap<String, &'static str> = [
3061 ("a".to_string(), "rest"),
3062 ("b".to_string(), "csv"),
3063 ("c".to_string(), "rest"),
3064 ]
3065 .into();
3066 let up = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::<HashSet<_>>();
3067 assert_eq!(source_label(Some(&up(&["a", "t"])), &kinds), "rest");
3068 assert_eq!(source_label(Some(&up(&["a", "c"])), &kinds), "rest");
3069 assert_eq!(source_label(Some(&up(&["a", "b"])), &kinds), "multiple");
3070 assert_eq!(source_label(None, &kinds), "unknown");
3071 }
3072
3073 #[tokio::test]
3074 async fn a_state_read_error_fails_the_run_instead_of_replaying() {
3075 struct Unreadable;
3076 #[async_trait]
3077 impl StateStore for Unreadable {
3078 async fn get(&self, _k: &str) -> Result<Option<Value>, FaucetError> {
3079 Err(FaucetError::State("store unreachable".into()))
3080 }
3081 async fn put(&self, _k: &str, _v: &Value) -> Result<(), FaucetError> {
3082 Ok(())
3083 }
3084 async fn delete(&self, _k: &str) -> Result<(), FaucetError> {
3085 Ok(())
3086 }
3087 }
3088 let (sink, store) = CollectSink::new();
3089 let topo = Topology::builder()
3090 .source("s", VecSource::boxed(recs(2)))
3091 .sink("k", Box::new(sink))
3092 .edge("s", "k")
3093 .build()
3094 .unwrap();
3095 let mut opts = TopologyOptions::new("p");
3096 opts.state_store = Some(Arc::new(Unreadable));
3097 let err = topo.run(opts).await.unwrap_err();
3098 assert!(err.to_string().contains("store unreachable"), "{err}");
3099 assert!(store.lock().unwrap().is_empty());
3100 }
3101
3102 #[tokio::test]
3103 async fn a_failed_build_side_is_never_probed() {
3104 struct HalfSource;
3106 #[async_trait]
3107 impl Source for HalfSource {
3108 async fn fetch_with_context(
3109 &self,
3110 _c: &std::collections::HashMap<String, Value>,
3111 ) -> Result<Vec<Value>, FaucetError> {
3112 Ok(Vec::new())
3113 }
3114 fn stream_pages<'a>(
3115 &'a self,
3116 _c: &'a std::collections::HashMap<String, Value>,
3117 _b: usize,
3118 ) -> Pin<Box<dyn crate::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>>
3119 {
3120 Box::pin(futures::stream::iter(vec![
3121 Ok(StreamPage {
3122 records: vec![json!({"id": 1, "tier": "gold"})],
3123 bookmark: None,
3124 }),
3125 Err(FaucetError::Source("build side broke".into())),
3126 ]))
3127 }
3128 }
3129 for on_error in [TopologyOnError::Continue, TopologyOnError::Propagate] {
3130 let (sink, store) = CollectSink::new();
3131 let jn = JoinNode {
3132 config: JoinConfig {
3133 mode: JoinMode::Left,
3134 build_key: "id".into(),
3135 probe_key: "cust".into(),
3136 projections: vec![Projection {
3137 from: "tier".into(),
3138 as_: "tier".into(),
3139 }],
3140 ..Default::default()
3141 },
3142 build_edge: "customers".into(),
3143 probe_edge: "orders".into(),
3144 };
3145 let topo = Topology::builder()
3146 .source("c", Box::new(HalfSource))
3147 .source(
3148 "o",
3149 VecSource::boxed(vec![
3150 json!({"order": "A", "cust": 1}),
3151 json!({"order": "B", "cust": 2}),
3152 ]),
3153 )
3154 .join("j", jn)
3155 .sink("k", Box::new(sink))
3156 .labelled_edge("c", "j", "customers")
3157 .labelled_edge("o", "j", "orders")
3158 .edge("j", "k")
3159 .build()
3160 .unwrap();
3161 let mut opts = TopologyOptions::new("p");
3162 opts.on_error = on_error;
3163 let (run, err) = topo
3164 .run_attributed(opts, TopologyGovernance::default())
3165 .await;
3166 let errors = format!("{err:?} {:?}", run.result.errors);
3167 assert!(errors.contains("build side broke"), "{errors}");
3168 assert!(
3169 store.lock().unwrap().is_empty(),
3170 "{on_error:?}: nothing probed"
3171 );
3172 }
3173 }
3174
3175 #[tokio::test]
3176 async fn propagate_aborts_on_sink_failure() {
3177 let topo = Topology::builder()
3178 .source("s", VecSource::boxed(recs(3)))
3179 .sink("k", Box::new(FailingSink))
3180 .edge("s", "k")
3181 .build()
3182 .unwrap();
3183 let err = topo.run(TopologyOptions::new("p")).await.unwrap_err();
3184 assert!(matches!(err, FaucetError::Sink(_)));
3185 }
3186
3187 #[tokio::test]
3188 async fn propagate_aborts_on_source_failure() {
3189 let (sink, _) = CollectSink::new();
3190 let topo = Topology::builder()
3191 .source("s", Box::new(FailingSource))
3192 .sink("k", Box::new(sink))
3193 .edge("s", "k")
3194 .build()
3195 .unwrap();
3196 let err = topo.run(TopologyOptions::new("p")).await.unwrap_err();
3197 assert!(matches!(err, FaucetError::Source(_)));
3198 }
3199
3200 struct CompletingSink(Arc<Mutex<u32>>);
3201 #[async_trait]
3202 impl Sink for CompletingSink {
3203 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3204 Ok(records.len())
3205 }
3206 async fn complete_run(&self) -> Result<(), FaucetError> {
3207 *self.0.lock().unwrap() += 1;
3208 Ok(())
3209 }
3210 }
3211
3212 #[tokio::test]
3213 async fn complete_run_fires_only_for_a_fully_successful_graph() {
3214 let done = Arc::new(Mutex::new(0));
3215 let topo = Topology::builder()
3216 .source("s", VecSource::boxed(recs(2)))
3217 .sink("k", Box::new(CompletingSink(done.clone())))
3218 .edge("s", "k")
3219 .build()
3220 .unwrap();
3221 topo.run(TopologyOptions::new("p")).await.unwrap();
3222 assert_eq!(*done.lock().unwrap(), 1);
3223
3224 let failed = Arc::new(Mutex::new(0));
3225 let topo = Topology::builder()
3226 .source("s", Box::new(FailingSource))
3227 .sink("k", Box::new(CompletingSink(failed.clone())))
3228 .edge("s", "k")
3229 .build()
3230 .unwrap();
3231 assert!(topo.run(TopologyOptions::new("p")).await.is_err());
3232 assert_eq!(*failed.lock().unwrap(), 0);
3233
3234 let partial = Arc::new(Mutex::new(0));
3235 let topo = Topology::builder()
3236 .source("s", VecSource::boxed(recs(4)))
3237 .tee("t", 4, Some(2))
3238 .sink("bad", Box::new(FailingSink))
3239 .sink("good", Box::new(CompletingSink(partial.clone())))
3240 .edge("s", "t")
3241 .edge("t", "bad")
3242 .edge("t", "good")
3243 .build()
3244 .unwrap();
3245 let opts = TopologyOptions::new("p").with_on_error(TopologyOnError::Continue);
3246 topo.run(opts).await.unwrap();
3247 assert_eq!(*partial.lock().unwrap(), 0);
3248
3249 let cancelled = Arc::new(Mutex::new(0));
3250 let token = CancellationToken::new();
3251 token.cancel();
3252 let topo = Topology::builder()
3253 .source("s", VecSource::boxed(recs(2)))
3254 .sink("k", Box::new(CompletingSink(cancelled.clone())))
3255 .edge("s", "k")
3256 .build()
3257 .unwrap();
3258 let _ = topo.run(TopologyOptions::new("p").with_cancel(token)).await;
3259 assert_eq!(*cancelled.lock().unwrap(), 0);
3260 }
3261
3262 #[tokio::test]
3263 async fn continue_lets_healthy_branch_finish() {
3264 let (good, store) = CollectSink::new();
3266 let topo = Topology::builder()
3267 .source("s", VecSource::boxed(recs(8)))
3268 .tee("t", 8, Some(2))
3269 .sink("bad", Box::new(FailingSink))
3270 .sink("good", Box::new(good))
3271 .edge("s", "t")
3272 .edge("t", "bad")
3273 .edge("t", "good")
3274 .build()
3275 .unwrap();
3276 let opts = TopologyOptions::new("p").with_on_error(TopologyOnError::Continue);
3277 let result = topo.run(opts).await.unwrap();
3278 assert_eq!(store.lock().unwrap().len(), 8);
3279 assert!(!result.errors.is_empty(), "failing sink should be recorded");
3280 }
3281
3282 #[tokio::test]
3283 async fn state_agreeing_bookmarks_resume_the_source() {
3284 let store = Arc::new(MemoryStateStore::new());
3285 store.put("p::a", &json!(100)).await.unwrap();
3287 store.put("p::b", &json!(100)).await.unwrap();
3288 let applied = Arc::new(Mutex::new(None));
3289 let src = RecordingSource {
3290 records: recs(1),
3291 applied: applied.clone(),
3292 };
3293 let (s1, _) = CollectSink::new();
3294 let (s2, _) = CollectSink::new();
3295 let topo = Topology::builder()
3296 .source("s", Box::new(src))
3297 .tee("t", 4, Some(2))
3298 .sink("a", Box::new(s1))
3299 .sink("b", Box::new(s2))
3300 .edge("s", "t")
3301 .edge("t", "a")
3302 .edge("t", "b")
3303 .build()
3304 .unwrap();
3305 let opts = TopologyOptions::new("p").with_state_store(store.clone());
3306 topo.run(opts).await.unwrap();
3307 assert_eq!(*applied.lock().unwrap(), Some(json!(100)));
3308 }
3309
3310 #[tokio::test]
3311 async fn state_diverged_bookmarks_replay_in_full() {
3312 let store = Arc::new(MemoryStateStore::new());
3316 store.put("p::a", &json!(250)).await.unwrap();
3317 store.put("p::b", &json!(100)).await.unwrap();
3318 let applied = Arc::new(Mutex::new(None));
3319 let src = RecordingSource {
3320 records: recs(1),
3321 applied: applied.clone(),
3322 };
3323 let (s1, _) = CollectSink::new();
3324 let (s2, _) = CollectSink::new();
3325 let topo = Topology::builder()
3326 .source("s", Box::new(src))
3327 .tee("t", 4, Some(2))
3328 .sink("a", Box::new(s1))
3329 .sink("b", Box::new(s2))
3330 .edge("s", "t")
3331 .edge("t", "a")
3332 .edge("t", "b")
3333 .build()
3334 .unwrap();
3335 let opts = TopologyOptions::new("p").with_state_store(store.clone());
3336 topo.run(opts).await.unwrap();
3337 assert_eq!(
3338 *applied.lock().unwrap(),
3339 None,
3340 "diverged bookmarks must replay, never guess an order"
3341 );
3342 }
3343
3344 #[tokio::test]
3345 async fn state_multi_source_never_cross_applies_a_bookmark() {
3346 let store = Arc::new(MemoryStateStore::new());
3350 store.put("p::k", &json!(500)).await.unwrap();
3351 let a_applied = Arc::new(Mutex::new(None));
3352 let b_applied = Arc::new(Mutex::new(None));
3353 let a = RecordingSource {
3354 records: recs(1),
3355 applied: a_applied.clone(),
3356 };
3357 let b = RecordingSource {
3358 records: recs(1),
3359 applied: b_applied.clone(),
3360 };
3361 let (sink, _) = CollectSink::new();
3362 let topo = Topology::builder()
3363 .source("a", Box::new(a))
3364 .source("b", Box::new(b))
3365 .merge("m")
3366 .sink("k", Box::new(sink))
3367 .edge("a", "m")
3368 .edge("b", "m")
3369 .edge("m", "k")
3370 .build()
3371 .unwrap();
3372 let opts = TopologyOptions::new("p").with_state_store(store.clone());
3373 topo.run(opts).await.unwrap();
3374 assert_eq!(*a_applied.lock().unwrap(), None);
3375 assert_eq!(*b_applied.lock().unwrap(), None);
3376 }
3377
3378 #[tokio::test]
3379 async fn state_no_bookmark_when_a_sink_is_missing() {
3380 let store = Arc::new(MemoryStateStore::new());
3381 store.put("p::a", &json!(100)).await.unwrap();
3382 let applied = Arc::new(Mutex::new(None));
3384 let src = RecordingSource {
3385 records: recs(1),
3386 applied: applied.clone(),
3387 };
3388 let (s1, _) = CollectSink::new();
3389 let (s2, _) = CollectSink::new();
3390 let topo = Topology::builder()
3391 .source("s", Box::new(src))
3392 .tee("t", 4, Some(2))
3393 .sink("a", Box::new(s1))
3394 .sink("b", Box::new(s2))
3395 .edge("s", "t")
3396 .edge("t", "a")
3397 .edge("t", "b")
3398 .build()
3399 .unwrap();
3400 let opts = TopologyOptions::new("p").with_state_store(store.clone());
3401 topo.run(opts).await.unwrap();
3402 assert_eq!(*applied.lock().unwrap(), None);
3403 }
3404
3405 #[tokio::test]
3406 async fn sink_persists_bookmark() {
3407 let store = Arc::new(MemoryStateStore::new());
3408 let (sink, _) = CollectSink::new();
3409 let topo = Topology::builder()
3410 .source("s", VecSource::boxed_bm(recs(2), json!("v9")))
3411 .sink("k", Box::new(sink))
3412 .edge("s", "k")
3413 .build()
3414 .unwrap();
3415 let opts = TopologyOptions::new("p").with_state_store(store.clone());
3416 let result = topo.run(opts).await.unwrap();
3417 assert_eq!(result.bookmarks.get("k"), Some(&Some(json!("v9"))));
3418 assert_eq!(
3419 store
3420 .get("p::k")
3421 .await
3422 .unwrap()
3423 .map(|v| crate::state_version::peel_versioned(&v)),
3424 Some(json!("v9"))
3425 );
3426 }
3427
3428 #[tokio::test]
3432 async fn propagate_lets_siblings_flush_before_returning_the_error() {
3433 let flushed = Arc::new(Mutex::new(false));
3434 let tracker = FlushTrackingSink {
3435 flushed: flushed.clone(),
3436 };
3437 let topo = Topology::builder()
3438 .source("s", VecSource::boxed(recs(64)))
3439 .tee("t", 4, Some(2))
3440 .sink("bad", Box::new(FailingSink))
3441 .sink("good", Box::new(tracker))
3442 .edge("s", "t")
3443 .edge("t", "bad")
3444 .edge("t", "good")
3445 .build()
3446 .unwrap();
3447 let opts = TopologyOptions::new("p").with_on_error(TopologyOnError::Propagate);
3448 let err = topo.run(opts).await.unwrap_err();
3449 assert!(matches!(err, FaucetError::Sink(_)), "{err:?}");
3450 assert!(
3451 *flushed.lock().unwrap(),
3452 "the healthy sink node must be flushed, not dropped mid-write"
3453 );
3454 }
3455
3456 #[cfg(feature = "masking")]
3459 #[tokio::test]
3460 async fn masking_applies_to_a_sink_node() {
3461 use crate::masking::{CompiledMasking, MaskingSpec};
3462
3463 let spec: MaskingSpec = serde_json::from_value(json!({
3464 "rules": [{
3465 "name": "hide-email",
3466 "match": { "fields": ["email"] },
3467 "action": { "type": "redact", "mask": "***" }
3468 }]
3469 }))
3470 .unwrap();
3471 let compiled = Arc::new(CompiledMasking::compile(&spec).unwrap());
3472
3473 let (sink, store) = CollectSink::new();
3474 let topo = Topology::builder()
3475 .source(
3476 "s",
3477 VecSource::boxed(vec![json!({"id": 1, "email": "a@b.c"})]),
3478 )
3479 .sink("k", Box::new(sink))
3480 .edge("s", "k")
3481 .build()
3482 .unwrap();
3483
3484 let mut governance = TopologyGovernance::new();
3485 governance.masking_by_sink.insert("k".to_string(), compiled);
3486 topo.run_with(TopologyOptions::new("p"), governance)
3487 .await
3488 .unwrap();
3489
3490 let written = store.lock().unwrap();
3491 assert_eq!(written.len(), 1);
3492 assert_eq!(written[0]["email"], json!("***"), "PII must be masked");
3493 assert_eq!(written[0]["id"], json!(1));
3494 }
3495
3496 #[tokio::test]
3497 async fn cancellation_stops_the_run() {
3498 let cancel = CancellationToken::new();
3499 cancel.cancel(); let (sink, store) = CollectSink::new();
3501 let topo = Topology::builder()
3502 .source("s", VecSource::boxed(recs(1000)))
3503 .sink("k", Box::new(sink))
3504 .edge("s", "k")
3505 .build()
3506 .unwrap();
3507 let opts = TopologyOptions::new("p").with_cancel(cancel);
3508 let result = topo.run(opts).await.unwrap();
3509 assert!(store.lock().unwrap().len() < 1000);
3511 let _ = result;
3512 }
3513
3514 #[test]
3515 fn start_bookmark_only_resumes_when_provably_safe() {
3516 assert_eq!(
3518 start_bookmark(&[json!(100), json!(100)], 1),
3519 Some(json!(100))
3520 );
3521 let lsn = json!({"slot": "s", "lsn": "0/16B3748"});
3523 assert_eq!(
3524 start_bookmark(&[lsn.clone(), lsn.clone()], 1),
3525 Some(lsn.clone())
3526 );
3527
3528 assert_eq!(start_bookmark(&[json!(250), json!(100)], 1), None);
3532 assert_eq!(
3535 start_bookmark(
3536 &[json!({"lsn": "0/9FFFFFF"}), json!({"lsn": "0/10000000"}),],
3537 1
3538 ),
3539 None
3540 );
3541
3542 assert_eq!(start_bookmark(&[json!(100), json!(100)], 2), None);
3544 assert_eq!(start_bookmark(&[], 1), None);
3546 assert_eq!(start_bookmark(&[], 3), None);
3547 }
3548
3549 #[test]
3550 fn kind_str_matches() {
3551 assert_eq!(NodeKind::Merge.kind_str(), "merge");
3552 assert_eq!(
3553 NodeKind::Tee {
3554 capacity: 1,
3555 fanout: None
3556 }
3557 .kind_str(),
3558 "tee"
3559 );
3560 }
3561
3562 #[test]
3563 fn builder_exposes_nodes_and_edges() {
3564 let (sink, _) = CollectSink::new();
3565 let topo = Topology::builder()
3566 .source("s", VecSource::boxed(recs(1)))
3567 .sink("k", Box::new(sink))
3568 .edge("s", "k")
3569 .build()
3570 .unwrap();
3571 assert_eq!(topo.nodes().len(), 2);
3572 assert_eq!(topo.edges().len(), 1);
3573 }
3574
3575 #[cfg(feature = "transform-keys-case")]
3576 #[tokio::test]
3577 async fn transform_node_applies_stage() {
3578 use crate::stage::{TransformStage, compile_stage};
3579 use crate::transform::{KeyCaseMode, RecordTransform};
3580 let stage = compile_stage(&TransformStage::Map(RecordTransform::KeysCase {
3581 mode: KeyCaseMode::Snake,
3582 on_collision: Default::default(),
3583 }))
3584 .unwrap();
3585 let (sink, store) = CollectSink::new();
3586 let topo = Topology::builder()
3587 .source("s", VecSource::boxed(vec![json!({"FooBar": 1})]))
3588 .transform("t", vec![stage])
3589 .sink("k", Box::new(sink))
3590 .edge("s", "t")
3591 .edge("t", "k")
3592 .build()
3593 .unwrap();
3594 topo.run(TopologyOptions::new("p")).await.unwrap();
3595 let w = store.lock().unwrap();
3596 assert!(w[0].get("foo_bar").is_some());
3597 }
3598}
3599
3600#[cfg(test)]
3601mod delivery_and_report_tests {
3602 use super::tests::{BreakerSink, CollectSink, FailingSink, VecSource, recs};
3603 use super::*;
3604 use crate::Stream;
3605 use crate::idempotency::{DeliveryMode, format_token_with_bookmark, wrap_state};
3606 use crate::state::{MemoryStateStore, StateStore};
3607 use async_trait::async_trait;
3608 use serde_json::json;
3609 use std::sync::Mutex;
3610
3611 struct TokenSink {
3613 rows: Arc<Mutex<Vec<Value>>>,
3614 tokens: Arc<Mutex<std::collections::HashMap<String, String>>>,
3615 }
3616 #[async_trait]
3617 impl Sink for TokenSink {
3618 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3619 self.rows.lock().unwrap().extend_from_slice(records);
3620 Ok(records.len())
3621 }
3622 fn supports_idempotent_writes(&self) -> bool {
3623 true
3624 }
3625 async fn write_batch_idempotent(
3626 &self,
3627 records: &[Value],
3628 scope: &str,
3629 token: &str,
3630 ) -> Result<usize, FaucetError> {
3631 self.rows.lock().unwrap().extend_from_slice(records);
3632 self.tokens
3633 .lock()
3634 .unwrap()
3635 .insert(scope.to_string(), token.to_string());
3636 Ok(records.len())
3637 }
3638 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
3639 Ok(self.tokens.lock().unwrap().get(scope).cloned())
3640 }
3641 }
3642
3643 #[tokio::test]
3647 async fn exactly_once_commits_a_token_per_sink_node_scope() {
3648 let rows = Arc::new(Mutex::new(Vec::new()));
3649 let tokens = Arc::new(Mutex::new(std::collections::HashMap::new()));
3650 let sink = TokenSink {
3651 rows: rows.clone(),
3652 tokens: tokens.clone(),
3653 };
3654 let store = Arc::new(MemoryStateStore::new());
3655 let topo = Topology::builder()
3656 .source("s", Box::new(EoSource(recs(3))))
3657 .sink("k", Box::new(sink))
3658 .edge("s", "k")
3659 .build()
3660 .unwrap();
3661
3662 let mut gov = TopologyGovernance::new();
3663 gov.delivery = DeliveryMode::ExactlyOnce;
3664 let opts = TopologyOptions::new("p").with_state_store(store.clone());
3665 topo.run_with(opts, gov).await.unwrap();
3666
3667 assert_eq!(rows.lock().unwrap().len(), 3);
3668 let committed = tokens.lock().unwrap();
3669 assert!(
3670 committed.contains_key("p::k"),
3671 "token must be scoped to the sink node's own state key, got {:?}",
3672 committed.keys().collect::<Vec<_>>()
3673 );
3674 }
3675
3676 struct EoSource(Vec<Value>);
3679 #[async_trait]
3680 impl Source for EoSource {
3681 async fn fetch_with_context(
3682 &self,
3683 _ctx: &std::collections::HashMap<String, Value>,
3684 ) -> Result<Vec<Value>, FaucetError> {
3685 Ok(self.0.clone())
3686 }
3687 fn stream_pages<'a>(
3688 &'a self,
3689 _ctx: &'a std::collections::HashMap<String, Value>,
3690 _batch: usize,
3691 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
3692 let rows = self.0.clone();
3693 Box::pin(async_stream::try_stream! {
3694 yield StreamPage { records: rows, bookmark: Some(json!({"pos": 1})) };
3695 })
3696 }
3697 fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
3698 crate::idempotency::ReplayGuarantee::Deterministic
3699 }
3700 fn supports_exactly_once(&self) -> bool {
3701 true
3702 }
3703 }
3704
3705 #[test]
3708 fn eo_resume_picks_the_lowest_sequence() {
3709 let a = (7u64, Some(json!({"pos": 7})));
3710 let b = (4u64, Some(json!({"pos": 4})));
3711 let c = (9u64, Some(json!({"pos": 9})));
3712 assert_eq!(
3713 eo_start_bookmark(&[a.clone(), b.clone(), c.clone()], 1),
3714 Some(json!({"pos": 4})),
3715 "resume from the laggard, not the leader"
3716 );
3717 assert_eq!(eo_start_bookmark(&[a, b, c], 2), None);
3719 assert_eq!(eo_start_bookmark(&[], 1), None);
3720 }
3721
3722 #[tokio::test]
3725 async fn eo_resume_unwraps_the_state_envelope() {
3726 let store = Arc::new(MemoryStateStore::new());
3727 store
3728 .put("p::k", &wrap_state(Some(&json!({"pos": 5})), 5))
3729 .await
3730 .unwrap();
3731 let opts = TopologyOptions::new("p").with_state_store(store.clone());
3732 let bm = compute_start_bookmark(
3733 &opts,
3734 &["k".to_string()],
3735 1,
3736 DeliveryMode::ExactlyOnce,
3737 None,
3738 )
3739 .await
3740 .unwrap();
3741 assert_eq!(bm, Some(json!({"pos": 5})), "envelope must be unwrapped");
3742 let t = format_token_with_bookmark(5, Some(&json!({"pos": 5})));
3744 assert!(t.contains('#'), "token embeds the bookmark: {t}");
3745 }
3746
3747 #[test]
3749 fn node_error_kind_classifies_only_the_breaker() {
3750 assert_eq!(
3753 NodeErrorKind::classify(&FaucetError::CircuitOpen {
3754 failures: 3,
3755 cooldown: std::time::Duration::from_secs(30),
3756 }),
3757 NodeErrorKind::CircuitOpen
3758 );
3759 assert_eq!(
3761 NodeErrorKind::classify(&FaucetError::Sink(
3762 "Circuit open after 3 consecutive failures".into()
3763 )),
3764 NodeErrorKind::Other
3765 );
3766 }
3767
3768 #[tokio::test]
3769 async fn a_breaker_trip_is_reported_on_the_node_that_raised_it_only() {
3770 let (healthy, _) = CollectSink::new();
3775 let topo = Topology::builder()
3776 .source("s", VecSource::boxed(recs(4)))
3777 .tee("t", 4, Some(2))
3778 .sink("tripped", Box::new(BreakerSink))
3779 .sink("healthy", Box::new(healthy))
3780 .edge("s", "t")
3781 .edge("t", "tripped")
3782 .edge("t", "healthy")
3783 .build()
3784 .unwrap();
3785
3786 let run = topo
3787 .run_reported(
3788 TopologyOptions::new("p").with_on_error(TopologyOnError::Continue),
3789 TopologyGovernance::new(),
3790 )
3791 .await
3792 .unwrap();
3793
3794 let tripped = run.nodes.iter().find(|n| n.node_id == "tripped").unwrap();
3795 assert_eq!(
3796 tripped.error_kind,
3797 Some(NodeErrorKind::CircuitOpen),
3798 "the node that raised the trip must carry the class that earns the cooldown"
3799 );
3800
3801 let classified: Vec<&str> = run
3802 .nodes
3803 .iter()
3804 .filter(|n| n.error_kind == Some(NodeErrorKind::CircuitOpen))
3805 .map(|n| n.node_id.as_str())
3806 .collect();
3807 assert_eq!(
3808 classified,
3809 vec!["tripped"],
3810 "exactly one node may be classified as the breaker trip"
3811 );
3812 let healthy = run.nodes.iter().find(|n| n.node_id == "healthy").unwrap();
3815 assert_eq!(healthy.error_kind, None);
3816 }
3817
3818 #[tokio::test]
3820 async fn run_reported_attributes_failures_to_their_node() {
3821 let (good, _) = CollectSink::new();
3822 let topo = Topology::builder()
3823 .source("s", VecSource::boxed(recs(4)))
3824 .tee("t", 4, Some(2))
3825 .sink("bad", Box::new(FailingSink))
3826 .sink("good", Box::new(good))
3827 .edge("s", "t")
3828 .edge("t", "bad")
3829 .edge("t", "good")
3830 .build()
3831 .unwrap();
3832 let run = topo
3833 .run_reported(
3834 TopologyOptions::new("p").with_on_error(TopologyOnError::Continue),
3835 TopologyGovernance::new(),
3836 )
3837 .await
3838 .unwrap();
3839
3840 let bad = run.nodes.iter().find(|n| n.node_id == "bad").unwrap();
3841 assert!(bad.error.is_some(), "the failing sink is attributed");
3842 assert_eq!(
3843 bad.error_kind,
3844 Some(NodeErrorKind::Other),
3845 "an ordinary sink failure is classified, just not as a breaker trip"
3846 );
3847 let good = run.nodes.iter().find(|n| n.node_id == "good").unwrap();
3848 assert!(good.error.is_none(), "the healthy sink is not");
3849 assert_eq!(good.records, 4);
3850 assert_eq!(run.nodes.len(), 4);
3852 assert!(run.nodes.iter().any(|n| n.kind == "source"));
3853 assert!(run.nodes.iter().any(|n| n.kind == "tee"));
3854 }
3855}
3856
3857#[cfg(test)]
3858mod graph_safety_tests {
3859 use super::tests::{CollectSink, FailingSink, VecSource, recs};
3860 use super::*;
3861 use crate::Stream;
3862 use crate::idempotency::{DeliveryMode, format_token_with_bookmark, wrap_state};
3863 use crate::state::MemoryStateStore;
3864 use async_trait::async_trait;
3865 use serde_json::json;
3866 use std::sync::Mutex;
3867
3868 struct LogSource {
3872 total: u64,
3873 per_page: u64,
3874 ordered: bool,
3875 start: Mutex<u64>,
3876 applied: Arc<Mutex<Option<Value>>>,
3877 }
3878 impl LogSource {
3879 fn new(total: u64, per_page: u64, ordered: bool) -> (Self, Arc<Mutex<Option<Value>>>) {
3880 let applied = Arc::new(Mutex::new(None));
3881 (
3882 Self {
3883 total,
3884 per_page,
3885 ordered,
3886 start: Mutex::new(0),
3887 applied: applied.clone(),
3888 },
3889 applied,
3890 )
3891 }
3892 }
3893 #[async_trait]
3894 impl Source for LogSource {
3895 async fn fetch_with_context(
3896 &self,
3897 _c: &HashMap<String, Value>,
3898 ) -> Result<Vec<Value>, FaucetError> {
3899 Ok(Vec::new())
3900 }
3901 fn stream_pages<'a>(
3902 &'a self,
3903 _ctx: &'a HashMap<String, Value>,
3904 _batch: usize,
3905 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
3906 let mut next = *self.start.lock().unwrap() + 1;
3907 let (total, per_page) = (self.total, self.per_page);
3908 Box::pin(async_stream::try_stream! {
3909 while next <= total {
3910 let last = (next + per_page - 1).min(total);
3911 let records: Vec<Value> = (next..=last).map(|n| json!({ "n": n })).collect();
3912 yield StreamPage { records, bookmark: Some(json!({ "pos": last })) };
3913 next = last + 1;
3914 }
3915 })
3916 }
3917 async fn apply_start_bookmark(&self, bm: Value) -> Result<(), FaucetError> {
3918 *self.start.lock().unwrap() = bm["pos"].as_u64().unwrap_or(0);
3919 *self.applied.lock().unwrap() = Some(bm);
3920 Ok(())
3921 }
3922 fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
3923 crate::idempotency::ReplayGuarantee::Deterministic
3924 }
3925 fn supports_exactly_once(&self) -> bool {
3926 true
3927 }
3928 fn position_le(&self, a: &Value, b: &Value) -> Option<bool> {
3929 if !self.ordered {
3930 return None;
3931 }
3932 Some(a["pos"].as_u64()? <= b["pos"].as_u64()?)
3933 }
3934 }
3935
3936 #[derive(Clone)]
3938 struct TokenSink {
3939 rows: Arc<Mutex<Vec<Value>>>,
3940 tokens: Arc<Mutex<HashMap<String, String>>>,
3941 }
3942 impl TokenSink {
3943 fn new() -> Self {
3944 Self {
3945 rows: Arc::new(Mutex::new(Vec::new())),
3946 tokens: Arc::new(Mutex::new(HashMap::new())),
3947 }
3948 }
3949 fn committed(self, scope: &str, seq: u64, pos: u64) -> Self {
3950 self.tokens.lock().unwrap().insert(
3951 scope.into(),
3952 format_token_with_bookmark(seq, Some(&json!({ "pos": pos }))),
3953 );
3954 self
3955 }
3956 fn ns(&self) -> Vec<u64> {
3957 self.rows
3958 .lock()
3959 .unwrap()
3960 .iter()
3961 .filter_map(|r| r["n"].as_u64())
3962 .collect()
3963 }
3964 }
3965 #[async_trait]
3966 impl Sink for TokenSink {
3967 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3968 self.rows.lock().unwrap().extend_from_slice(records);
3969 Ok(records.len())
3970 }
3971 fn supports_idempotent_writes(&self) -> bool {
3972 true
3973 }
3974 async fn write_batch_idempotent(
3975 &self,
3976 records: &[Value],
3977 scope: &str,
3978 token: &str,
3979 ) -> Result<usize, FaucetError> {
3980 self.rows.lock().unwrap().extend_from_slice(records);
3981 self.tokens
3982 .lock()
3983 .unwrap()
3984 .insert(scope.to_string(), token.to_string());
3985 Ok(records.len())
3986 }
3987 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
3988 Ok(self.tokens.lock().unwrap().get(scope).cloned())
3989 }
3990 }
3991
3992 async fn seed(store: &MemoryStateStore, key: &str, pos: u64, seq: u64) {
3993 store
3994 .put(key, &wrap_state(Some(&json!({ "pos": pos })), seq))
3995 .await
3996 .unwrap();
3997 }
3998
3999 async fn stored_seq(store: &MemoryStateStore, key: &str) -> u64 {
4000 let v = store.get(key).await.unwrap().unwrap();
4001 crate::idempotency::unwrap_state(&crate::state_version::peel_versioned(&v)).1
4002 }
4003
4004 fn eo() -> TopologyGovernance {
4005 let mut g = TopologyGovernance::new();
4006 g.delivery = DeliveryMode::ExactlyOnce;
4007 g
4008 }
4009
4010 fn two_sink_graph(source: LogSource, ahead: TokenSink, behind: TokenSink) -> Topology {
4011 Topology::builder()
4012 .source("s", Box::new(source))
4013 .tee("t", 4, Some(2))
4014 .sink("ahead", Box::new(ahead))
4015 .sink("behind", Box::new(behind))
4016 .edge("s", "t")
4017 .edge("t", "ahead")
4018 .edge("t", "behind")
4019 .build()
4020 .unwrap()
4021 }
4022
4023 #[tokio::test]
4028 async fn eo_diverged_sinks_never_rewrite_committed_pages() {
4029 let store = Arc::new(MemoryStateStore::new());
4030 seed(&store, "p::ahead", 9, 9).await;
4031 seed(&store, "p::behind", 5, 5).await;
4032 let ahead = TokenSink::new().committed("p::ahead", 9, 9);
4033 let behind = TokenSink::new().committed("p::behind", 5, 5);
4034 let (source, applied) = LogSource::new(10, 1, false);
4035 let topo = two_sink_graph(source, ahead.clone(), behind.clone());
4036
4037 topo.run_with(
4038 TopologyOptions::new("p").with_state_store(store.clone()),
4039 eo(),
4040 )
4041 .await
4042 .unwrap();
4043
4044 assert_eq!(*applied.lock().unwrap(), Some(json!({ "pos": 5 })));
4045 assert_eq!(ahead.ns(), vec![10], "pages 6..=9 were already committed");
4046 assert_eq!(behind.ns(), vec![6, 7, 8, 9, 10]);
4047 assert_eq!(stored_seq(&store, "p::ahead").await, 10);
4048 assert_eq!(stored_seq(&store, "p::behind").await, 10);
4049 }
4050
4051 #[tokio::test]
4055 async fn eo_diverged_sinks_skip_by_position_when_the_source_orders_them() {
4056 let store = Arc::new(MemoryStateStore::new());
4057 seed(&store, "p::ahead", 9, 9).await;
4058 seed(&store, "p::behind", 5, 5).await;
4059 let ahead = TokenSink::new().committed("p::ahead", 9, 9);
4060 let behind = TokenSink::new().committed("p::behind", 5, 5);
4061 let (source, applied) = LogSource::new(10, 2, true);
4062 let topo = two_sink_graph(source, ahead.clone(), behind.clone());
4063
4064 topo.run_with(
4065 TopologyOptions::new("p").with_state_store(store.clone()),
4066 eo(),
4067 )
4068 .await
4069 .unwrap();
4070
4071 assert_eq!(*applied.lock().unwrap(), Some(json!({ "pos": 5 })));
4072 assert_eq!(ahead.ns(), vec![10]);
4073 assert_eq!(behind.ns(), vec![6, 7, 8, 9, 10]);
4074 assert_eq!(stored_seq(&store, "p::ahead").await, 10);
4075 }
4076
4077 #[tokio::test]
4081 async fn eo_resume_reanchors_from_the_sinks_committed_token() {
4082 let store = Arc::new(MemoryStateStore::new());
4083 seed(&store, "p::k", 4, 4).await;
4084 let sink = TokenSink::new().committed("p::k", 5, 7);
4085 let (source, applied) = LogSource::new(9, 1, false);
4086 let topo = Topology::builder()
4087 .source("s", Box::new(source))
4088 .sink("k", Box::new(sink.clone()))
4089 .edge("s", "k")
4090 .build()
4091 .unwrap();
4092
4093 topo.run_with(
4094 TopologyOptions::new("p").with_state_store(store.clone()),
4095 eo(),
4096 )
4097 .await
4098 .unwrap();
4099
4100 assert_eq!(*applied.lock().unwrap(), Some(json!({ "pos": 7 })));
4101 assert_eq!(sink.ns(), vec![8, 9]);
4102 assert_eq!(stored_seq(&store, "p::k").await, 7);
4103 }
4104
4105 #[test]
4106 fn eo_plan_without_a_committed_position_replays_from_zero() {
4107 let (src, _) = LogSource::new(1, 1, false);
4108 let plan = plan_exactly_once(
4109 &[
4110 ("a".into(), Some(json!({ "pos": 3 })), 3),
4111 ("b".into(), None, 0),
4112 ],
4113 1,
4114 Some(&src),
4115 );
4116 assert_eq!(plan.start, None);
4117 assert!(plan.sinks.values().all(|r| r.start_seq == 0));
4118
4119 let (ordered, _) = LogSource::new(1, 1, true);
4120 let plan = plan_exactly_once(
4121 &[
4122 ("a".into(), Some(json!({ "pos": 3 })), 3),
4123 ("b".into(), None, 0),
4124 ],
4125 1,
4126 Some(&ordered),
4127 );
4128 assert_eq!(plan.start, None);
4129 assert_eq!(plan.sinks["a"].skip_through, Some(json!({ "pos": 3 })));
4130 assert_eq!(plan.sinks["a"].start_seq, 3);
4131 assert_eq!(plan.sinks["b"].skip_through, None);
4132 }
4133
4134 #[test]
4135 fn eo_plan_for_several_sources_keeps_each_sinks_sequence() {
4136 let plan = plan_exactly_once(
4137 &[
4138 ("a".into(), Some(json!({ "pos": 3 })), 3),
4139 ("b".into(), Some(json!({ "pos": 4 })), 4),
4140 ],
4141 2,
4142 None,
4143 );
4144 assert_eq!(plan.start, None);
4145 assert_eq!(plan.sinks["a"].start_seq, 3);
4146 assert_eq!(plan.sinks["b"].start_seq, 4);
4147 }
4148
4149 #[test]
4150 fn eo_plan_agreeing_sinks_resume_without_skipping() {
4151 let plan = plan_exactly_once(
4152 &[
4153 ("a".into(), Some(json!({ "pos": 3 })), 3),
4154 ("b".into(), Some(json!({ "pos": 3 })), 6),
4155 ],
4156 1,
4157 None,
4158 );
4159 assert_eq!(plan.start, Some(json!({ "pos": 3 })));
4160 assert_eq!(plan.sinks["b"].start_seq, 6);
4161 assert!(plan.sinks.values().all(|r| r.skip_through.is_none()));
4162 }
4163
4164 #[test]
4167 fn validate_rejects_a_join_fed_by_one_tee_on_both_sides() {
4168 let jn = JoinNode {
4169 config: JoinConfig {
4170 mode: JoinMode::Inner,
4171 build_key: "i".into(),
4172 probe_key: "i".into(),
4173 ..Default::default()
4174 },
4175 build_edge: "b".into(),
4176 probe_edge: "p".into(),
4177 };
4178 let (sink, _) = CollectSink::new();
4179 let err = Topology::builder()
4180 .source("s", VecSource::boxed(recs(1)))
4181 .tee("t", 4, Some(2))
4182 .join("j", jn)
4183 .sink("k", Box::new(sink))
4184 .edge("s", "t")
4185 .labelled_edge("t", "j", "b")
4186 .labelled_edge("t", "j", "p")
4187 .edge("j", "k")
4188 .build()
4189 .unwrap_err()
4190 .to_string();
4191 assert!(err.contains("join 'j'"), "{err}");
4192 assert!(err.contains("'s'") || err.contains("'t'"), "{err}");
4193 }
4194
4195 #[tokio::test]
4196 async fn dropping_the_run_future_aborts_its_node_tasks() {
4197 struct Wedged {
4198 alive: Arc<()>,
4199 entered: Arc<tokio::sync::Notify>,
4200 }
4201 #[async_trait::async_trait]
4202 impl Sink for Wedged {
4203 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
4204 let _hold = Arc::clone(&self.alive);
4205 self.entered.notify_one();
4206 std::future::pending().await
4207 }
4208 }
4209 let alive = Arc::new(());
4210 let entered = Arc::new(tokio::sync::Notify::new());
4211 let topology = Topology::builder()
4212 .source("s", VecSource::boxed(recs(2)))
4213 .sink(
4214 "k",
4215 Box::new(Wedged {
4216 alive: Arc::clone(&alive),
4217 entered: Arc::clone(&entered),
4218 }),
4219 )
4220 .edge("s", "k")
4221 .build()
4222 .unwrap();
4223 let run = tokio::spawn(topology.run(TopologyOptions::default()));
4224 entered.notified().await;
4225 run.abort();
4226 let _ = run.await;
4227 tokio::time::timeout(Duration::from_secs(5), async {
4228 while Arc::strong_count(&alive) > 1 {
4229 tokio::time::sleep(Duration::from_millis(10)).await;
4230 }
4231 })
4232 .await
4233 .expect("the wedged sink node must be aborted with the run");
4234 }
4235
4236 #[tokio::test]
4237 async fn broadcast_gives_up_on_a_full_channel_when_cancelled() {
4238 let (tx, mut rx) = mpsc::channel::<StreamPage>(1);
4239 tx.send(StreamPage {
4240 records: Vec::new(),
4241 bookmark: None,
4242 })
4243 .await
4244 .unwrap();
4245 let cancel = CancellationToken::new();
4246 let trip = cancel.clone();
4247 tokio::spawn(async move {
4248 tokio::time::sleep(Duration::from_millis(20)).await;
4249 trip.cancel();
4250 });
4251 let mut outs = vec![tx.clone(), tx];
4252 let sent = tokio::time::timeout(
4253 Duration::from_secs(5),
4254 broadcast(
4255 StreamPage {
4256 records: recs(1),
4257 bookmark: None,
4258 },
4259 &mut outs,
4260 &cancel,
4261 ),
4262 )
4263 .await
4264 .expect("a cancelled broadcast must not wait on a full channel");
4265 assert!(!sent);
4266 assert!(rx.recv().await.is_some());
4267 }
4268
4269 #[tokio::test]
4270 async fn broadcast_drops_closed_outputs() {
4271 let (live_tx, mut live_rx) = mpsc::channel::<StreamPage>(4);
4272 let (dead_tx, dead_rx) = mpsc::channel::<StreamPage>(4);
4273 drop(dead_rx);
4274 let mut outs = vec![dead_tx, live_tx];
4275 let cancel = CancellationToken::new();
4276 let page = StreamPage {
4277 records: recs(2),
4278 bookmark: None,
4279 };
4280 assert!(broadcast(page, &mut outs, &cancel).await);
4281 assert_eq!(outs.len(), 1);
4282 assert_eq!(live_rx.recv().await.unwrap().records.len(), 2);
4283 let mut none: Vec<mpsc::Sender<StreamPage>> = Vec::new();
4284 let page = StreamPage {
4285 records: Vec::new(),
4286 bookmark: None,
4287 };
4288 assert!(!broadcast(page, &mut none, &cancel).await);
4289 }
4290
4291 #[tokio::test]
4292 async fn a_cancelled_receive_returns_none() {
4293 let (_tx, mut rx) = mpsc::channel::<StreamPage>(1);
4294 let cancel = CancellationToken::new();
4295 cancel.cancel();
4296 assert!(recv_or_cancel(&mut rx, &cancel).await.is_none());
4297 }
4298
4299 #[test]
4302 fn merge_mode_follows_the_inputs_origins() {
4303 let set = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::<HashSet<_>>();
4304 assert_eq!(
4305 merge_mode(&[set(&["a"]), set(&["b"])]),
4306 MergeMode::Passthrough
4307 );
4308 assert_eq!(merge_mode(&[set(&["a"]), set(&["a"])]), MergeMode::Gated);
4309 assert_eq!(
4310 merge_mode(&[set(&["a", "b"]), set(&["a"])]),
4311 MergeMode::Strip
4312 );
4313 }
4314
4315 #[test]
4316 fn bookmark_gate_releases_a_position_only_once_every_input_delivered_it() {
4317 let mut gate = BookmarkGate::new(2);
4318 assert_eq!(gate.arrive(0, json!(1)), None);
4319 assert_eq!(gate.arrive(0, json!(2)), None);
4320 assert_eq!(gate.arrive(1, json!(1)), Some(json!(1)));
4321 assert_eq!(gate.arrive(1, json!(2)), Some(json!(2)));
4322 assert_eq!(gate.arrive(1, json!(3)), None);
4323 assert_eq!(gate.close(0), Some(json!(3)));
4325 assert_eq!(gate.close(1), None);
4326 }
4327
4328 #[test]
4329 fn graph_facts_gate_a_merge_that_rejoins_one_source() {
4330 let (k, _) = CollectSink::new();
4331 let topo = Topology::builder()
4332 .source("s", VecSource::boxed(recs(1)))
4333 .tee("t", 4, Some(2))
4334 .transform("x", Vec::new())
4335 .transform("y", Vec::new())
4336 .merge("m")
4337 .sink("k", Box::new(k))
4338 .edge("s", "t")
4339 .edge("t", "x")
4340 .edge("t", "y")
4341 .edge("x", "m")
4342 .edge("y", "m")
4343 .edge("m", "k")
4344 .build()
4345 .unwrap();
4346 let facts = GraphFacts::of(topo.nodes(), topo.edges());
4347 assert_eq!(facts.merge_modes["m"], MergeMode::Gated);
4348 assert!(facts.ancestors["k"].contains("s"));
4349 assert!(facts.ancestors["k"].contains("x"));
4350 }
4351
4352 struct LoggingStore {
4355 inner: MemoryStateStore,
4356 log: Arc<Mutex<Vec<String>>>,
4357 }
4358 #[async_trait]
4359 impl StateStore for LoggingStore {
4360 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
4361 self.inner.get(key).await
4362 }
4363 async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
4364 let data = crate::state_version::peel_versioned(value);
4365 self.log
4366 .lock()
4367 .unwrap()
4368 .push(format!("put {}", data["pos"].as_u64().unwrap_or(0)));
4369 self.inner.put(key, value).await
4370 }
4371 async fn delete(&self, key: &str) -> Result<(), FaucetError> {
4372 self.inner.delete(key).await
4373 }
4374 }
4375
4376 struct LoggingSink {
4377 log: Arc<Mutex<Vec<String>>>,
4378 }
4379 #[async_trait]
4380 impl Sink for LoggingSink {
4381 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4382 let mut log = self.log.lock().unwrap();
4383 for r in records {
4384 log.push(format!("row {}", r["n"].as_u64().unwrap_or(0)));
4385 }
4386 Ok(records.len())
4387 }
4388 }
4389
4390 #[tokio::test]
4393 async fn a_diamond_persists_a_position_only_after_both_copies_landed() {
4394 let log = Arc::new(Mutex::new(Vec::new()));
4395 let store = Arc::new(LoggingStore {
4396 inner: MemoryStateStore::new(),
4397 log: log.clone(),
4398 });
4399 let (source, _) = LogSource::new(6, 1, false);
4400 let topo = Topology::builder()
4401 .source("s", Box::new(source))
4402 .tee("t", 1, Some(2))
4403 .transform("x", Vec::new())
4404 .transform("y", Vec::new())
4405 .merge("m")
4406 .sink("k", Box::new(LoggingSink { log: log.clone() }))
4407 .edge("s", "t")
4408 .edge("t", "x")
4409 .edge("t", "y")
4410 .edge("x", "m")
4411 .edge("y", "m")
4412 .edge("m", "k")
4413 .build()
4414 .unwrap();
4415 topo.run(TopologyOptions::new("p").with_state_store(store))
4416 .await
4417 .unwrap();
4418
4419 let log = log.lock().unwrap().clone();
4420 for (i, entry) in log.iter().enumerate() {
4421 let Some(pos) = entry.strip_prefix("put ") else {
4422 continue;
4423 };
4424 let copies = log[..i]
4425 .iter()
4426 .filter(|e| **e == format!("row {pos}"))
4427 .count();
4428 assert_eq!(
4429 copies, 2,
4430 "position {pos} persisted before both copies: {log:?}"
4431 );
4432 }
4433 assert_eq!(log.iter().filter(|e| e.starts_with("row")).count(), 12);
4434 assert!(log.iter().any(|e| e == "put 6"), "{log:?}");
4435 }
4436
4437 struct OverwriteSink {
4440 log: Arc<Mutex<Vec<String>>>,
4441 fail_commit: bool,
4442 }
4443 #[async_trait]
4444 impl Sink for OverwriteSink {
4445 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4446 self.log.lock().unwrap().push("write".into());
4447 Ok(records.len())
4448 }
4449 fn is_overwrite(&self) -> bool {
4450 true
4451 }
4452 async fn begin_overwrite(&self) -> Result<(), FaucetError> {
4453 self.log.lock().unwrap().push("begin".into());
4454 Ok(())
4455 }
4456 async fn commit_overwrite(&self) -> Result<(), FaucetError> {
4457 if self.fail_commit {
4458 return Err(FaucetError::Sink("swap failed".into()));
4459 }
4460 self.log.lock().unwrap().push("commit".into());
4461 Ok(())
4462 }
4463 async fn abort_overwrite(&self) -> Result<(), FaucetError> {
4464 self.log.lock().unwrap().push("abort".into());
4465 Ok(())
4466 }
4467 async fn complete_run(&self) -> Result<(), FaucetError> {
4468 self.log.lock().unwrap().push("complete".into());
4469 Ok(())
4470 }
4471 }
4472
4473 #[tokio::test]
4474 async fn an_overwrite_sink_node_commits_then_persists_its_position() {
4475 let log = Arc::new(Mutex::new(Vec::new()));
4476 let store = Arc::new(LoggingStore {
4477 inner: MemoryStateStore::new(),
4478 log: log.clone(),
4479 });
4480 let (source, _) = LogSource::new(3, 1, false);
4481 let topo = Topology::builder()
4482 .source("s", Box::new(source))
4483 .sink(
4484 "k",
4485 Box::new(OverwriteSink {
4486 log: log.clone(),
4487 fail_commit: false,
4488 }),
4489 )
4490 .edge("s", "k")
4491 .build()
4492 .unwrap();
4493 topo.run(TopologyOptions::new("p").with_state_store(store))
4494 .await
4495 .unwrap();
4496 let log = log.lock().unwrap().clone();
4497 assert_eq!(
4498 log,
4499 vec![
4500 "begin", "write", "write", "write", "commit", "put 3", "complete"
4501 ],
4502 "staged, swapped, then the position — never before the swap"
4503 );
4504 }
4505
4506 #[tokio::test]
4507 async fn an_overwrite_sink_node_discards_staging_when_another_node_fails() {
4508 let log = Arc::new(Mutex::new(Vec::new()));
4509 let topo = Topology::builder()
4510 .source("s", VecSource::boxed(recs(3)))
4511 .tee("t", 4, Some(2))
4512 .sink(
4513 "k",
4514 Box::new(OverwriteSink {
4515 log: log.clone(),
4516 fail_commit: false,
4517 }),
4518 )
4519 .sink("bad", Box::new(FailingSink))
4520 .edge("s", "t")
4521 .edge("t", "k")
4522 .edge("t", "bad")
4523 .build()
4524 .unwrap();
4525 let run = topo
4526 .run_reported(
4527 TopologyOptions::new("p").with_on_error(TopologyOnError::Continue),
4528 TopologyGovernance::new(),
4529 )
4530 .await
4531 .unwrap();
4532 assert!(!run.result.errors.is_empty());
4533 let log = log.lock().unwrap().clone();
4534 assert!(log.contains(&"abort".to_string()), "{log:?}");
4535 assert!(!log.contains(&"commit".to_string()), "{log:?}");
4536 }
4537
4538 #[tokio::test]
4539 async fn an_overwrite_sink_node_discards_staging_on_cancel() {
4540 let log = Arc::new(Mutex::new(Vec::new()));
4541 let cancel = CancellationToken::new();
4542 cancel.cancel();
4543 let topo = Topology::builder()
4544 .source("s", VecSource::boxed(recs(3)))
4545 .sink(
4546 "k",
4547 Box::new(OverwriteSink {
4548 log: log.clone(),
4549 fail_commit: false,
4550 }),
4551 )
4552 .edge("s", "k")
4553 .build()
4554 .unwrap();
4555 topo.run(TopologyOptions::new("p").with_cancel(cancel))
4556 .await
4557 .unwrap();
4558 let log = log.lock().unwrap().clone();
4559 assert_eq!(log.first().map(String::as_str), Some("begin"));
4560 assert!(log.contains(&"abort".to_string()), "{log:?}");
4561 assert!(!log.contains(&"commit".to_string()), "{log:?}");
4562 }
4563
4564 #[tokio::test]
4565 async fn a_failed_swap_fails_the_run_and_discards_the_other_staging() {
4566 let first = Arc::new(Mutex::new(Vec::new()));
4567 let second = Arc::new(Mutex::new(Vec::new()));
4568 let topo = Topology::builder()
4569 .source("s", VecSource::boxed(recs(2)))
4570 .tee("t", 4, Some(2))
4571 .sink(
4572 "a",
4573 Box::new(OverwriteSink {
4574 log: first.clone(),
4575 fail_commit: true,
4576 }),
4577 )
4578 .sink(
4579 "b",
4580 Box::new(OverwriteSink {
4581 log: second.clone(),
4582 fail_commit: false,
4583 }),
4584 )
4585 .edge("s", "t")
4586 .edge("t", "a")
4587 .edge("t", "b")
4588 .build()
4589 .unwrap();
4590 let err = topo
4591 .run(TopologyOptions::new("p"))
4592 .await
4593 .unwrap_err()
4594 .to_string();
4595 assert!(err.contains("swap failed"), "{err}");
4596 let second = second.lock().unwrap().clone();
4597 assert!(second.contains(&"abort".to_string()), "{second:?}");
4598 assert!(!second.contains(&"commit".to_string()), "{second:?}");
4599 }
4600
4601 struct BrokenSource;
4604 #[async_trait]
4605 impl Source for BrokenSource {
4606 async fn fetch_with_context(
4607 &self,
4608 _c: &HashMap<String, Value>,
4609 ) -> Result<Vec<Value>, FaucetError> {
4610 Err(FaucetError::Source("source down".into()))
4611 }
4612 }
4613
4614 #[tokio::test]
4615 async fn a_sink_fed_by_a_failed_source_is_reported_failed() {
4616 let (sink, _) = CollectSink::new();
4617 let topo = Topology::builder()
4618 .source("src", Box::new(BrokenSource))
4619 .transform("x", Vec::new())
4620 .sink("k", Box::new(sink))
4621 .edge("src", "x")
4622 .edge("x", "k")
4623 .build()
4624 .unwrap();
4625 let run = topo
4626 .run_reported(
4627 TopologyOptions::new("p").with_on_error(TopologyOnError::Continue),
4628 TopologyGovernance::new(),
4629 )
4630 .await
4631 .unwrap();
4632 let k = run.nodes.iter().find(|n| n.node_id == "k").unwrap();
4633 let err = k.error.as_deref().unwrap_or_default();
4634 assert!(err.contains("upstream node 'src' failed"), "{err}");
4635 assert_eq!(k.error_kind, Some(NodeErrorKind::Other));
4636 }
4637
4638 #[tokio::test]
4639 async fn run_attributed_keeps_the_per_node_report_on_failure() {
4640 let (sink, _) = CollectSink::new();
4641 let topo = Topology::builder()
4642 .source("src", Box::new(BrokenSource))
4643 .sink("k", Box::new(sink))
4644 .edge("src", "k")
4645 .build()
4646 .unwrap();
4647 let (run, err) = topo
4648 .run_attributed(TopologyOptions::new("p"), TopologyGovernance::new())
4649 .await;
4650 assert!(err.is_some(), "Propagate surfaces the failure");
4651 let k = run.nodes.iter().find(|n| n.node_id == "k").unwrap();
4652 assert!(k.error.is_some(), "the sink is reported, not dropped");
4653 let src = run.nodes.iter().find(|n| n.node_id == "src").unwrap();
4654 assert!(
4655 src.error
4656 .as_deref()
4657 .unwrap_or_default()
4658 .contains("source down")
4659 );
4660 }
4661
4662 #[tokio::test]
4663 async fn run_attributed_reports_an_invalid_graph() {
4664 let (run, err) = Topology {
4665 nodes: Vec::new(),
4666 edges: Vec::new(),
4667 }
4668 .run_attributed(TopologyOptions::new("p"), TopologyGovernance::new())
4669 .await;
4670 assert!(err.is_some());
4671 assert!(run.nodes.is_empty());
4672 }
4673}