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