Skip to main content

faucet_core/
topology.rs

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