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//!
40//! Resuming is deliberately conservative, because the only safe direction to err
41//! is *replay* (duplicates) and never *skip* (loss) — see [`start_bookmark`]:
42//!
43//! - **One source node only.** With two or more sources there is no way to tell
44//!   which source a given sink's bookmark came from, so applying one to all of
45//!   them would resume a source at a position that is not its own. Multi-source
46//!   graphs therefore replay in full.
47//! - **Comparable, agreeing bookmarks only.** Sink bookmarks are compared for
48//!   equality, not ordered. Resume positions are frequently structured (CDC LSN
49//!   maps, Kafka offset maps), and [`json_gt`](crate::replication::json_gt)'s
50//!   object arm orders by *serialized
51//!   text*, which is not the replication order — so a "minimum" picked that way
52//!   can sit ahead of the true minimum and skip records. Divergent bookmarks
53//!   therefore replay in full rather than guess.
54//!
55//! ## Governance passes
56//!
57//! Sink nodes reuse [`run_stream`], so the masking / quality / contract /
58//! schema-drift passes and the resilience policy apply exactly as they do to a
59//! single-source pipeline — supply them via
60//! [`Topology::run_with`]. Masking is destination-scoped and is
61//! therefore keyed by sink node id.
62
63use crate::dlq::DlqConfig;
64use crate::error::FaucetError;
65use crate::join::HashJoin;
66use crate::observability::{Labels, RunStreamOptions, instrumented_apply_stages};
67use crate::pipeline::{DEFAULT_BATCH_SIZE, StreamPage, run_stream};
68use crate::stage::CompiledStage;
69use crate::state::StateStore;
70use crate::traits::{Sink, Source};
71use futures::StreamExt;
72use metrics::{Label, SharedString, counter, histogram};
73use serde_json::Value;
74use std::collections::{HashMap, HashSet};
75use std::future::Future;
76use std::pin::Pin;
77use std::sync::Arc;
78use std::time::Duration;
79use tokio::sync::mpsc;
80use tokio_util::sync::CancellationToken;
81
82pub use crate::join::{JoinConfig, JoinMode, KeyNormalize, OnCollision, OnDuplicate, Projection};
83
84/// Default bounded-channel capacity for topology edges.
85pub const DEFAULT_CHANNEL_CAPACITY: usize = 4;
86
87/// A join node: the pure [`JoinConfig`] plus the labels of its two incoming
88/// edges identifying which upstream is the build (right) side and which is the
89/// probe (left) side.
90#[derive(Debug, Clone)]
91pub struct JoinNode {
92    /// Pure join logic configuration.
93    pub config: JoinConfig,
94    /// Label of the incoming edge feeding the build (right) side.
95    pub build_edge: String,
96    /// Label of the incoming edge feeding the probe (left) side.
97    pub probe_edge: String,
98}
99
100/// A typed topology node.
101pub enum NodeKind {
102    /// A data source (0 in, 1 out).
103    Source(Box<dyn Source>),
104    /// Transform stages applied per page (1 in, 1 out).
105    Transform(Vec<CompiledStage>),
106    /// Fan-out: clone each page to every downstream edge (1 in, N out).
107    Tee {
108        /// Bounded-channel capacity for each outgoing edge.
109        capacity: usize,
110        /// Optional expected fan-out (outgoing edge count) sanity check.
111        fanout: Option<usize>,
112    },
113    /// Fan-in: forward pages from all inputs in arrival order (N in, 1 out).
114    Merge,
115    /// Hash-join two upstreams by key (2 in, 1 out).
116    Join(JoinNode),
117    /// A data sink (1 in, 0 out).
118    Sink(Box<dyn Sink>),
119}
120
121impl std::fmt::Debug for NodeKind {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.write_str(self.kind_str())
124    }
125}
126
127impl NodeKind {
128    /// Short name of this node kind, used in errors and metric labels.
129    pub fn kind_str(&self) -> &'static str {
130        match self {
131            NodeKind::Source(_) => "source",
132            NodeKind::Transform(_) => "transform",
133            NodeKind::Tee { .. } => "tee",
134            NodeKind::Merge => "merge",
135            NodeKind::Join(_) => "join",
136            NodeKind::Sink(_) => "sink",
137        }
138    }
139
140    fn is_source(&self) -> bool {
141        matches!(self, NodeKind::Source(_))
142    }
143
144    fn is_sink(&self) -> bool {
145        matches!(self, NodeKind::Sink(_))
146    }
147}
148
149/// A node in the topology: a stable id plus its typed kind.
150#[derive(Debug)]
151pub struct Node {
152    /// Stable node id (used as the metric `node` label and state-key suffix).
153    pub id: String,
154    /// The node's kind.
155    pub kind: NodeKind,
156}
157
158/// A directed edge from one node's output to another's input.
159#[derive(Debug, Clone)]
160pub struct Edge {
161    /// Producer node id.
162    pub from: String,
163    /// Consumer node id.
164    pub to: String,
165    /// Optional edge label, used by [`NodeKind::Join`] to distinguish its
166    /// build edge from its probe edge.
167    pub label: Option<String>,
168}
169
170/// What to do when a node fails.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172pub enum TopologyOnError {
173    /// Abort the whole topology on the first node failure (default).
174    #[default]
175    Propagate,
176    /// Let every node run to completion; collect and report failures without
177    /// aborting healthy branches.
178    Continue,
179}
180
181/// The per-run governance passes applied to every sink node.
182///
183/// These are the same passes [`crate::Pipeline`] applies in matrix mode; a
184/// topology wires them through [`run_stream`] per sink node so a graph pipeline
185/// gets identical enforcement. Masking is destination-scoped (a rule may name
186/// the sinks it applies to), so it is keyed by **sink node id** and compiled by
187/// the caller; the rest are pipeline-wide.
188///
189/// `#[non_exhaustive]`: construct with [`TopologyGovernance::new`] (or
190/// `Default`) and assign the fields you need. This is deliberate — adding a pass
191/// later would otherwise be a major-version break for every downstream crate,
192/// which is exactly the trap `TopologyOptions` is already in.
193#[derive(Clone, Default)]
194#[non_exhaustive]
195pub struct TopologyGovernance {
196    /// Compiled data-quality checks, applied per page in every sink node.
197    #[cfg(feature = "quality")]
198    pub quality: Option<Arc<crate::quality::CompiledQuality>>,
199    /// Compiled data contract, applied per page in every sink node.
200    #[cfg(feature = "contract")]
201    pub contract: Option<Arc<crate::contract::CompiledContract>>,
202    /// Compiled masking policy per sink node id. A sink node with no entry runs
203    /// no masking pass (the caller found no rule that applies to it).
204    #[cfg(feature = "masking")]
205    pub masking_by_sink: HashMap<String, Arc<crate::masking::CompiledMasking>>,
206    /// Compiled schema-drift policy, applied per page in every sink node.
207    pub schema_drift: Option<crate::drift::SchemaDriftPolicy>,
208    /// Resilience policy (retry / circuit breaker / poison) for sink-side
209    /// writes, flushes, and state puts.
210    pub resilience: Option<crate::resilience::ResiliencePolicy>,
211    /// Delivery guarantee for every sink node (#458).
212    ///
213    /// `ExactlyOnce` gives each sink node its own commit-token scope — its state
214    /// key, `{pipeline}::{node_id}` — so a sink that durably committed a page
215    /// skips it on resume independently of its siblings. Lives here rather than on
216    /// [`TopologyOptions`] because that struct is exhaustively constructible
217    /// through the public API, so a new field there is a major break; this one is
218    /// `#[non_exhaustive]`. It is a per-sink write policy either way.
219    ///
220    /// The caller is responsible for the gate (deterministic-replay source,
221    /// idempotent sinks, durable state, no DLQ) — `run_stream` re-checks the sink
222    /// side and downgrades with a warning rather than pretending.
223    pub delivery: crate::idempotency::DeliveryMode,
224}
225
226impl TopologyGovernance {
227    /// A governance set with no passes configured.
228    pub fn new() -> Self {
229        Self::default()
230    }
231}
232
233/// Per-run options for [`Topology::run`].
234#[derive(Clone)]
235pub struct TopologyOptions {
236    /// Pipeline name (metric `pipeline` label).
237    pub pipeline_name: String,
238    /// Run id (span attribute).
239    pub run_id: String,
240    /// Batch-size hint passed to source nodes' `stream_pages`.
241    pub batch_size: usize,
242    /// State store shared by every sink node (each under `{pipeline}::{node_id}`).
243    pub state_store: Option<Arc<dyn StateStore>>,
244    /// DLQ applied to every sink node.
245    pub dlq: Option<DlqConfig>,
246    /// Cooperative cancellation.
247    pub cancel: Option<CancellationToken>,
248    /// Failure policy.
249    pub on_error: TopologyOnError,
250    /// Default bounded-channel capacity for edges not fed by a tee.
251    pub default_channel_capacity: usize,
252}
253
254/// How long a node gets to stop at its next page boundary and flush after another
255/// node has failed under [`TopologyOnError::Propagate`], before it is aborted.
256///
257/// Mirrors the CLI executor's `on_error: stop` grace: without it a buffered sink
258/// is dropped mid-write, orphaning a multipart upload or leaving a footer-less
259/// Parquet file (#146 H16, #456 M1). The window opens only once a failure has
260/// cancelled the run, so a healthy run is never bounded by it.
261pub const STOP_FLUSH_GRACE: Duration = Duration::from_secs(30);
262
263impl Default for TopologyOptions {
264    fn default() -> Self {
265        Self {
266            pipeline_name: "unnamed".into(),
267            run_id: String::new(),
268            batch_size: DEFAULT_BATCH_SIZE,
269            state_store: None,
270            dlq: None,
271            cancel: None,
272            on_error: TopologyOnError::default(),
273            default_channel_capacity: DEFAULT_CHANNEL_CAPACITY,
274        }
275    }
276}
277
278impl TopologyOptions {
279    /// New options with the given pipeline name.
280    pub fn new(pipeline_name: impl Into<String>) -> Self {
281        Self {
282            pipeline_name: pipeline_name.into(),
283            ..Default::default()
284        }
285    }
286
287    /// Attach a state store.
288    pub fn with_state_store(mut self, store: Arc<dyn StateStore>) -> Self {
289        self.state_store = Some(store);
290        self
291    }
292
293    /// Attach a DLQ applied to every sink node.
294    pub fn with_dlq(mut self, dlq: DlqConfig) -> Self {
295        self.dlq = Some(dlq);
296        self
297    }
298
299    /// Attach a cancellation token.
300    pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
301        self.cancel = Some(cancel);
302        self
303    }
304
305    /// Set the failure policy.
306    pub fn with_on_error(mut self, on_error: TopologyOnError) -> Self {
307        self.on_error = on_error;
308        self
309    }
310
311    /// Set the batch-size hint.
312    pub fn with_batch_size(mut self, batch_size: usize) -> Self {
313        self.batch_size = batch_size;
314        self
315    }
316}
317
318/// What one node did, for callers that need per-node attribution (the CLI emits
319/// notifications and evaluates SLAs per **sink node**, which needs to know which
320/// node failed — [`TopologyResult::errors`] is a flat list of messages).
321#[derive(Debug, Clone)]
322#[non_exhaustive]
323pub struct NodeReport {
324    /// Node id.
325    pub node_id: String,
326    /// Node kind (`"source"`, `"sink"`, …).
327    pub kind: &'static str,
328    /// Records written (sink nodes only; 0 elsewhere).
329    pub records: usize,
330    /// Final bookmark (sink nodes only).
331    pub bookmark: Option<Value>,
332    /// The node's failure, if it failed.
333    pub error: Option<String>,
334}
335
336/// A topology run with per-node attribution.
337///
338/// `#[non_exhaustive]`: this is an output callers read, never construct, so
339/// keeping it open means a future per-node field is a minor release rather than
340/// a breaking one — the mistake [`TopologyResult`] cannot now undo.
341#[derive(Debug, Clone, Default)]
342#[non_exhaustive]
343pub struct TopologyRun {
344    /// The aggregate result, identical to what [`Topology::run`] returns.
345    pub result: TopologyResult,
346    /// One entry per node, in the graph's deterministic (sorted-id) order.
347    pub nodes: Vec<NodeReport>,
348    /// Records emitted per **source** node, keyed by node id (#459). Lives here
349    /// rather than on [`TopologyResult`] so adding it stays a minor change.
350    pub per_source: HashMap<String, usize>,
351}
352
353/// Outcome of a topology run.
354#[derive(Debug, Clone, Default)]
355pub struct TopologyResult {
356    /// Total records written across all sink nodes.
357    pub records_written: usize,
358    /// Per-sink-node records written, keyed by node id.
359    pub per_sink: HashMap<String, usize>,
360    /// Per-sink-node final bookmark, keyed by node id.
361    pub bookmarks: HashMap<String, Option<Value>>,
362    /// Node failures observed under [`TopologyOnError::Continue`] (empty under
363    /// `Propagate`, which returns `Err` on the first failure instead).
364    pub errors: Vec<String>,
365}
366
367/// One incoming edge of a node: its optional label plus the receiving end of
368/// the channel.
369struct InEdge {
370    label: Option<String>,
371    rx: mpsc::Receiver<StreamPage>,
372}
373
374/// Pop the single input receiver from a one-input node's edge list.
375fn take_single(mut ins: Vec<InEdge>) -> Option<mpsc::Receiver<StreamPage>> {
376    ins.drain(..).next().map(|ie| ie.rx)
377}
378
379/// Remove and return the input receiver whose edge carries `label`.
380fn take_by_label(ins: &mut Vec<InEdge>, label: &str) -> Option<mpsc::Receiver<StreamPage>> {
381    ins.iter()
382        .position(|ie| ie.label.as_deref() == Some(label))
383        .map(|pos| ins.remove(pos).rx)
384}
385
386/// What a completed node future reports back.
387enum NodeOutcome {
388    Sink {
389        node_id: String,
390        records: usize,
391        bookmark: Option<Value>,
392    },
393    /// A source node and how many records it emitted. Needed so a lineage /
394    /// catalog edge can report the volume *that input* contributed rather than
395    /// the sink's total, which would over-count a merge (#459).
396    Source {
397        node_id: String,
398        records: usize,
399    },
400    Other,
401}
402
403/// A directed acyclic graph of typed nodes.
404///
405/// Build one with [`Topology::builder`], then drive it with [`Topology::run`].
406#[derive(Debug)]
407pub struct Topology {
408    nodes: Vec<Node>,
409    edges: Vec<Edge>,
410}
411
412impl Topology {
413    /// Start building a topology.
414    pub fn builder() -> TopologyBuilder {
415        TopologyBuilder::default()
416    }
417
418    /// The nodes, in insertion order.
419    pub fn nodes(&self) -> &[Node] {
420        &self.nodes
421    }
422
423    /// The edges, in insertion order.
424    pub fn edges(&self) -> &[Edge] {
425        &self.edges
426    }
427
428    /// Validate the graph: unique ids, existing endpoints, per-kind arity,
429    /// tee fan-out, join edge labels, acyclicity, and source→sink
430    /// reachability. Returns [`FaucetError::Config`] with a descriptive
431    /// message on the first violation.
432    pub fn validate(&self) -> Result<(), FaucetError> {
433        if self.nodes.is_empty() {
434            return Err(cfg("topology has no nodes"));
435        }
436
437        // Unique ids.
438        let mut seen = HashSet::new();
439        for n in &self.nodes {
440            if !seen.insert(n.id.as_str()) {
441                return Err(cfg(format!("duplicate node id '{}'", n.id)));
442            }
443        }
444        let ids: HashSet<&str> = seen;
445
446        // Edge endpoints exist.
447        for e in &self.edges {
448            if !ids.contains(e.from.as_str()) {
449                return Err(cfg(format!(
450                    "edge references unknown 'from' node '{}'",
451                    e.from
452                )));
453            }
454            if !ids.contains(e.to.as_str()) {
455                return Err(cfg(format!("edge references unknown 'to' node '{}'", e.to)));
456            }
457        }
458
459        // In/out degrees.
460        let mut in_deg: HashMap<&str, usize> = HashMap::new();
461        let mut out_deg: HashMap<&str, usize> = HashMap::new();
462        for e in &self.edges {
463            *out_deg.entry(e.from.as_str()).or_default() += 1;
464            *in_deg.entry(e.to.as_str()).or_default() += 1;
465        }
466
467        let mut has_source = false;
468        let mut has_sink = false;
469        for n in &self.nodes {
470            let i = in_deg.get(n.id.as_str()).copied().unwrap_or(0);
471            let o = out_deg.get(n.id.as_str()).copied().unwrap_or(0);
472            match &n.kind {
473                NodeKind::Source(_) => {
474                    has_source = true;
475                    arity(&n.id, "source", i == 0, o == 1, "0 in, exactly 1 out")?;
476                }
477                NodeKind::Transform(_) => {
478                    arity(&n.id, "transform", i == 1, o == 1, "exactly 1 in, 1 out")?;
479                }
480                NodeKind::Tee { fanout, .. } => {
481                    arity(&n.id, "tee", i == 1, o >= 2, "exactly 1 in, 2+ out")?;
482                    if let Some(f) = fanout
483                        && *f != o
484                    {
485                        return Err(cfg(format!(
486                            "tee '{}' declares fanout {f} but has {o} outgoing edges",
487                            n.id
488                        )));
489                    }
490                }
491                NodeKind::Merge => {
492                    arity(&n.id, "merge", i >= 2, o == 1, "2+ in, exactly 1 out")?;
493                }
494                NodeKind::Join(j) => {
495                    arity(&n.id, "join", i == 2, o == 1, "exactly 2 in, 1 out")?;
496                    self.validate_join_edges(&n.id, j)?;
497                }
498                NodeKind::Sink(_) => {
499                    has_sink = true;
500                    arity(&n.id, "sink", i == 1, o == 0, "exactly 1 in, 0 out")?;
501                }
502            }
503        }
504
505        if !has_source {
506            return Err(cfg("topology has no source node"));
507        }
508        if !has_sink {
509            return Err(cfg("topology has no sink node"));
510        }
511
512        self.detect_cycle()?;
513        self.check_reachability()?;
514        Ok(())
515    }
516
517    fn validate_join_edges(&self, node_id: &str, j: &JoinNode) -> Result<(), FaucetError> {
518        let labels: Vec<&str> = self
519            .edges
520            .iter()
521            .filter(|e| e.to == node_id)
522            .filter_map(|e| e.label.as_deref())
523            .collect();
524        for want in [j.build_edge.as_str(), j.probe_edge.as_str()] {
525            if !labels.contains(&want) {
526                return Err(cfg(format!(
527                    "join '{node_id}' has no incoming edge labelled '{want}' (known labels: {labels:?})"
528                )));
529            }
530        }
531        if j.build_edge == j.probe_edge {
532            return Err(cfg(format!(
533                "join '{node_id}' build_edge and probe_edge must differ"
534            )));
535        }
536        Ok(())
537    }
538
539    /// DFS cycle detection (three-color).
540    fn detect_cycle(&self) -> Result<(), FaucetError> {
541        let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
542        for e in &self.edges {
543            adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
544        }
545        #[derive(Clone, Copy, PartialEq)]
546        enum Color {
547            White,
548            Gray,
549            Black,
550        }
551        let mut color: HashMap<&str, Color> = self
552            .nodes
553            .iter()
554            .map(|n| (n.id.as_str(), Color::White))
555            .collect();
556
557        // Iterative DFS to avoid stack overflow on deep graphs.
558        for start in self.nodes.iter().map(|n| n.id.as_str()) {
559            if color[start] != Color::White {
560                continue;
561            }
562            let mut stack: Vec<(&str, usize)> = vec![(start, 0)];
563            *color.get_mut(start).unwrap() = Color::Gray;
564            while let Some((node, idx)) = stack.last().copied() {
565                let neighbours = adj.get(node).map(|v| v.as_slice()).unwrap_or(&[]);
566                if idx < neighbours.len() {
567                    stack.last_mut().unwrap().1 += 1;
568                    let next = neighbours[idx];
569                    match color[next] {
570                        Color::Gray => {
571                            return Err(cfg(format!("topology has a cycle through node '{next}'")));
572                        }
573                        Color::White => {
574                            *color.get_mut(next).unwrap() = Color::Gray;
575                            stack.push((next, 0));
576                        }
577                        Color::Black => {}
578                    }
579                } else {
580                    *color.get_mut(node).unwrap() = Color::Black;
581                    stack.pop();
582                }
583            }
584        }
585        Ok(())
586    }
587
588    /// Every source must reach at least one sink, and every sink must be
589    /// reachable from at least one source.
590    fn check_reachability(&self) -> Result<(), FaucetError> {
591        let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
592        let mut radj: HashMap<&str, Vec<&str>> = HashMap::new();
593        for e in &self.edges {
594            adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
595            radj.entry(e.to.as_str()).or_default().push(e.from.as_str());
596        }
597        let sink_ids: HashSet<&str> = self
598            .nodes
599            .iter()
600            .filter(|n| n.kind.is_sink())
601            .map(|n| n.id.as_str())
602            .collect();
603        let source_ids: HashSet<&str> = self
604            .nodes
605            .iter()
606            .filter(|n| n.kind.is_source())
607            .map(|n| n.id.as_str())
608            .collect();
609
610        for src in &source_ids {
611            if !reaches_any(src, &adj, &sink_ids) {
612                return Err(cfg(format!("source '{src}' does not reach any sink node")));
613            }
614        }
615        for sink in &sink_ids {
616            if !reaches_any(sink, &radj, &source_ids) {
617                return Err(cfg(format!(
618                    "sink '{sink}' is not reachable from any source node"
619                )));
620            }
621        }
622        Ok(())
623    }
624
625    /// Run the topology to completion with no governance passes.
626    ///
627    /// Equivalent to [`Topology::run_with`] with a default
628    /// [`TopologyGovernance`] — kept as-is so existing callers are unaffected.
629    pub async fn run(self, opts: TopologyOptions) -> Result<TopologyResult, FaucetError> {
630        self.run_with(opts, TopologyGovernance::default()).await
631    }
632
633    /// Run the topology to completion, applying `governance` to every sink node.
634    ///
635    /// Separate from [`Topology::run`] rather than a field on
636    /// [`TopologyOptions`]: that struct is exhaustively constructible through the
637    /// public API, so adding a field to it would be a major-version break for
638    /// every downstream crate. A new method is additive.
639    pub async fn run_with(
640        self,
641        opts: TopologyOptions,
642        governance: TopologyGovernance,
643    ) -> Result<TopologyResult, FaucetError> {
644        self.run_reported(opts, governance).await.map(|r| r.result)
645    }
646
647    /// [`Topology::run_with`] with **per-node attribution**.
648    ///
649    /// The CLI emits notifications and evaluates SLAs per *sink node*, which needs
650    /// to know which node failed; [`TopologyResult::errors`] is only a flat list
651    /// of messages. Additive rather than a change to `run_with`'s return type,
652    /// which would break every caller (#459).
653    pub async fn run_reported(
654        self,
655        opts: TopologyOptions,
656        governance: TopologyGovernance,
657    ) -> Result<TopologyRun, FaucetError> {
658        self.validate()?;
659        let Topology { nodes, edges } = self;
660
661        // Capacity per outgoing edge: a tee's edges use its configured
662        // capacity; everything else uses the default.
663        let tee_cap: HashMap<&str, usize> = nodes
664            .iter()
665            .filter_map(|n| match &n.kind {
666                NodeKind::Tee { capacity, .. } => Some((n.id.as_str(), *capacity)),
667                _ => None,
668            })
669            .collect();
670
671        // Resume point for the source node(s) — only when provably safe (see
672        // `start_bookmark`); otherwise every source replays in full.
673        let sink_ids: Vec<String> = nodes
674            .iter()
675            .filter(|n| n.kind.is_sink())
676            .map(|n| n.id.clone())
677            .collect();
678        let source_count = nodes.iter().filter(|n| n.kind.is_source()).count();
679        // The graph's source replay capability, captured before the sources are
680        // moved into their futures. Only meaningful with exactly one source —
681        // which is also the only shape exactly-once is allowed in (#458).
682        let source_replay = if source_count == 1 {
683            nodes.iter().find_map(|n| match &n.kind {
684                NodeKind::Source(src) => Some(src.replay_guarantee()),
685                _ => None,
686            })
687        } else {
688            None
689        };
690        let start_bookmark =
691            compute_start_bookmark(&opts, &sink_ids, source_count, governance.delivery).await;
692
693        // Build channels.
694        let mut outs: HashMap<String, Vec<mpsc::Sender<StreamPage>>> = HashMap::new();
695        let mut ins: HashMap<String, Vec<InEdge>> = HashMap::new();
696        for e in &edges {
697            let cap = tee_cap
698                .get(e.from.as_str())
699                .copied()
700                .unwrap_or(opts.default_channel_capacity)
701                .max(1);
702            let (tx, rx) = mpsc::channel(cap);
703            outs.entry(e.from.clone()).or_default().push(tx);
704            ins.entry(e.to.clone()).or_default().push(InEdge {
705                label: e.label.clone(),
706                rx,
707            });
708        }
709
710        // Build one future per node. `Send + 'static` so each node can own a
711        // task (see the spawn below).
712        type NodeFut = Pin<Box<dyn Future<Output = Result<NodeOutcome, FaucetError>> + Send>>;
713        let mut futs: Vec<NodeFut> = Vec::with_capacity(nodes.len());
714        // Parallel to `futs`, so a failure can be attributed to its node.
715        let mut order: Vec<(String, &'static str)> = Vec::with_capacity(nodes.len());
716
717        for node in nodes {
718            let node_outs = outs.remove(&node.id).unwrap_or_default();
719            let mut node_ins = ins.remove(&node.id).unwrap_or_default();
720            let pipeline = opts.pipeline_name.clone();
721            let cancel = opts.cancel.clone();
722            let Node { id, kind } = node;
723            order.push((id.clone(), kind.kind_str()));
724
725            let fut: NodeFut = match kind {
726                NodeKind::Source(source) => {
727                    let sb = start_bookmark.clone();
728                    let bs = opts.batch_size;
729                    Box::pin(run_source_node(id, source, sb, bs, node_outs, cancel))
730                }
731                NodeKind::Transform(stages) => {
732                    let rx = take_single(node_ins)
733                        .ok_or_else(|| cfg(format!("transform '{id}' has no input edge")))?;
734                    let labels = Labels::new(pipeline.clone(), id.clone(), opts.run_id.clone());
735                    Box::pin(run_transform_node(stages, labels, rx, node_outs, cancel))
736                }
737                NodeKind::Tee { .. } => {
738                    let rx = take_single(node_ins)
739                        .ok_or_else(|| cfg(format!("tee '{id}' has no input edge")))?;
740                    Box::pin(run_tee_node(id, pipeline, rx, node_outs, cancel))
741                }
742                NodeKind::Merge => {
743                    let rxs: Vec<mpsc::Receiver<StreamPage>> =
744                        node_ins.into_iter().map(|ie| ie.rx).collect();
745                    Box::pin(run_merge_node(id, pipeline, rxs, node_outs, cancel))
746                }
747                NodeKind::Join(j) => {
748                    let build_rx = take_by_label(&mut node_ins, &j.build_edge);
749                    let probe_rx = take_by_label(&mut node_ins, &j.probe_edge);
750                    match (build_rx, probe_rx) {
751                        (Some(b), Some(p)) => {
752                            Box::pin(run_join_node(id, pipeline, j, b, p, node_outs, cancel))
753                        }
754                        _ => {
755                            return Err(cfg(format!(
756                                "join '{id}' is missing its build/probe input edges"
757                            )));
758                        }
759                    }
760                }
761                NodeKind::Sink(sink) => {
762                    let rx = take_single(node_ins)
763                        .ok_or_else(|| cfg(format!("sink '{id}' has no input edge")))?;
764                    let sopts = SinkNodeOpts {
765                        pipeline_name: pipeline,
766                        run_id: opts.run_id.clone(),
767                        state_store: opts.state_store.clone(),
768                        dlq: opts.dlq.clone(),
769                        cancel: cancel.clone(),
770                        // Masking is destination-scoped, so each sink node takes
771                        // the policy compiled for it (if any); the rest are
772                        // pipeline-wide.
773                        #[cfg(feature = "masking")]
774                        masking: governance.masking_by_sink.get(&id).cloned(),
775                        #[cfg(feature = "quality")]
776                        quality: governance.quality.clone(),
777                        #[cfg(feature = "contract")]
778                        contract: governance.contract.clone(),
779                        schema_drift: governance.schema_drift,
780                        resilience: governance.resilience.clone(),
781                        delivery: governance.delivery,
782                        replay: source_replay,
783                    };
784                    Box::pin(run_sink_node(id, sink, rx, sopts))
785                }
786            };
787            futs.push(fut);
788        }
789
790        // Drop the leftover maps so no dangling senders keep channels open.
791        drop(outs);
792        drop(ins);
793
794        // One task per node, so nodes run on the runtime's whole thread pool
795        // instead of sharing a single task. A synchronous stage (the DuckDB `sql`
796        // transform, a wasm transform) would otherwise occupy the one task and
797        // stall every other node including the sinks (#456 M5). A spawned node
798        // also isolates panics: they arrive as a `JoinError` we report, rather
799        // than unwinding the caller.
800        let handles: Vec<tokio::task::JoinHandle<Result<NodeOutcome, FaucetError>>> =
801            futs.into_iter().map(tokio::spawn).collect();
802        // Dropping a `JoinHandle` detaches the task rather than cancelling it, so
803        // every abandon path below aborts explicitly.
804        let aborts: Vec<tokio::task::AbortHandle> =
805            handles.iter().map(|h| h.abort_handle()).collect();
806        let abort_all = || {
807            for a in &aborts {
808                a.abort();
809            }
810        };
811        let joined = handles.into_iter().map(|h| async move {
812            match h.await {
813                Ok(r) => r,
814                Err(e) if e.is_panic() => {
815                    Err(FaucetError::Source(format!("topology node panicked: {e}")))
816                }
817                Err(e) => Err(FaucetError::Source(format!("topology node aborted: {e}"))),
818            }
819        });
820
821        match opts.on_error {
822            TopologyOnError::Propagate => {
823                // Do NOT `try_join_all`: it returns on the first error and drops
824                // the remaining node futures where they stand, so a buffered sink
825                // never flushes — orphaning a multipart upload or writing a
826                // footer-less Parquet file. Instead signal the shared cancel
827                // token and let the siblings stop at their next page boundary and
828                // flush, exactly as the CLI executor's `on_error: stop` does
829                // (#146 H16, #456 M1). A node that does not stop within the grace
830                // window is still dropped, so a sink wedged mid-write cannot hang
831                // the run.
832                let coop = opts.cancel.clone().unwrap_or_default().child_token();
833                let first_err: Arc<std::sync::Mutex<Option<FaucetError>>> =
834                    Arc::new(std::sync::Mutex::new(None));
835                let failures: Arc<std::sync::Mutex<Vec<(String, String)>>> =
836                    Arc::new(std::sync::Mutex::new(Vec::new()));
837                let wrapped = joined.zip(order.clone()).map(|(f, (node_id, _))| {
838                    let coop = coop.clone();
839                    let slot = Arc::clone(&first_err);
840                    let failed = Arc::clone(&failures);
841                    async move {
842                        match f.await {
843                            Ok(o) => Some(o),
844                            Err(e) => {
845                                failed
846                                    .lock()
847                                    .unwrap_or_else(|p| p.into_inner())
848                                    .push((node_id, e.to_string()));
849                                tracing::error!(
850                                    error = %e,
851                                    "topology node failed; cancelling siblings so they flush"
852                                );
853                                let mut guard = slot.lock().unwrap_or_else(|p| p.into_inner());
854                                if guard.is_none() {
855                                    *guard = Some(e);
856                                }
857                                coop.cancel();
858                                None
859                            }
860                        }
861                    }
862                });
863                // The grace window opens only once something has cancelled the
864                // token — a healthy run is never bounded by it.
865                let all = futures::future::join_all(wrapped);
866                let grace = STOP_FLUSH_GRACE;
867                let deadline = {
868                    let coop = coop.clone();
869                    async move {
870                        coop.cancelled().await;
871                        tokio::time::sleep(grace).await;
872                    }
873                };
874                let outcomes = tokio::select! {
875                    biased;
876                    v = all => v,
877                    () = deadline => {
878                        tracing::warn!(
879                            grace_secs = grace.as_secs(),
880                            "topology: nodes did not stop within the flush grace after a failure; \
881                             aborting them"
882                        );
883                        abort_all();
884                        Vec::new()
885                    }
886                };
887                if let Some(e) = first_err.lock().unwrap_or_else(|p| p.into_inner()).take() {
888                    return Err(e);
889                }
890                let (result, per_source) = aggregate(outcomes.into_iter().flatten().collect());
891                let failed = failures.lock().unwrap_or_else(|p| p.into_inner()).clone();
892                let nodes = reports(&order, &result, &per_source, &failed);
893                Ok(TopologyRun {
894                    result,
895                    nodes,
896                    per_source,
897                })
898            }
899            TopologyOnError::Continue => {
900                let results = futures::future::join_all(joined).await;
901                let mut ok = Vec::new();
902                let mut errs = Vec::new();
903                let mut failed: Vec<(String, String)> = Vec::new();
904                for (r, (node_id, _)) in results.into_iter().zip(order.clone()) {
905                    match r {
906                        Ok(o) => ok.push(o),
907                        Err(e) => {
908                            tracing::error!(
909                                node = %node_id,
910                                error = %e,
911                                "topology node failed (on_error: continue)"
912                            );
913                            errs.push(e.to_string());
914                            failed.push((node_id, e.to_string()));
915                        }
916                    }
917                }
918                let (mut result, per_source) = aggregate(ok);
919                result.errors = errs;
920                let nodes = reports(&order, &result, &per_source, &failed);
921                Ok(TopologyRun {
922                    result,
923                    nodes,
924                    per_source,
925                })
926            }
927        }
928    }
929}
930
931/// Build the per-node report list from the node ids/kinds and their outcomes.
932fn reports(
933    order: &[(String, &'static str)],
934    sinks: &TopologyResult,
935    per_source: &HashMap<String, usize>,
936    errors: &[(String, String)],
937) -> Vec<NodeReport> {
938    order
939        .iter()
940        .map(|(id, kind)| NodeReport {
941            node_id: id.clone(),
942            kind,
943            records: sinks
944                .per_sink
945                .get(id)
946                .or_else(|| per_source.get(id))
947                .copied()
948                .unwrap_or(0),
949            bookmark: sinks.bookmarks.get(id).cloned().flatten(),
950            error: errors
951                .iter()
952                .find(|(nid, _)| nid == id)
953                .map(|(_, e)| e.clone()),
954        })
955        .collect()
956}
957
958/// Aggregate node outcomes into a [`TopologyResult`] plus the per-source counts
959/// (which live on [`TopologyRun`], not the result).
960fn aggregate(outcomes: Vec<NodeOutcome>) -> (TopologyResult, HashMap<String, usize>) {
961    let mut result = TopologyResult::default();
962    let mut per_source = HashMap::new();
963    for o in outcomes {
964        match o {
965            NodeOutcome::Sink {
966                node_id,
967                records,
968                bookmark,
969            } => {
970                result.records_written += records;
971                result.per_sink.insert(node_id.clone(), records);
972                result.bookmarks.insert(node_id, bookmark);
973            }
974            NodeOutcome::Source { node_id, records } => {
975                per_source.insert(node_id, records);
976            }
977            NodeOutcome::Other => {}
978        }
979    }
980    (result, per_source)
981}
982
983fn cfg(msg: impl Into<String>) -> FaucetError {
984    FaucetError::Config(format!("topology: {}", msg.into()))
985}
986
987fn arity(
988    node_id: &str,
989    kind: &str,
990    in_ok: bool,
991    out_ok: bool,
992    expected: &str,
993) -> Result<(), FaucetError> {
994    if in_ok && out_ok {
995        Ok(())
996    } else {
997        Err(cfg(format!(
998            "{kind} '{node_id}' has the wrong edge arity (expected {expected})"
999        )))
1000    }
1001}
1002
1003fn reaches_any(start: &str, adj: &HashMap<&str, Vec<&str>>, targets: &HashSet<&str>) -> bool {
1004    let mut stack = vec![start];
1005    let mut seen = HashSet::new();
1006    while let Some(n) = stack.pop() {
1007        if targets.contains(n) {
1008            return true;
1009        }
1010        if !seen.insert(n) {
1011            continue;
1012        }
1013        if let Some(ns) = adj.get(n) {
1014            stack.extend(ns.iter().copied());
1015        }
1016    }
1017    false
1018}
1019
1020/// Read every sink node's stored bookmark and decide the source's resume point.
1021///
1022/// Returns `Some(bookmark)` only when it is provably safe to resume there;
1023/// `None` means "replay in full", which costs duplicates on a non-idempotent
1024/// sink but can never skip a record. See [`start_bookmark`] for the rules.
1025async fn compute_start_bookmark(
1026    opts: &TopologyOptions,
1027    sink_ids: &[String],
1028    source_count: usize,
1029    delivery: crate::idempotency::DeliveryMode,
1030) -> Option<Value> {
1031    let store = opts.state_store.as_ref()?;
1032    if sink_ids.is_empty() {
1033        return None;
1034    }
1035    let mut values = Vec::with_capacity(sink_ids.len());
1036    for id in sink_ids {
1037        let key = format!("{}::{}", opts.pipeline_name, id);
1038        match store.get(&key).await {
1039            Ok(Some(v)) => values.push(v),
1040            _ => return None, // a sink with no bookmark → full replay.
1041        }
1042    }
1043    if delivery == crate::idempotency::DeliveryMode::ExactlyOnce {
1044        // Exactly-once stores `(bookmark, seq)`, and `seq` is a monotonic page
1045        // counter — a real total order, so the sinks can be ranked exactly
1046        // instead of guessed at. Resume from the *furthest behind* sink; every
1047        // sink ahead of it skips the pages it already committed, which is
1048        // precisely what the commit token is for. (#458)
1049        let ranked: Vec<(u64, Option<Value>)> = values
1050            .iter()
1051            .map(|v| {
1052                let (bm, seq) = crate::idempotency::unwrap_state(v);
1053                (seq, bm)
1054            })
1055            .collect();
1056        return eo_start_bookmark(&ranked, source_count);
1057    }
1058    start_bookmark(&values, source_count)
1059}
1060
1061/// Exactly-once resume point: the bookmark of the lowest-`seq` sink.
1062///
1063/// Unlike the at-least-once path this *can* order the sinks, because `seq` is a
1064/// monotonic counter rather than an opaque position — so a diverged set resumes
1065/// from the laggard instead of replaying from scratch, and the sinks that are
1066/// ahead skip their already-committed pages via their commit tokens.
1067///
1068/// The single-source restriction still applies: nothing records which source a
1069/// sink's bookmark came from.
1070pub fn eo_start_bookmark(ranked: &[(u64, Option<Value>)], source_count: usize) -> Option<Value> {
1071    if ranked.is_empty() || source_count != 1 {
1072        if source_count > 1 && !ranked.is_empty() {
1073            tracing::warn!(
1074                sources = source_count,
1075                "topology: multi-source graph cannot attribute a sink bookmark to a source; \
1076                 replaying every source in full"
1077            );
1078        }
1079        return None;
1080    }
1081    ranked
1082        .iter()
1083        .min_by_key(|(seq, _)| *seq)
1084        .and_then(|(_, bm)| bm.clone())
1085}
1086
1087/// Pure resume-point decision: the bookmark every source node is started from,
1088/// or `None` to replay in full.
1089///
1090/// Deliberately conservative — the only safe way to be wrong is to replay:
1091///
1092/// 1. **More than one source node → `None`.** A sink's bookmark records the
1093///    position of whichever source fed its pages, and nothing in the graph
1094///    records which one that was. Applying one source's position to another
1095///    resumes it somewhere it has never been. (#456 H1)
1096/// 2. **Sink bookmarks that are not all equal → `None`.** Bookmarks are compared
1097///    for *equality*, never ordered: resume positions are routinely structured
1098///    (CDC LSN maps, Kafka/Kinesis offset maps) and
1099///    [`json_gt`](crate::replication::json_gt)'s object arm falls back to
1100///    comparing serialized text, an order unrelated to replication progress. A
1101///    "minimum" chosen that way can sit *ahead* of the true minimum and silently
1102///    skip the lagging sink's records. (#456 H1)
1103/// 3. **All sinks agree → resume there.** No ordering needed, so no guessing.
1104pub fn start_bookmark(sink_bookmarks: &[Value], source_count: usize) -> Option<Value> {
1105    if sink_bookmarks.is_empty() || source_count != 1 {
1106        if source_count > 1 && !sink_bookmarks.is_empty() {
1107            tracing::warn!(
1108                sources = source_count,
1109                "topology: multi-source graph cannot attribute a sink bookmark to a source; \
1110                 replaying every source in full. Make the sinks idempotent \
1111                 (`write_mode: upsert`) or split the graph into one pipeline per source."
1112            );
1113        }
1114        return None;
1115    }
1116    let first = &sink_bookmarks[0];
1117    if sink_bookmarks.iter().any(|v| v != first) {
1118        tracing::warn!(
1119            "topology: sink bookmarks have diverged and resume positions are not safely \
1120             ordered; replaying the source in full so no sink is skipped past. Faster sinks \
1121             will re-see already-written pages — make them idempotent."
1122        );
1123        return None;
1124    }
1125    Some(first.clone())
1126}
1127
1128/// Send `page` to every live output, moving into the last and cloning for the
1129/// rest. Closed (dropped-receiver) outputs are removed. Returns `false` once
1130/// every output has closed.
1131async fn broadcast(page: StreamPage, outs: &mut Vec<mpsc::Sender<StreamPage>>) -> bool {
1132    if outs.is_empty() {
1133        return false;
1134    }
1135    let last = outs.len() - 1;
1136    let mut closed: Vec<usize> = Vec::new();
1137    for (i, tx) in outs.iter().enumerate().take(last) {
1138        if tx.send(page.clone()).await.is_err() {
1139            closed.push(i);
1140        }
1141    }
1142    if outs[last].send(page).await.is_err() {
1143        closed.push(last);
1144    }
1145    for &i in closed.iter().rev() {
1146        outs.remove(i);
1147    }
1148    !outs.is_empty()
1149}
1150
1151fn cancelled(cancel: &Option<CancellationToken>) -> bool {
1152    cancel.as_ref().is_some_and(|c| c.is_cancelled())
1153}
1154
1155async fn run_source_node(
1156    node_id: String,
1157    source: Box<dyn Source>,
1158    start_bookmark: Option<Value>,
1159    batch_size: usize,
1160    mut outs: Vec<mpsc::Sender<StreamPage>>,
1161    cancel: Option<CancellationToken>,
1162) -> Result<NodeOutcome, FaucetError> {
1163    if let Some(bm) = start_bookmark {
1164        source.apply_start_bookmark(bm).await?;
1165    }
1166    let ctx = std::collections::HashMap::new();
1167    let mut pages = source.stream_pages(&ctx, batch_size);
1168    let mut records = 0usize;
1169    while let Some(item) = pages.next().await {
1170        if cancelled(&cancel) {
1171            break;
1172        }
1173        let page = item?;
1174        records += page.records.len();
1175        if !broadcast(page, &mut outs).await {
1176            break;
1177        }
1178    }
1179    Ok(NodeOutcome::Source { node_id, records })
1180}
1181
1182async fn run_transform_node(
1183    stages: Vec<CompiledStage>,
1184    labels: Labels,
1185    mut rx: mpsc::Receiver<StreamPage>,
1186    mut outs: Vec<mpsc::Sender<StreamPage>>,
1187    cancel: Option<CancellationToken>,
1188) -> Result<NodeOutcome, FaucetError> {
1189    while let Some(page) = rx.recv().await {
1190        if cancelled(&cancel) {
1191            break;
1192        }
1193        let records = instrumented_apply_stages(page.records, &stages, &labels)?;
1194        let out = StreamPage {
1195            records,
1196            bookmark: page.bookmark,
1197        };
1198        if !broadcast(out, &mut outs).await {
1199            break;
1200        }
1201    }
1202    Ok(NodeOutcome::Other)
1203}
1204
1205fn node_labels(pipeline: &str, node: &str) -> Vec<Label> {
1206    vec![
1207        Label::new("pipeline", SharedString::from(pipeline.to_string())),
1208        Label::new("node", SharedString::from(node.to_string())),
1209    ]
1210}
1211
1212async fn run_tee_node(
1213    node_id: String,
1214    pipeline: String,
1215    mut rx: mpsc::Receiver<StreamPage>,
1216    mut outs: Vec<mpsc::Sender<StreamPage>>,
1217    cancel: Option<CancellationToken>,
1218) -> Result<NodeOutcome, FaucetError> {
1219    let labels = node_labels(&pipeline, &node_id);
1220    while let Some(page) = rx.recv().await {
1221        if cancelled(&cancel) {
1222            break;
1223        }
1224        counter!("faucet_tee_records_total", labels.clone()).increment(page.records.len() as u64);
1225        if !broadcast(page, &mut outs).await {
1226            break;
1227        }
1228    }
1229    Ok(NodeOutcome::Other)
1230}
1231
1232async fn run_merge_node(
1233    node_id: String,
1234    pipeline: String,
1235    rxs: Vec<mpsc::Receiver<StreamPage>>,
1236    mut outs: Vec<mpsc::Sender<StreamPage>>,
1237    cancel: Option<CancellationToken>,
1238) -> Result<NodeOutcome, FaucetError> {
1239    let labels = node_labels(&pipeline, &node_id);
1240    let streams = rxs.into_iter().map(|mut rx| {
1241        Box::pin(async_stream::stream! {
1242            while let Some(p) = rx.recv().await {
1243                yield p;
1244            }
1245        }) as Pin<Box<dyn futures::Stream<Item = StreamPage> + Send>>
1246    });
1247    let mut sel = futures::stream::select_all(streams);
1248    while let Some(page) = sel.next().await {
1249        if cancelled(&cancel) {
1250            break;
1251        }
1252        counter!("faucet_merge_records_total", labels.clone()).increment(page.records.len() as u64);
1253        if !broadcast(page, &mut outs).await {
1254            break;
1255        }
1256    }
1257    Ok(NodeOutcome::Other)
1258}
1259
1260#[allow(clippy::too_many_arguments)]
1261async fn run_join_node(
1262    node_id: String,
1263    pipeline: String,
1264    j: JoinNode,
1265    mut build_rx: mpsc::Receiver<StreamPage>,
1266    mut probe_rx: mpsc::Receiver<StreamPage>,
1267    mut outs: Vec<mpsc::Sender<StreamPage>>,
1268    cancel: Option<CancellationToken>,
1269) -> Result<NodeOutcome, FaucetError> {
1270    let mode = j.config.mode;
1271    let mut join = HashJoin::new(j.config);
1272
1273    // Build phase: fully drain the build side before probing.
1274    let build_start = std::time::Instant::now();
1275    while let Some(page) = build_rx.recv().await {
1276        if cancelled(&cancel) {
1277            return Ok(NodeOutcome::Other);
1278        }
1279        join.add_build_page(page.records)?;
1280    }
1281    let labels = node_labels(&pipeline, &node_id);
1282    histogram!("faucet_join_build_duration_seconds", labels.clone())
1283        .record(build_start.elapsed().as_secs_f64());
1284
1285    // Probe phase.
1286    while let Some(page) = probe_rx.recv().await {
1287        if cancelled(&cancel) {
1288            break;
1289        }
1290        let enriched = join.probe_page(page.records)?;
1291        let out = StreamPage {
1292            records: enriched,
1293            bookmark: page.bookmark,
1294        };
1295        if !broadcast(out, &mut outs).await {
1296            break;
1297        }
1298    }
1299
1300    emit_join_metrics(&labels, mode, join.stats());
1301    Ok(NodeOutcome::Other)
1302}
1303
1304fn emit_join_metrics(labels: &[Label], mode: JoinMode, stats: &crate::join::JoinStats) {
1305    counter!("faucet_join_build_records_total", labels.to_vec()).increment(stats.build_records);
1306    counter!("faucet_join_build_nulls_total", labels.to_vec()).increment(stats.build_nulls);
1307    counter!("faucet_join_duplicates_total", labels.to_vec()).increment(stats.duplicates);
1308    counter!("faucet_join_probe_records_total", labels.to_vec()).increment(stats.probe_records);
1309    counter!("faucet_join_project_misses_total", labels.to_vec()).increment(stats.project_misses);
1310    let mut match_labels = labels.to_vec();
1311    match_labels.push(Label::new("kind", SharedString::from(mode.to_string())));
1312    counter!("faucet_join_matches_total", match_labels.clone()).increment(stats.matches);
1313    counter!("faucet_join_misses_total", match_labels).increment(stats.misses);
1314}
1315
1316struct SinkNodeOpts {
1317    pipeline_name: String,
1318    run_id: String,
1319    state_store: Option<Arc<dyn StateStore>>,
1320    dlq: Option<DlqConfig>,
1321    cancel: Option<CancellationToken>,
1322    /// Masking policy compiled for *this* sink node (destination-scoped).
1323    #[cfg(feature = "masking")]
1324    masking: Option<Arc<crate::masking::CompiledMasking>>,
1325    #[cfg(feature = "quality")]
1326    quality: Option<Arc<crate::quality::CompiledQuality>>,
1327    #[cfg(feature = "contract")]
1328    contract: Option<Arc<crate::contract::CompiledContract>>,
1329    schema_drift: Option<crate::drift::SchemaDriftPolicy>,
1330    resilience: Option<crate::resilience::ResiliencePolicy>,
1331    /// Delivery guarantee for this sink node.
1332    delivery: crate::idempotency::DeliveryMode,
1333    /// The replay capability of the graph's source, so `run_stream` can tell an
1334    /// atomic-watermark run from a keyed-upsert one. `None` when there is not
1335    /// exactly one source (in which case exactly-once is gated off anyway).
1336    replay: Option<crate::idempotency::ReplayGuarantee>,
1337}
1338
1339async fn run_sink_node(
1340    node_id: String,
1341    sink: Box<dyn Sink>,
1342    mut rx: mpsc::Receiver<StreamPage>,
1343    opts: SinkNodeOpts,
1344) -> Result<NodeOutcome, FaucetError> {
1345    let pages = Box::pin(async_stream::stream! {
1346        while let Some(page) = rx.recv().await {
1347            yield Ok::<StreamPage, FaucetError>(page);
1348        }
1349    });
1350
1351    let mut run_opts = RunStreamOptions::new()
1352        .with_name(opts.pipeline_name.clone())
1353        .with_row(node_id.clone())
1354        .with_run_id(opts.run_id.clone());
1355    if let Some(store) = opts.state_store {
1356        let key = format!("{}::{}", opts.pipeline_name, node_id);
1357        // Exactly-once: this node's state holds `(bookmark, seq)`, and `seq` is
1358        // where its commit-token sequence resumes. Read it before handing the
1359        // store to `run_stream`, which owns the writes from here (#458).
1360        if opts.delivery == crate::idempotency::DeliveryMode::ExactlyOnce {
1361            let seq = match store.get(&key).await {
1362                Ok(Some(prior)) => crate::idempotency::unwrap_state(&prior).1,
1363                Ok(None) => 0,
1364                Err(e) => return Err(e),
1365            };
1366            run_opts = run_opts.with_delivery(opts.delivery).with_start_seq(seq);
1367            if let Some(replay) = opts.replay {
1368                run_opts = run_opts.with_replay_guarantee(replay);
1369            }
1370        }
1371        run_opts = run_opts.with_state(store, key);
1372    }
1373    if let Some(dlq) = opts.dlq {
1374        run_opts = run_opts.with_dlq(dlq);
1375    }
1376    if let Some(cancel) = opts.cancel {
1377        run_opts = run_opts.with_cancel(cancel);
1378    }
1379    // Governance passes, in the same order `Pipeline` applies them: masking
1380    // first (so nothing downstream — sink, DLQ, lineage sample — ever sees
1381    // unmasked PII), then quality, contract, and drift.
1382    #[cfg(feature = "masking")]
1383    if let Some(m) = opts.masking {
1384        run_opts = run_opts.with_masking(m);
1385    }
1386    #[cfg(feature = "quality")]
1387    if let Some(q) = opts.quality {
1388        run_opts = run_opts.with_quality(q);
1389    }
1390    #[cfg(feature = "contract")]
1391    if let Some(c) = opts.contract {
1392        run_opts = run_opts.with_contract(c);
1393    }
1394    if let Some(d) = opts.schema_drift {
1395        run_opts.schema_drift = Some(d);
1396    }
1397    if let Some(r) = opts.resilience {
1398        run_opts.resilience = Some(r);
1399    }
1400
1401    let result = run_stream(pages, sink.as_ref(), run_opts).await?;
1402    Ok(NodeOutcome::Sink {
1403        node_id,
1404        records: result.records_written,
1405        bookmark: result.bookmark,
1406    })
1407}
1408
1409// ── Builder ──────────────────────────────────────────────────────────────────
1410
1411/// Fluent builder for a [`Topology`].
1412#[derive(Default)]
1413pub struct TopologyBuilder {
1414    nodes: Vec<Node>,
1415    edges: Vec<Edge>,
1416}
1417
1418impl TopologyBuilder {
1419    /// Add a node of any kind.
1420    pub fn node(mut self, id: impl Into<String>, kind: NodeKind) -> Self {
1421        self.nodes.push(Node {
1422            id: id.into(),
1423            kind,
1424        });
1425        self
1426    }
1427
1428    /// Add a source node.
1429    pub fn source(self, id: impl Into<String>, source: Box<dyn Source>) -> Self {
1430        self.node(id, NodeKind::Source(source))
1431    }
1432
1433    /// Add a transform node.
1434    pub fn transform(self, id: impl Into<String>, stages: Vec<CompiledStage>) -> Self {
1435        self.node(id, NodeKind::Transform(stages))
1436    }
1437
1438    /// Add a tee (fan-out) node.
1439    pub fn tee(self, id: impl Into<String>, capacity: usize, fanout: Option<usize>) -> Self {
1440        self.node(id, NodeKind::Tee { capacity, fanout })
1441    }
1442
1443    /// Add a merge (fan-in) node.
1444    pub fn merge(self, id: impl Into<String>) -> Self {
1445        self.node(id, NodeKind::Merge)
1446    }
1447
1448    /// Add a join node.
1449    pub fn join(self, id: impl Into<String>, join: JoinNode) -> Self {
1450        self.node(id, NodeKind::Join(join))
1451    }
1452
1453    /// Add a sink node.
1454    pub fn sink(self, id: impl Into<String>, sink: Box<dyn Sink>) -> Self {
1455        self.node(id, NodeKind::Sink(sink))
1456    }
1457
1458    /// Add an unlabelled edge.
1459    pub fn edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
1460        self.edges.push(Edge {
1461            from: from.into(),
1462            to: to.into(),
1463            label: None,
1464        });
1465        self
1466    }
1467
1468    /// Add a labelled edge (used by join build/probe wiring).
1469    pub fn labelled_edge(
1470        mut self,
1471        from: impl Into<String>,
1472        to: impl Into<String>,
1473        label: impl Into<String>,
1474    ) -> Self {
1475        self.edges.push(Edge {
1476            from: from.into(),
1477            to: to.into(),
1478            label: Some(label.into()),
1479        });
1480        self
1481    }
1482
1483    /// Finalize and validate the topology.
1484    pub fn build(self) -> Result<Topology, FaucetError> {
1485        let t = Topology {
1486            nodes: self.nodes,
1487            edges: self.edges,
1488        };
1489        t.validate()?;
1490        Ok(t)
1491    }
1492}
1493
1494#[cfg(test)]
1495mod tests {
1496    use super::*;
1497    use crate::join::{JoinConfig, JoinMode, Projection};
1498    use crate::state::MemoryStateStore;
1499    use async_trait::async_trait;
1500    use serde_json::json;
1501    use std::sync::Mutex;
1502
1503    // ── Mock connectors ───────────────────────────────────────────────────────
1504
1505    pub(super) struct VecSource {
1506        records: Vec<Value>,
1507        bookmark: Option<Value>,
1508    }
1509    impl VecSource {
1510        pub(super) fn boxed(records: Vec<Value>) -> Box<dyn Source> {
1511            Box::new(VecSource {
1512                records,
1513                bookmark: None,
1514            })
1515        }
1516        fn boxed_bm(records: Vec<Value>, bm: Value) -> Box<dyn Source> {
1517            Box::new(VecSource {
1518                records,
1519                bookmark: Some(bm),
1520            })
1521        }
1522    }
1523    #[async_trait]
1524    impl Source for VecSource {
1525        async fn fetch_with_context(
1526            &self,
1527            _c: &std::collections::HashMap<String, Value>,
1528        ) -> Result<Vec<Value>, FaucetError> {
1529            Ok(self.records.clone())
1530        }
1531        async fn fetch_with_context_incremental(
1532            &self,
1533            _c: &std::collections::HashMap<String, Value>,
1534        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1535            Ok((self.records.clone(), self.bookmark.clone()))
1536        }
1537    }
1538
1539    struct FailingSource;
1540    #[async_trait]
1541    impl Source for FailingSource {
1542        async fn fetch_with_context(
1543            &self,
1544            _c: &std::collections::HashMap<String, Value>,
1545        ) -> Result<Vec<Value>, FaucetError> {
1546            Err(FaucetError::Source("boom".into()))
1547        }
1548    }
1549
1550    /// Records the bookmark applied via `apply_start_bookmark`.
1551    struct RecordingSource {
1552        records: Vec<Value>,
1553        applied: Arc<Mutex<Option<Value>>>,
1554    }
1555    #[async_trait]
1556    impl Source for RecordingSource {
1557        async fn fetch_with_context(
1558            &self,
1559            _c: &std::collections::HashMap<String, Value>,
1560        ) -> Result<Vec<Value>, FaucetError> {
1561            Ok(self.records.clone())
1562        }
1563        async fn apply_start_bookmark(&self, bm: Value) -> Result<(), FaucetError> {
1564            *self.applied.lock().unwrap() = Some(bm);
1565            Ok(())
1566        }
1567    }
1568
1569    #[derive(Clone)]
1570    pub(super) struct CollectSink {
1571        store: Arc<Mutex<Vec<Value>>>,
1572    }
1573    impl CollectSink {
1574        pub(super) fn new() -> (Self, Arc<Mutex<Vec<Value>>>) {
1575            let store = Arc::new(Mutex::new(Vec::new()));
1576            (
1577                Self {
1578                    store: store.clone(),
1579                },
1580                store,
1581            )
1582        }
1583    }
1584    #[async_trait]
1585    impl Sink for CollectSink {
1586        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1587            self.store.lock().unwrap().extend_from_slice(records);
1588            Ok(records.len())
1589        }
1590    }
1591
1592    pub(super) struct FailingSink;
1593    #[async_trait]
1594    impl Sink for FailingSink {
1595        async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
1596            Err(FaucetError::Sink("sink boom".into()))
1597        }
1598    }
1599
1600    /// A sink that records whether `flush` was called — the observable proof that
1601    /// a node was allowed to finish cooperatively rather than being dropped.
1602    struct FlushTrackingSink {
1603        flushed: Arc<Mutex<bool>>,
1604    }
1605    #[async_trait]
1606    impl Sink for FlushTrackingSink {
1607        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1608            Ok(records.len())
1609        }
1610        async fn flush(&self) -> Result<(), FaucetError> {
1611            *self.flushed.lock().unwrap() = true;
1612            Ok(())
1613        }
1614    }
1615
1616    pub(super) fn recs(n: usize) -> Vec<Value> {
1617        (0..n).map(|i| json!({ "i": i })).collect()
1618    }
1619
1620    // ── Validation ────────────────────────────────────────────────────────────
1621
1622    #[test]
1623    fn validate_rejects_empty() {
1624        let err = Topology {
1625            nodes: vec![],
1626            edges: vec![],
1627        }
1628        .validate()
1629        .unwrap_err();
1630        assert!(err.to_string().contains("no nodes"));
1631    }
1632
1633    #[test]
1634    fn validate_rejects_duplicate_id() {
1635        let (sink, _) = CollectSink::new();
1636        let err = Topology::builder()
1637            .source("a", VecSource::boxed(recs(1)))
1638            .sink("a", Box::new(sink))
1639            .edge("a", "a")
1640            .build()
1641            .unwrap_err();
1642        assert!(err.to_string().contains("duplicate node id"));
1643    }
1644
1645    #[test]
1646    fn validate_rejects_unknown_endpoint() {
1647        let err = Topology::builder()
1648            .source("s", VecSource::boxed(recs(1)))
1649            .edge("s", "ghost")
1650            .build()
1651            .unwrap_err();
1652        assert!(err.to_string().contains("unknown 'to' node 'ghost'"));
1653    }
1654
1655    #[test]
1656    fn validate_rejects_unknown_from_endpoint() {
1657        let (sink, _) = CollectSink::new();
1658        let err = Topology::builder()
1659            .sink("k", Box::new(sink))
1660            .edge("ghost", "k")
1661            .build()
1662            .unwrap_err();
1663        assert!(err.to_string().contains("unknown 'from' node 'ghost'"));
1664    }
1665
1666    #[test]
1667    fn validate_rejects_source_with_incoming_edge() {
1668        let (sink, _) = CollectSink::new();
1669        let err = Topology::builder()
1670            .source("s", VecSource::boxed(recs(1)))
1671            .sink("k", Box::new(sink))
1672            .edge("s", "k")
1673            .edge("k", "s") // sink→source: gives source in=1 and sink out=1
1674            .build()
1675            .unwrap_err();
1676        // Either arity or cycle is caught; both are correct rejections.
1677        assert!(err.to_string().contains("arity") || err.to_string().contains("cycle"));
1678    }
1679
1680    #[test]
1681    fn validate_rejects_tee_fanout_mismatch() {
1682        let (s1, _) = CollectSink::new();
1683        let (s2, _) = CollectSink::new();
1684        let err = Topology::builder()
1685            .source("s", VecSource::boxed(recs(1)))
1686            .tee("t", 4, Some(3))
1687            .sink("a", Box::new(s1))
1688            .sink("b", Box::new(s2))
1689            .edge("s", "t")
1690            .edge("t", "a")
1691            .edge("t", "b")
1692            .build()
1693            .unwrap_err();
1694        assert!(err.to_string().contains("fanout 3 but has 2"));
1695    }
1696
1697    #[test]
1698    fn validate_rejects_tee_with_one_output() {
1699        let (s1, _) = CollectSink::new();
1700        let err = Topology::builder()
1701            .source("s", VecSource::boxed(recs(1)))
1702            .tee("t", 4, None)
1703            .sink("a", Box::new(s1))
1704            .edge("s", "t")
1705            .edge("t", "a")
1706            .build()
1707            .unwrap_err();
1708        assert!(err.to_string().contains("tee 't'"));
1709    }
1710
1711    #[test]
1712    fn validate_rejects_merge_with_one_input() {
1713        let (s1, _) = CollectSink::new();
1714        let err = Topology::builder()
1715            .source("s", VecSource::boxed(recs(1)))
1716            .merge("m")
1717            .sink("a", Box::new(s1))
1718            .edge("s", "m")
1719            .edge("m", "a")
1720            .build()
1721            .unwrap_err();
1722        assert!(err.to_string().contains("merge 'm'"));
1723    }
1724
1725    #[test]
1726    fn validate_rejects_join_missing_label() {
1727        let (s1, _) = CollectSink::new();
1728        let jn = JoinNode {
1729            config: JoinConfig::default(),
1730            build_edge: "build".into(),
1731            probe_edge: "probe".into(),
1732        };
1733        let err = Topology::builder()
1734            .source("b", VecSource::boxed(recs(1)))
1735            .source("p", VecSource::boxed(recs(1)))
1736            .join("j", jn)
1737            .sink("a", Box::new(s1))
1738            .labelled_edge("b", "j", "build")
1739            .edge("p", "j") // unlabelled — probe label missing
1740            .edge("j", "a")
1741            .build()
1742            .unwrap_err();
1743        assert!(err.to_string().contains("labelled 'probe'"));
1744    }
1745
1746    #[test]
1747    fn validate_rejects_join_same_labels() {
1748        let (s1, _) = CollectSink::new();
1749        let jn = JoinNode {
1750            config: JoinConfig::default(),
1751            build_edge: "x".into(),
1752            probe_edge: "x".into(),
1753        };
1754        let err = Topology::builder()
1755            .source("b", VecSource::boxed(recs(1)))
1756            .source("p", VecSource::boxed(recs(1)))
1757            .join("j", jn)
1758            .sink("a", Box::new(s1))
1759            .labelled_edge("b", "j", "x")
1760            .labelled_edge("p", "j", "x")
1761            .edge("j", "a")
1762            .build()
1763            .unwrap_err();
1764        assert!(err.to_string().contains("must differ"));
1765    }
1766
1767    #[test]
1768    fn validate_rejects_cycle() {
1769        // s → m(merge) → t(tee) → {m, k}. The m→t→m loop is a valid-arity
1770        // cycle (merge absorbs the back-edge, tee provides the second out).
1771        let (sink, _) = CollectSink::new();
1772        let err = Topology::builder()
1773            .source("s", VecSource::boxed(recs(1)))
1774            .merge("m")
1775            .tee("t", 4, None)
1776            .sink("k", Box::new(sink))
1777            .edge("s", "m")
1778            .edge("m", "t")
1779            .edge("t", "m")
1780            .edge("t", "k")
1781            .build()
1782            .unwrap_err();
1783        assert!(err.to_string().contains("cycle"), "{err}");
1784    }
1785
1786    #[test]
1787    fn validate_rejects_no_source() {
1788        // Two transforms wired in a ring: valid arity, but no source node.
1789        let err = Topology::builder()
1790            .transform("t1", vec![])
1791            .transform("t2", vec![])
1792            .edge("t1", "t2")
1793            .edge("t2", "t1")
1794            .build()
1795            .unwrap_err();
1796        assert!(err.to_string().contains("no source"), "{err}");
1797    }
1798
1799    #[test]
1800    fn validate_rejects_no_sink() {
1801        // Two sources into a self-looping merge: valid arity, but no sink.
1802        let err = Topology::builder()
1803            .source("s1", VecSource::boxed(recs(1)))
1804            .source("s2", VecSource::boxed(recs(1)))
1805            .merge("m")
1806            .edge("s1", "m")
1807            .edge("s2", "m")
1808            .edge("m", "m")
1809            .build()
1810            .unwrap_err();
1811        assert!(err.to_string().contains("no sink"), "{err}");
1812    }
1813
1814    // ── Execution ─────────────────────────────────────────────────────────────
1815
1816    #[tokio::test]
1817    async fn simple_source_to_sink() {
1818        let (sink, store) = CollectSink::new();
1819        let topo = Topology::builder()
1820            .source("s", VecSource::boxed(recs(5)))
1821            .sink("k", Box::new(sink))
1822            .edge("s", "k")
1823            .build()
1824            .unwrap();
1825        let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1826        assert_eq!(result.records_written, 5);
1827        assert_eq!(store.lock().unwrap().len(), 5);
1828        assert_eq!(result.per_sink.get("k"), Some(&5));
1829    }
1830
1831    #[tokio::test]
1832    async fn source_transform_sink() {
1833        let (sink, store) = CollectSink::new();
1834        let topo = Topology::builder()
1835            .source("s", VecSource::boxed(recs(3)))
1836            .transform("t", vec![]) // passthrough
1837            .sink("k", Box::new(sink))
1838            .edge("s", "t")
1839            .edge("t", "k")
1840            .build()
1841            .unwrap();
1842        let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1843        assert_eq!(result.records_written, 3);
1844        assert_eq!(store.lock().unwrap().len(), 3);
1845    }
1846
1847    #[tokio::test]
1848    async fn tee_fans_out_to_three_sinks() {
1849        let (s1, st1) = CollectSink::new();
1850        let (s2, st2) = CollectSink::new();
1851        let (s3, st3) = CollectSink::new();
1852        let topo = Topology::builder()
1853            .source("s", VecSource::boxed(recs(10)))
1854            .tee("t", 4, Some(3))
1855            .sink("a", Box::new(s1))
1856            .sink("b", Box::new(s2))
1857            .sink("c", Box::new(s3))
1858            .edge("s", "t")
1859            .edge("t", "a")
1860            .edge("t", "b")
1861            .edge("t", "c")
1862            .build()
1863            .unwrap();
1864        let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1865        assert_eq!(st1.lock().unwrap().len(), 10);
1866        assert_eq!(st2.lock().unwrap().len(), 10);
1867        assert_eq!(st3.lock().unwrap().len(), 10);
1868        assert_eq!(result.records_written, 30);
1869    }
1870
1871    #[tokio::test]
1872    async fn merge_fans_in_two_sources() {
1873        let (sink, store) = CollectSink::new();
1874        let topo = Topology::builder()
1875            .source("s1", VecSource::boxed(recs(4)))
1876            .source("s2", VecSource::boxed(recs(6)))
1877            .merge("m")
1878            .sink("k", Box::new(sink))
1879            .edge("s1", "m")
1880            .edge("s2", "m")
1881            .edge("m", "k")
1882            .build()
1883            .unwrap();
1884        let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1885        assert_eq!(result.records_written, 10);
1886        assert_eq!(store.lock().unwrap().len(), 10);
1887    }
1888
1889    #[tokio::test]
1890    async fn join_enriches_end_to_end() {
1891        let (sink, store) = CollectSink::new();
1892        let customers = vec![
1893            json!({"id": 1, "tier": "gold"}),
1894            json!({"id": 2, "tier": "silver"}),
1895        ];
1896        let orders = vec![
1897            json!({"order": "A", "cust": 1}),
1898            json!({"order": "B", "cust": 2}),
1899            json!({"order": "C", "cust": 99}),
1900        ];
1901        let jn = JoinNode {
1902            config: JoinConfig {
1903                mode: JoinMode::Inner,
1904                build_key: "id".into(),
1905                probe_key: "cust".into(),
1906                projections: vec![Projection {
1907                    from: "tier".into(),
1908                    as_: "tier".into(),
1909                }],
1910                ..Default::default()
1911            },
1912            build_edge: "customers".into(),
1913            probe_edge: "orders".into(),
1914        };
1915        let topo = Topology::builder()
1916            .source("c", VecSource::boxed(customers))
1917            .source("o", VecSource::boxed(orders))
1918            .join("j", jn)
1919            .sink("k", Box::new(sink))
1920            .labelled_edge("c", "j", "customers")
1921            .labelled_edge("o", "j", "orders")
1922            .edge("j", "k")
1923            .build()
1924            .unwrap();
1925        let result = topo.run(TopologyOptions::new("p")).await.unwrap();
1926        // inner join: C (cust 99) drops → 2 enriched records.
1927        assert_eq!(result.records_written, 2);
1928        let written = store.lock().unwrap();
1929        assert!(
1930            written
1931                .iter()
1932                .any(|r| r["order"] == json!("A") && r["tier"] == json!("gold"))
1933        );
1934    }
1935
1936    #[tokio::test]
1937    async fn propagate_aborts_on_sink_failure() {
1938        let topo = Topology::builder()
1939            .source("s", VecSource::boxed(recs(3)))
1940            .sink("k", Box::new(FailingSink))
1941            .edge("s", "k")
1942            .build()
1943            .unwrap();
1944        let err = topo.run(TopologyOptions::new("p")).await.unwrap_err();
1945        assert!(matches!(err, FaucetError::Sink(_)));
1946    }
1947
1948    #[tokio::test]
1949    async fn propagate_aborts_on_source_failure() {
1950        let (sink, _) = CollectSink::new();
1951        let topo = Topology::builder()
1952            .source("s", Box::new(FailingSource))
1953            .sink("k", Box::new(sink))
1954            .edge("s", "k")
1955            .build()
1956            .unwrap();
1957        let err = topo.run(TopologyOptions::new("p")).await.unwrap_err();
1958        assert!(matches!(err, FaucetError::Source(_)));
1959    }
1960
1961    #[tokio::test]
1962    async fn continue_lets_healthy_branch_finish() {
1963        // One branch fails, the other still receives every record.
1964        let (good, store) = CollectSink::new();
1965        let topo = Topology::builder()
1966            .source("s", VecSource::boxed(recs(8)))
1967            .tee("t", 8, Some(2))
1968            .sink("bad", Box::new(FailingSink))
1969            .sink("good", Box::new(good))
1970            .edge("s", "t")
1971            .edge("t", "bad")
1972            .edge("t", "good")
1973            .build()
1974            .unwrap();
1975        let opts = TopologyOptions::new("p").with_on_error(TopologyOnError::Continue);
1976        let result = topo.run(opts).await.unwrap();
1977        assert_eq!(store.lock().unwrap().len(), 8);
1978        assert!(!result.errors.is_empty(), "failing sink should be recorded");
1979    }
1980
1981    #[tokio::test]
1982    async fn state_agreeing_bookmarks_resume_the_source() {
1983        let store = Arc::new(MemoryStateStore::new());
1984        // Both sinks committed the same page → that position is a safe resume.
1985        store.put("p::a", &json!(100)).await.unwrap();
1986        store.put("p::b", &json!(100)).await.unwrap();
1987        let applied = Arc::new(Mutex::new(None));
1988        let src = RecordingSource {
1989            records: recs(1),
1990            applied: applied.clone(),
1991        };
1992        let (s1, _) = CollectSink::new();
1993        let (s2, _) = CollectSink::new();
1994        let topo = Topology::builder()
1995            .source("s", Box::new(src))
1996            .tee("t", 4, Some(2))
1997            .sink("a", Box::new(s1))
1998            .sink("b", Box::new(s2))
1999            .edge("s", "t")
2000            .edge("t", "a")
2001            .edge("t", "b")
2002            .build()
2003            .unwrap();
2004        let opts = TopologyOptions::new("p").with_state_store(store.clone());
2005        topo.run(opts).await.unwrap();
2006        assert_eq!(*applied.lock().unwrap(), Some(json!(100)));
2007    }
2008
2009    #[tokio::test]
2010    async fn state_diverged_bookmarks_replay_in_full() {
2011        // #456 H1: bookmarks are compared for equality, never ordered — an
2012        // ordered "minimum" over structured positions can sit ahead of the true
2013        // minimum and skip the lagging sink's records. Diverged → full replay.
2014        let store = Arc::new(MemoryStateStore::new());
2015        store.put("p::a", &json!(250)).await.unwrap();
2016        store.put("p::b", &json!(100)).await.unwrap();
2017        let applied = Arc::new(Mutex::new(None));
2018        let src = RecordingSource {
2019            records: recs(1),
2020            applied: applied.clone(),
2021        };
2022        let (s1, _) = CollectSink::new();
2023        let (s2, _) = CollectSink::new();
2024        let topo = Topology::builder()
2025            .source("s", Box::new(src))
2026            .tee("t", 4, Some(2))
2027            .sink("a", Box::new(s1))
2028            .sink("b", Box::new(s2))
2029            .edge("s", "t")
2030            .edge("t", "a")
2031            .edge("t", "b")
2032            .build()
2033            .unwrap();
2034        let opts = TopologyOptions::new("p").with_state_store(store.clone());
2035        topo.run(opts).await.unwrap();
2036        assert_eq!(
2037            *applied.lock().unwrap(),
2038            None,
2039            "diverged bookmarks must replay, never guess an order"
2040        );
2041    }
2042
2043    #[tokio::test]
2044    async fn state_multi_source_never_cross_applies_a_bookmark() {
2045        // #456 H1: nothing records which source a sink's bookmark came from, so
2046        // applying one to every source would resume a source somewhere it has
2047        // never been. Multi-source graphs replay in full.
2048        let store = Arc::new(MemoryStateStore::new());
2049        store.put("p::k", &json!(500)).await.unwrap();
2050        let a_applied = Arc::new(Mutex::new(None));
2051        let b_applied = Arc::new(Mutex::new(None));
2052        let a = RecordingSource {
2053            records: recs(1),
2054            applied: a_applied.clone(),
2055        };
2056        let b = RecordingSource {
2057            records: recs(1),
2058            applied: b_applied.clone(),
2059        };
2060        let (sink, _) = CollectSink::new();
2061        let topo = Topology::builder()
2062            .source("a", Box::new(a))
2063            .source("b", Box::new(b))
2064            .merge("m")
2065            .sink("k", Box::new(sink))
2066            .edge("a", "m")
2067            .edge("b", "m")
2068            .edge("m", "k")
2069            .build()
2070            .unwrap();
2071        let opts = TopologyOptions::new("p").with_state_store(store.clone());
2072        topo.run(opts).await.unwrap();
2073        assert_eq!(*a_applied.lock().unwrap(), None);
2074        assert_eq!(*b_applied.lock().unwrap(), None);
2075    }
2076
2077    #[tokio::test]
2078    async fn state_no_bookmark_when_a_sink_is_missing() {
2079        let store = Arc::new(MemoryStateStore::new());
2080        store.put("p::a", &json!(100)).await.unwrap();
2081        // sink b has no stored bookmark → full replay (no apply).
2082        let applied = Arc::new(Mutex::new(None));
2083        let src = RecordingSource {
2084            records: recs(1),
2085            applied: applied.clone(),
2086        };
2087        let (s1, _) = CollectSink::new();
2088        let (s2, _) = CollectSink::new();
2089        let topo = Topology::builder()
2090            .source("s", Box::new(src))
2091            .tee("t", 4, Some(2))
2092            .sink("a", Box::new(s1))
2093            .sink("b", Box::new(s2))
2094            .edge("s", "t")
2095            .edge("t", "a")
2096            .edge("t", "b")
2097            .build()
2098            .unwrap();
2099        let opts = TopologyOptions::new("p").with_state_store(store.clone());
2100        topo.run(opts).await.unwrap();
2101        assert_eq!(*applied.lock().unwrap(), None);
2102    }
2103
2104    #[tokio::test]
2105    async fn sink_persists_bookmark() {
2106        let store = Arc::new(MemoryStateStore::new());
2107        let (sink, _) = CollectSink::new();
2108        let topo = Topology::builder()
2109            .source("s", VecSource::boxed_bm(recs(2), json!("v9")))
2110            .sink("k", Box::new(sink))
2111            .edge("s", "k")
2112            .build()
2113            .unwrap();
2114        let opts = TopologyOptions::new("p").with_state_store(store.clone());
2115        let result = topo.run(opts).await.unwrap();
2116        assert_eq!(result.bookmarks.get("k"), Some(&Some(json!("v9"))));
2117        assert_eq!(store.get("p::k").await.unwrap(), Some(json!("v9")));
2118    }
2119
2120    /// #456 M1: a node failure under `Propagate` must let its siblings stop at a
2121    /// page boundary and **flush**, not drop them where they stand (which
2122    /// orphans a multipart upload / writes a footer-less file).
2123    #[tokio::test]
2124    async fn propagate_lets_siblings_flush_before_returning_the_error() {
2125        let flushed = Arc::new(Mutex::new(false));
2126        let tracker = FlushTrackingSink {
2127            flushed: flushed.clone(),
2128        };
2129        let topo = Topology::builder()
2130            .source("s", VecSource::boxed(recs(64)))
2131            .tee("t", 4, Some(2))
2132            .sink("bad", Box::new(FailingSink))
2133            .sink("good", Box::new(tracker))
2134            .edge("s", "t")
2135            .edge("t", "bad")
2136            .edge("t", "good")
2137            .build()
2138            .unwrap();
2139        let opts = TopologyOptions::new("p").with_on_error(TopologyOnError::Propagate);
2140        let err = topo.run(opts).await.unwrap_err();
2141        assert!(matches!(err, FaucetError::Sink(_)), "{err:?}");
2142        assert!(
2143            *flushed.lock().unwrap(),
2144            "the healthy sink node must be flushed, not dropped mid-write"
2145        );
2146    }
2147
2148    /// #456 C3: the governance passes must apply to a topology's sink nodes, or a
2149    /// config declaring masking writes PII in the clear.
2150    #[cfg(feature = "masking")]
2151    #[tokio::test]
2152    async fn masking_applies_to_a_sink_node() {
2153        use crate::masking::{CompiledMasking, MaskingSpec};
2154
2155        let spec: MaskingSpec = serde_json::from_value(json!({
2156            "rules": [{
2157                "name": "hide-email",
2158                "match": { "fields": ["email"] },
2159                "action": { "type": "redact", "mask": "***" }
2160            }]
2161        }))
2162        .unwrap();
2163        let compiled = Arc::new(CompiledMasking::compile(&spec).unwrap());
2164
2165        let (sink, store) = CollectSink::new();
2166        let topo = Topology::builder()
2167            .source(
2168                "s",
2169                VecSource::boxed(vec![json!({"id": 1, "email": "a@b.c"})]),
2170            )
2171            .sink("k", Box::new(sink))
2172            .edge("s", "k")
2173            .build()
2174            .unwrap();
2175
2176        let mut governance = TopologyGovernance::new();
2177        governance.masking_by_sink.insert("k".to_string(), compiled);
2178        topo.run_with(TopologyOptions::new("p"), governance)
2179            .await
2180            .unwrap();
2181
2182        let written = store.lock().unwrap();
2183        assert_eq!(written.len(), 1);
2184        assert_eq!(written[0]["email"], json!("***"), "PII must be masked");
2185        assert_eq!(written[0]["id"], json!(1));
2186    }
2187
2188    #[tokio::test]
2189    async fn cancellation_stops_the_run() {
2190        let cancel = CancellationToken::new();
2191        cancel.cancel(); // pre-cancelled
2192        let (sink, store) = CollectSink::new();
2193        let topo = Topology::builder()
2194            .source("s", VecSource::boxed(recs(1000)))
2195            .sink("k", Box::new(sink))
2196            .edge("s", "k")
2197            .build()
2198            .unwrap();
2199        let opts = TopologyOptions::new("p").with_cancel(cancel);
2200        let result = topo.run(opts).await.unwrap();
2201        // Cancelled before/early: far fewer than 1000 records written.
2202        assert!(store.lock().unwrap().len() < 1000);
2203        let _ = result;
2204    }
2205
2206    #[test]
2207    fn start_bookmark_only_resumes_when_provably_safe() {
2208        // Every sink agrees, single source → resume there.
2209        assert_eq!(
2210            start_bookmark(&[json!(100), json!(100)], 1),
2211            Some(json!(100))
2212        );
2213        // Structured positions that agree are fine too — no ordering needed.
2214        let lsn = json!({"slot": "s", "lsn": "0/16B3748"});
2215        assert_eq!(
2216            start_bookmark(&[lsn.clone(), lsn.clone()], 1),
2217            Some(lsn.clone())
2218        );
2219
2220        // Diverged scalars → replay. The old code returned `min` = 100 here;
2221        // for the structured case below that "minimum" was text-ordered and
2222        // could sit ahead of the true minimum (#456 H1).
2223        assert_eq!(start_bookmark(&[json!(250), json!(100)], 1), None);
2224        // The exact shape that made a text-ordered minimum unsafe: "0/9…" sorts
2225        // above "0/10…" lexicographically while being *behind* it numerically.
2226        assert_eq!(
2227            start_bookmark(
2228                &[json!({"lsn": "0/9FFFFFF"}), json!({"lsn": "0/10000000"}),],
2229                1
2230            ),
2231            None
2232        );
2233
2234        // More than one source → never cross-apply.
2235        assert_eq!(start_bookmark(&[json!(100), json!(100)], 2), None);
2236        // No sinks / no bookmarks → nothing to resume from.
2237        assert_eq!(start_bookmark(&[], 1), None);
2238        assert_eq!(start_bookmark(&[], 3), None);
2239    }
2240
2241    #[test]
2242    fn kind_str_matches() {
2243        assert_eq!(NodeKind::Merge.kind_str(), "merge");
2244        assert_eq!(
2245            NodeKind::Tee {
2246                capacity: 1,
2247                fanout: None
2248            }
2249            .kind_str(),
2250            "tee"
2251        );
2252    }
2253
2254    #[test]
2255    fn builder_exposes_nodes_and_edges() {
2256        let (sink, _) = CollectSink::new();
2257        let topo = Topology::builder()
2258            .source("s", VecSource::boxed(recs(1)))
2259            .sink("k", Box::new(sink))
2260            .edge("s", "k")
2261            .build()
2262            .unwrap();
2263        assert_eq!(topo.nodes().len(), 2);
2264        assert_eq!(topo.edges().len(), 1);
2265    }
2266
2267    #[cfg(feature = "transform-keys-case")]
2268    #[tokio::test]
2269    async fn transform_node_applies_stage() {
2270        use crate::stage::{TransformStage, compile_stage};
2271        use crate::transform::{KeyCaseMode, RecordTransform};
2272        let stage = compile_stage(&TransformStage::Map(RecordTransform::KeysCase {
2273            mode: KeyCaseMode::Snake,
2274        }))
2275        .unwrap();
2276        let (sink, store) = CollectSink::new();
2277        let topo = Topology::builder()
2278            .source("s", VecSource::boxed(vec![json!({"FooBar": 1})]))
2279            .transform("t", vec![stage])
2280            .sink("k", Box::new(sink))
2281            .edge("s", "t")
2282            .edge("t", "k")
2283            .build()
2284            .unwrap();
2285        topo.run(TopologyOptions::new("p")).await.unwrap();
2286        let w = store.lock().unwrap();
2287        assert!(w[0].get("foo_bar").is_some());
2288    }
2289}
2290
2291#[cfg(test)]
2292mod delivery_and_report_tests {
2293    use super::tests::{CollectSink, FailingSink, VecSource, recs};
2294    use super::*;
2295    use crate::Stream;
2296    use crate::idempotency::{DeliveryMode, format_token_with_bookmark, wrap_state};
2297    use crate::state::{MemoryStateStore, StateStore};
2298    use async_trait::async_trait;
2299    use serde_json::json;
2300    use std::sync::Mutex;
2301
2302    /// A sink that records a commit token per scope, like the SQL sinks do.
2303    struct TokenSink {
2304        rows: Arc<Mutex<Vec<Value>>>,
2305        tokens: Arc<Mutex<std::collections::HashMap<String, String>>>,
2306    }
2307    #[async_trait]
2308    impl Sink for TokenSink {
2309        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2310            self.rows.lock().unwrap().extend_from_slice(records);
2311            Ok(records.len())
2312        }
2313        fn supports_idempotent_writes(&self) -> bool {
2314            true
2315        }
2316        async fn write_batch_idempotent(
2317            &self,
2318            records: &[Value],
2319            scope: &str,
2320            token: &str,
2321        ) -> Result<usize, FaucetError> {
2322            self.rows.lock().unwrap().extend_from_slice(records);
2323            self.tokens
2324                .lock()
2325                .unwrap()
2326                .insert(scope.to_string(), token.to_string());
2327            Ok(records.len())
2328        }
2329        async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
2330            Ok(self.tokens.lock().unwrap().get(scope).cloned())
2331        }
2332    }
2333
2334    /// #458: a sink node under `delivery: exactly_once` must commit through the
2335    /// idempotent write path, under **its own** scope (its state key), so each
2336    /// sink's watermark is independent of its siblings'.
2337    #[tokio::test]
2338    async fn exactly_once_commits_a_token_per_sink_node_scope() {
2339        let rows = Arc::new(Mutex::new(Vec::new()));
2340        let tokens = Arc::new(Mutex::new(std::collections::HashMap::new()));
2341        let sink = TokenSink {
2342            rows: rows.clone(),
2343            tokens: tokens.clone(),
2344        };
2345        let store = Arc::new(MemoryStateStore::new());
2346        let topo = Topology::builder()
2347            .source("s", Box::new(EoSource(recs(3))))
2348            .sink("k", Box::new(sink))
2349            .edge("s", "k")
2350            .build()
2351            .unwrap();
2352
2353        let mut gov = TopologyGovernance::new();
2354        gov.delivery = DeliveryMode::ExactlyOnce;
2355        let opts = TopologyOptions::new("p").with_state_store(store.clone());
2356        topo.run_with(opts, gov).await.unwrap();
2357
2358        assert_eq!(rows.lock().unwrap().len(), 3);
2359        let committed = tokens.lock().unwrap();
2360        assert!(
2361            committed.contains_key("p::k"),
2362            "token must be scoped to the sink node's own state key, got {:?}",
2363            committed.keys().collect::<Vec<_>>()
2364        );
2365    }
2366
2367    /// A source that reports deterministic replay and emits a bookmark per page,
2368    /// which is what the atomic-watermark mechanism requires.
2369    struct EoSource(Vec<Value>);
2370    #[async_trait]
2371    impl Source for EoSource {
2372        async fn fetch_with_context(
2373            &self,
2374            _ctx: &std::collections::HashMap<String, Value>,
2375        ) -> Result<Vec<Value>, FaucetError> {
2376            Ok(self.0.clone())
2377        }
2378        fn stream_pages<'a>(
2379            &'a self,
2380            _ctx: &'a std::collections::HashMap<String, Value>,
2381            _batch: usize,
2382        ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
2383            let rows = self.0.clone();
2384            Box::pin(async_stream::try_stream! {
2385                yield StreamPage { records: rows, bookmark: Some(json!({"pos": 1})) };
2386            })
2387        }
2388        fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
2389            crate::idempotency::ReplayGuarantee::Deterministic
2390        }
2391        fn supports_exactly_once(&self) -> bool {
2392            true
2393        }
2394    }
2395
2396    /// #458: exactly-once *can* order sinks, because `seq` is a monotonic counter.
2397    /// Resume from the furthest-behind sink; the ones ahead skip via their tokens.
2398    #[test]
2399    fn eo_resume_picks_the_lowest_sequence() {
2400        let a = (7u64, Some(json!({"pos": 7})));
2401        let b = (4u64, Some(json!({"pos": 4})));
2402        let c = (9u64, Some(json!({"pos": 9})));
2403        assert_eq!(
2404            eo_start_bookmark(&[a.clone(), b.clone(), c.clone()], 1),
2405            Some(json!({"pos": 4})),
2406            "resume from the laggard, not the leader"
2407        );
2408        // Still never cross-applies in a multi-source graph.
2409        assert_eq!(eo_start_bookmark(&[a, b, c], 2), None);
2410        assert_eq!(eo_start_bookmark(&[], 1), None);
2411    }
2412
2413    /// The EO envelope must be unwrapped on read — a raw `get` would hand the
2414    /// source `{"__faucet_eo": …}` instead of its bookmark.
2415    #[tokio::test]
2416    async fn eo_resume_unwraps_the_state_envelope() {
2417        let store = Arc::new(MemoryStateStore::new());
2418        store
2419            .put("p::k", &wrap_state(Some(&json!({"pos": 5})), 5))
2420            .await
2421            .unwrap();
2422        let opts = TopologyOptions::new("p").with_state_store(store.clone());
2423        let bm =
2424            compute_start_bookmark(&opts, &["k".to_string()], 1, DeliveryMode::ExactlyOnce).await;
2425        assert_eq!(bm, Some(json!({"pos": 5})), "envelope must be unwrapped");
2426        // A bare token round-trips through parse_token_parts the same way.
2427        let t = format_token_with_bookmark(5, Some(&json!({"pos": 5})));
2428        assert!(t.contains('#'), "token embeds the bookmark: {t}");
2429    }
2430
2431    /// #459: the CLI needs to know *which* sink node failed to notify per node.
2432    #[tokio::test]
2433    async fn run_reported_attributes_failures_to_their_node() {
2434        let (good, _) = CollectSink::new();
2435        let topo = Topology::builder()
2436            .source("s", VecSource::boxed(recs(4)))
2437            .tee("t", 4, Some(2))
2438            .sink("bad", Box::new(FailingSink))
2439            .sink("good", Box::new(good))
2440            .edge("s", "t")
2441            .edge("t", "bad")
2442            .edge("t", "good")
2443            .build()
2444            .unwrap();
2445        let run = topo
2446            .run_reported(
2447                TopologyOptions::new("p").with_on_error(TopologyOnError::Continue),
2448                TopologyGovernance::new(),
2449            )
2450            .await
2451            .unwrap();
2452
2453        let bad = run.nodes.iter().find(|n| n.node_id == "bad").unwrap();
2454        assert!(bad.error.is_some(), "the failing sink is attributed");
2455        let good = run.nodes.iter().find(|n| n.node_id == "good").unwrap();
2456        assert!(good.error.is_none(), "the healthy sink is not");
2457        assert_eq!(good.records, 4);
2458        // Every node appears, with its kind.
2459        assert_eq!(run.nodes.len(), 4);
2460        assert!(run.nodes.iter().any(|n| n.kind == "source"));
2461        assert!(run.nodes.iter().any(|n| n.kind == "tee"));
2462    }
2463}