Skip to main content

faucet_cli/
topology.rs

1//! Topology mode (issues #71 / #72): build and run a
2//! [`faucet_core::Topology`] from a config's `pipeline.nodes` / `edges` block.
3//!
4//! When `pipeline.nodes` is non-empty the pipeline runs as an explicit node
5//! graph rather than a matrix. This module resolves each node's connector
6//! templates, compiles its transforms, wires the edges, and drives the core
7//! topology executor — reusing [`crate::registry`] for connector construction
8//! and [`crate::state`] / [`crate::executor::build_dlq_config`] for the
9//! sink-side plumbing.
10
11use crate::auth_catalog::AuthCatalog;
12use crate::config::{ConnectorSpec, NodeSpec, PipelineConfig};
13use crate::error::{CliError, CliResult};
14use crate::executor::{InvocationOutcome, RunSummary};
15use crate::merge::merge_value;
16use crate::registry::{build_sink, build_source};
17use crate::transforms::compile_transforms;
18use chrono::{DateTime, FixedOffset};
19use faucet_core::stage::compile_stage;
20use faucet_core::topology::{
21    JoinConfig, JoinNode, NodeKind, Topology, TopologyGovernance, TopologyOnError, TopologyOptions,
22};
23use serde_json::Value;
24use std::collections::HashMap;
25use tokio_util::sync::CancellationToken;
26
27/// Whether the config selects topology mode (a non-empty `pipeline.nodes`).
28pub fn is_topology(cfg: &PipelineConfig) -> bool {
29    !cfg.pipeline.nodes.is_empty()
30}
31
32/// Per-run knobs for topology mode, mirroring the matrix path's
33/// [`crate::executor::ExecuteOptions`] subset that applies to a node graph.
34#[derive(Default, Clone)]
35pub struct TopologyRunOptions {
36    /// External cancellation (serve run-cancel / timeout / shutdown, TUI quit).
37    pub cancel: Option<CancellationToken>,
38    /// Preview: build no real sinks, count records instead, and never persist a
39    /// bookmark (#456 C2).
40    pub dry_run: bool,
41    /// Preview: stop after this many records per sink, and never persist a
42    /// bookmark (#456 C2).
43    pub limit: Option<usize>,
44    /// Clock backing `${now.*}` in node configs. `None` = process start.
45    pub clock: Option<DateTime<FixedOffset>>,
46}
47
48impl TopologyRunOptions {
49    /// The effective `${now.*}` clock.
50    fn clock(&self) -> DateTime<FixedOffset> {
51        self.clock
52            .unwrap_or_else(|| chrono::Utc::now().fixed_offset())
53    }
54
55    /// Whether this is a non-writing preview, which must not persist bookmarks.
56    fn is_preview(&self) -> bool {
57        self.dry_run || self.limit.is_some()
58    }
59}
60
61/// Top-level blocks that topology mode parses but does **not** act on.
62///
63/// Empty — every top-level block is now applied to a node graph: the per-page
64/// governance passes and `resilience:` per sink node (#456 C3), and `sla:` /
65/// `notifications:` / `lineage:` / `catalog:` per sink node in
66/// `post_run_observability` (#459).
67///
68/// The mechanism is kept deliberately. A declared-but-inert block is the worst
69/// kind of silence — the operator believes a guarantee is in force when nothing
70/// is enforcing it — so if a future block lands in matrix mode before topology
71/// mode, list it here and `faucet validate` will say so out loud rather than
72/// printing a clean bill of health.
73pub fn inert_blocks(cfg: &PipelineConfig) -> Vec<(&'static str, &'static str)> {
74    let _ = cfg;
75    Vec::new()
76}
77
78/// Config-level graph validation: the checks that need only the `nodes:` /
79/// `edges:` spec, no connectors. Run as a fail-fast prelude to
80/// [`build_topology`] (so a wiring typo is reported before any client is
81/// constructed) and standalone by the template registry, which validates a
82/// config it must not build connectors for (#444).
83///
84/// Node **arity** (a tee's fan-out, a join's labelled inputs, …) is validated by
85/// [`faucet_core::topology::Topology::validate`] once the graph is built —
86/// deliberately not re-implemented here, so the two can never disagree.
87pub fn validate_topology_spec(cfg: &PipelineConfig) -> CliResult<()> {
88    if !cfg.matrix.is_empty() {
89        return Err(CliError::MatrixAndNodesBothPresent);
90    }
91    // Exactly-once in a node graph (#458). Each sink node commits under its own
92    // scope, so the requirements are the matrix ones applied per node — plus a
93    // single-source restriction, because nothing records which source a given
94    // sink's bookmark came from. Checked here, at config-load time, so an
95    // unsupported combination never runs *as if* it were exactly-once (#456 H2).
96    if cfg.delivery == faucet_core::DeliveryMode::ExactlyOnce {
97        validate_exactly_once(cfg)?;
98    }
99    let spec = &cfg.pipeline;
100    let mut known: Vec<String> = spec.nodes.keys().cloned().collect();
101    known.sort_unstable();
102    for edge in &spec.edges {
103        for endpoint in [&edge.from, &edge.to] {
104            if !spec.nodes.contains_key(endpoint) {
105                return Err(CliError::EdgeEndpointMissing {
106                    name: endpoint.clone(),
107                    known: known.clone(),
108                });
109            }
110        }
111    }
112    Ok(())
113}
114
115/// The connector kind a source/sink node resolves to, without building anything.
116///
117/// Mirrors [`resolve_connector`]'s kind precedence (inline `type` override, else
118/// the referenced template, else the legacy singular block) so the gate below and
119/// the builder can never disagree about what a node *is*.
120fn resolved_node_kind(cfg: &PipelineConfig, node: &NodeSpec) -> Option<String> {
121    let (template, kind, templates, legacy) = match node {
122        NodeSpec::Source { template, kind, .. } => {
123            (template, kind, &cfg.pipeline.sources, &cfg.pipeline.source)
124        }
125        NodeSpec::Sink { template, kind, .. } => {
126            (template, kind, &cfg.pipeline.sinks, &cfg.pipeline.sink)
127        }
128        _ => return None,
129    };
130    if let Some(k) = kind {
131        return Some(k.clone());
132    }
133    let name = template.as_deref().unwrap_or("default");
134    templates
135        .get(name)
136        .or(if name == "default" {
137            legacy.as_ref()
138        } else {
139            None
140        })
141        .map(|t| t.kind.clone())
142}
143
144/// The four atomic-watermark requirements, per node, plus the single-source rule.
145///
146/// Ordered so the message names the *limiting* side, and suggests the keyed-upsert
147/// alternative when the sinks could do it — the same shape as the matrix gate in
148/// `expand`, so an operator moving a pipeline between the two forms reads the same
149/// diagnosis.
150fn validate_exactly_once(cfg: &PipelineConfig) -> CliResult<()> {
151    let nodes = &cfg.pipeline.nodes;
152    let sources: Vec<(&String, String)> = nodes
153        .iter()
154        .filter(|(_, n)| matches!(n, NodeSpec::Source { .. }))
155        .map(|(id, n)| (id, resolved_node_kind(cfg, n).unwrap_or_default()))
156        .collect();
157    let sinks: Vec<(&String, String)> = nodes
158        .iter()
159        .filter(|(_, n)| matches!(n, NodeSpec::Sink { .. }))
160        .map(|(id, n)| (id, resolved_node_kind(cfg, n).unwrap_or_default()))
161        .collect();
162
163    // 1. One source. A sink's bookmark records the position of whichever source
164    //    fed its pages, and the graph does not record which one — so with several
165    //    sources there is no sound resume point to anchor the watermark against.
166    if sources.len() != 1 {
167        return Err(CliError::Config(format!(
168            "`delivery: exactly_once` needs exactly one source node; this graph has {}. A \
169             sink's commit watermark is only meaningful against a known source position, and \
170             nothing records which source fed a given page. Split the graph into one pipeline \
171             per source, or use `write_mode: upsert` + `key` on the sinks for keyed-upsert \
172             effectively-once with any number of sources",
173            sources.len()
174        )));
175    }
176    // 2. The source must replay deterministically.
177    let (src_id, src_kind) = &sources[0];
178    if !crate::registry::source_supports_exactly_once(src_kind) {
179        return Err(CliError::Config(format!(
180            "node '{src_id}': `delivery: exactly_once` is not supported by source '{src_kind}' \
181             (deterministic-replay sources only: {})",
182            crate::registry::EXACTLY_ONCE_SOURCE_KINDS.join(", ")
183        )));
184    }
185    // 3. Every sink must commit data + token atomically.
186    for (id, kind) in &sinks {
187        if !crate::registry::sink_supports_idempotent_writes(kind) {
188            return Err(CliError::Config(format!(
189                "node '{id}': `delivery: exactly_once` is not supported by sink '{kind}' \
190                 (sinks that commit a watermark atomically: {}). Every sink node must qualify — \
191                 each one keeps its own watermark",
192                crate::registry::IDEMPOTENT_SINK_KINDS.join(", ")
193            )));
194        }
195    }
196    // 4. Durable state — the per-node sequence has to survive a restart.
197    match cfg.pipeline.state.as_ref() {
198        None => {
199            return Err(CliError::Config(
200                "`delivery: exactly_once` requires a durable `state:` block: each sink node \
201                 persists its commit sequence there, and without it every restart would \
202                 re-commit from zero"
203                    .into(),
204            ));
205        }
206        Some(state) if state.kind == "memory" => {
207            return Err(CliError::Config(
208                "`delivery: exactly_once` requires a durable `state:` block, and `memory` does \
209                 not survive the process. Use `file`, `redis`, or `postgres`"
210                    .into(),
211            ));
212        }
213        Some(_) => {}
214    }
215    // 5. No DLQ — routing a row aside breaks the all-or-nothing page commit.
216    if cfg.pipeline.dlq.is_some() {
217        return Err(CliError::Config(
218            "`delivery: exactly_once` is incompatible with a `dlq:` block in this version: a \
219             page's rows and its commit token are written as one unit, so a partial page \
220             cannot be split off to a dead-letter queue"
221                .into(),
222        ));
223    }
224    Ok(())
225}
226
227/// Resolve one source/sink node's template `ref` + inline overrides into a
228/// concrete `(kind, config)` pair.
229fn resolve_connector(
230    templates: &HashMap<String, ConnectorSpec>,
231    legacy: &Option<ConnectorSpec>,
232    template_ref: Option<&str>,
233    kind_override: Option<&str>,
234    config_override: Option<&Value>,
235    node_id: &str,
236    kind_label: &'static str,
237) -> CliResult<(String, Value)> {
238    let name = template_ref.unwrap_or("default");
239    let base: ConnectorSpec = if name == "default" {
240        templates
241            .get("default")
242            .cloned()
243            .or_else(|| legacy.clone())
244            .ok_or(CliError::MissingTemplate {
245                kind: kind_label,
246                row_id: node_id.to_string(),
247            })?
248    } else {
249        templates
250            .get(name)
251            .cloned()
252            .ok_or_else(|| CliError::UnknownTemplate {
253                kind: kind_label,
254                name: name.to_string(),
255                row_id: node_id.to_string(),
256                known: {
257                    let mut k: Vec<String> = templates.keys().cloned().collect();
258                    if legacy.is_some() {
259                        k.push("default".to_string());
260                    }
261                    k.sort();
262                    k
263                },
264            })?
265    };
266    let mut kind = base.kind;
267    let mut config = base.config;
268    if let Some(k) = kind_override {
269        kind = k.to_string();
270    }
271    if let Some(c) = config_override {
272        merge_value(&mut config, c.clone());
273    }
274    Ok((kind, config))
275}
276
277/// Build a [`faucet_core::Topology`] from the config's `pipeline.nodes` /
278/// `edges` block, with default run options (no preview, process-start clock).
279pub async fn build_topology(cfg: &PipelineConfig, auth: &AuthCatalog) -> CliResult<Topology> {
280    build_topology_with(cfg, auth, &TopologyRunOptions::default()).await
281}
282
283/// Identity of one source/sink node, captured while the graph is built.
284///
285/// `dataset_uri` is a method on the *built* connector, and the graph is consumed
286/// by the run — so lineage and catalog need this recorded up front (#459).
287#[derive(Debug, Clone)]
288pub struct NodeIdentity {
289    /// Connector kind (`"csv"`, `"postgres"`, …).
290    pub kind: String,
291    /// Raw dataset URI, before canonicalization.
292    pub dataset_uri: String,
293    /// The node's resolved connector config, for `${now.*}` folding.
294    pub config: Value,
295}
296
297/// Record one connector node's identity, when a caller asked for the map.
298///
299/// Skipped when the caller wants no map, or when the connector does not override
300/// `dataset_uri()` — the `<kind>://unknown` default names nothing joinable, and
301/// recording it would put a placeholder dataset in the catalog and in every
302/// OpenLineage event.
303fn record_identity(
304    identities: &mut Option<&mut NodeIdentities>,
305    node_id: &str,
306    kind: &str,
307    config: Value,
308    dataset_uri: String,
309) {
310    let Some(map) = identities.as_mut() else {
311        return;
312    };
313    if dataset_uri.ends_with("://unknown") {
314        tracing::debug!(
315            node = %node_id,
316            kind = %kind,
317            "topology: connector does not expose a dataset_uri; omitted from lineage/catalog"
318        );
319        return;
320    }
321    let uri = dataset_uri;
322    map.insert(
323        node_id.to_string(),
324        NodeIdentity {
325            kind: kind.to_string(),
326            dataset_uri: uri,
327            config,
328        },
329    );
330}
331
332/// Per-node identities, keyed by node id. Only source and sink nodes appear.
333pub type NodeIdentities = std::collections::HashMap<String, NodeIdentity>;
334
335/// [`build_topology_with`] that also reports each source/sink node's identity.
336pub async fn build_topology_meta(
337    cfg: &PipelineConfig,
338    auth: &AuthCatalog,
339    opts: &TopologyRunOptions,
340) -> CliResult<(Topology, NodeIdentities)> {
341    let mut ids = NodeIdentities::new();
342    let topo = build_topology_inner(cfg, auth, opts, Some(&mut ids)).await?;
343    Ok((topo, ids))
344}
345
346/// Build a [`faucet_core::Topology`], honouring the run options.
347///
348/// Two things happen here that the matrix path does per invocation in
349/// [`crate::executor`], and that topology mode used to skip entirely:
350///
351/// - **`${now.*}` is resolved** in every node's source/sink config, and a
352///   leftover `${backfill.*}` token is rejected. Without this the literal token
353///   string reached the connector, so a dated path became a directory named
354///   `${now.date}` (#456 H4).
355/// - **Preview modes wrap the sinks**: `--dry-run` swaps in a counting sink and
356///   `--limit` truncates, so neither performs a real write (#456 C2).
357pub async fn build_topology_with(
358    cfg: &PipelineConfig,
359    auth: &AuthCatalog,
360    opts: &TopologyRunOptions,
361) -> CliResult<Topology> {
362    build_topology_inner(cfg, auth, opts, None).await
363}
364
365async fn build_topology_inner(
366    cfg: &PipelineConfig,
367    auth: &AuthCatalog,
368    opts: &TopologyRunOptions,
369    mut identities: Option<&mut NodeIdentities>,
370) -> CliResult<Topology> {
371    // Cheap graph checks first, so a wiring typo never costs a connector build.
372    validate_topology_spec(cfg)?;
373
374    let clock = opts.clock();
375    let spec = &cfg.pipeline;
376    let mut builder = Topology::builder();
377
378    // Deterministic node order (sorted by id) so errors/logs are stable.
379    let mut node_ids: Vec<&String> = spec.nodes.keys().collect();
380    node_ids.sort();
381
382    for id in &node_ids {
383        let node = &spec.nodes[*id];
384        let kind: NodeKind = match node {
385            NodeSpec::Source {
386                template,
387                kind,
388                config,
389            } => {
390                let (k, mut c) = resolve_connector(
391                    &spec.sources,
392                    &spec.source,
393                    template.as_deref(),
394                    kind.as_deref(),
395                    config.as_ref(),
396                    id,
397                    "source",
398                )?;
399                crate::executor::resolve_now_inplace(&mut c, clock)?;
400                crate::executor::reject_unresolved_backfill_tokens(&c, "source")?;
401                let source = build_source(&k, c.clone(), auth, None).await?;
402                record_identity(&mut identities, id, &k, c, source.dataset_uri());
403                NodeKind::Source(source)
404            }
405            NodeSpec::Sink {
406                template,
407                kind,
408                config,
409            } => {
410                let (k, mut c) = resolve_connector(
411                    &spec.sinks,
412                    &spec.sink,
413                    template.as_deref(),
414                    kind.as_deref(),
415                    config.as_ref(),
416                    id,
417                    "sink",
418                )?;
419                crate::executor::resolve_now_inplace(&mut c, clock)?;
420                crate::executor::reject_unresolved_backfill_tokens(&c, "sink")?;
421                // Preview modes must never reach the real destination. The
422                // identity is still recorded from the *real* config, so a
423                // `--dry-run` report names the destination it would have
424                // written rather than the counting stand-in.
425                let sink: Box<dyn faucet_core::Sink> = if opts.dry_run {
426                    if let Ok(probe) = build_sink(&k, c.clone(), auth).await {
427                        record_identity(&mut identities, id, &k, c.clone(), probe.dataset_uri());
428                    }
429                    Box::new(crate::executor::CountingSink::new())
430                } else {
431                    let sink = build_sink(&k, c.clone(), auth).await?;
432                    record_identity(&mut identities, id, &k, c, sink.dataset_uri());
433                    sink
434                };
435                let sink = match opts.limit {
436                    Some(n) => Box::new(crate::executor::LimitedSink::wrap(sink, n)) as Box<_>,
437                    None => sink,
438                };
439                NodeKind::Sink(sink)
440            }
441            NodeSpec::Transform { transforms } => {
442                let stages = compile_transforms(transforms)?;
443                let compiled = stages
444                    .iter()
445                    .map(compile_stage)
446                    .collect::<Result<Vec<_>, _>>()?;
447                NodeKind::Transform(compiled)
448            }
449            NodeSpec::Tee {
450                channel_capacity,
451                fanout,
452            } => NodeKind::Tee {
453                capacity: *channel_capacity,
454                fanout: *fanout,
455            },
456            NodeSpec::Merge => NodeKind::Merge,
457            NodeSpec::Join(js) => NodeKind::Join(JoinNode {
458                config: JoinConfig {
459                    mode: js.mode,
460                    build_key: js.build.key.clone(),
461                    probe_key: js.probe.key.clone(),
462                    projections: js.project.clone(),
463                    on_missing: js.on_missing.clone(),
464                    on_duplicate: js.on_duplicate,
465                    on_collision: js.on_collision,
466                    key_normalize: js.key_normalize,
467                    max_build_records: js.max_build_records,
468                },
469                build_edge: js.build.edge.clone(),
470                probe_edge: js.probe.edge.clone(),
471            }),
472        };
473        builder = builder.node((*id).clone(), kind);
474    }
475
476    // Edge endpoints were already validated by `validate_topology_spec` above —
477    // before any connector was constructed — so just wire them.
478    for e in &spec.edges {
479        builder = match &e.label {
480            Some(label) => builder.labelled_edge(e.from.clone(), e.to.clone(), label.clone()),
481            None => builder.edge(e.from.clone(), e.to.clone()),
482        };
483    }
484
485    builder.build().map_err(|e| CliError::InvalidTopology {
486        message: e.to_string(),
487    })
488}
489
490/// Compile the config's governance blocks for a node graph.
491///
492/// Mirrors the matrix path in [`crate::executor`] so a topology enforces the same
493/// policies — before this existed, a config declaring `masking:` ran with no
494/// masking at all and PII reached every destination in the clear (#456 C3).
495///
496/// Masking is destination-scoped, so it is compiled **per sink node** against
497/// that node's identifiers (node id, template ref, connector kind) — any of which
498/// an `applies_to` rule may name. A sink for which no rule applies gets no entry,
499/// so the pass is skipped entirely for it.
500fn build_governance(cfg: &PipelineConfig) -> CliResult<TopologyGovernance> {
501    #[allow(unused_mut)]
502    let mut g = TopologyGovernance::new();
503
504    #[cfg(feature = "quality")]
505    if let Some(spec) = &cfg.pipeline.quality {
506        g.quality = Some(std::sync::Arc::new(
507            faucet_core::CompiledQuality::compile(spec)
508                .map_err(|e| CliError::Config(format!("quality: {e}")))?,
509        ));
510    }
511    #[cfg(feature = "contract")]
512    if let Some(spec) = &cfg.pipeline.contract {
513        g.contract = Some(std::sync::Arc::new(
514            faucet_core::CompiledContract::compile(spec)
515                .map_err(|e| CliError::Config(format!("contract: {e}")))?,
516        ));
517    }
518    if let Some(spec) = &cfg.pipeline.schema {
519        g.schema_drift = Some(faucet_core::SchemaDriftPolicy::compile(spec));
520    }
521    if let Some(spec) = &cfg.resilience {
522        g.resilience = Some(spec.to_policy()?);
523    }
524    // Delivery guarantee (#458). `validate_topology_spec` has already checked the
525    // per-node requirements, so by here `exactly_once` is known to be supportable.
526    g.delivery = cfg.delivery;
527
528    #[cfg(feature = "masking")]
529    if let Some(spec) = &cfg.pipeline.masking {
530        for (node_id, node) in &cfg.pipeline.nodes {
531            let NodeSpec::Sink { template, kind, .. } = node else {
532                continue;
533            };
534            let template_ref = template.as_deref().unwrap_or("default");
535            // The node's own kind override, else the template's declared kind.
536            let resolved_kind = kind.clone().or_else(|| {
537                cfg.pipeline
538                    .sinks
539                    .get(template_ref)
540                    .or(cfg.pipeline.sink.as_ref())
541                    .map(|t| t.kind.clone())
542            });
543            let mut ids: Vec<&str> = vec![node_id.as_str(), template_ref];
544            if let Some(k) = resolved_kind.as_deref() {
545                ids.push(k);
546            }
547            let compiled = faucet_core::CompiledMasking::compile_for_sink(spec, &ids)
548                .map_err(|e| CliError::Config(format!("masking: {e}")))?;
549            if !compiled.is_empty() {
550                g.masking_by_sink
551                    .insert(node_id.clone(), std::sync::Arc::new(compiled));
552            }
553        }
554    }
555    Ok(g)
556}
557
558/// Collect a bounded preview of each `source` node's records (source side
559/// only; downstream nodes are not run). Returns `(node_id, records)` per
560/// source node, in sorted node-id order.
561pub async fn preview_records(
562    cfg: &PipelineConfig,
563    auth: &AuthCatalog,
564    limit: usize,
565) -> CliResult<Vec<(String, Vec<Value>)>> {
566    if !cfg.matrix.is_empty() {
567        return Err(CliError::MatrixAndNodesBothPresent);
568    }
569    let spec = &cfg.pipeline;
570    let mut ids: Vec<&String> = spec.nodes.keys().collect();
571    ids.sort();
572
573    let mut out = Vec::new();
574    for id in ids {
575        if let NodeSpec::Source {
576            template,
577            kind,
578            config,
579        } = &spec.nodes[id]
580        {
581            let (k, c) = resolve_connector(
582                &spec.sources,
583                &spec.source,
584                template.as_deref(),
585                kind.as_deref(),
586                config.as_ref(),
587                id,
588                "source",
589            )?;
590            let source = build_source(&k, c, auth, None).await?;
591            let records = source.fetch_all().await?;
592            out.push((
593                id.clone(),
594                records.into_iter().take(limit).collect::<Vec<_>>(),
595            ));
596        }
597    }
598    if out.is_empty() {
599        return Err(CliError::InvalidTopology {
600            message: "no source nodes to preview".to_string(),
601        });
602    }
603    Ok(out)
604}
605
606/// Preview topology mode: build each `source` node and print the first
607/// `limit` records per source to stdout as JSON Lines.
608pub async fn preview(cfg: &PipelineConfig, auth: &AuthCatalog, limit: usize) -> CliResult<()> {
609    for (id, records) in preview_records(cfg, auth, limit).await? {
610        tracing::info!(node = %id, "previewing source node");
611        for rec in records {
612            println!("{}", serde_json::to_string(&rec).unwrap_or_default());
613        }
614    }
615    Ok(())
616}
617
618/// Preview topology sources into a JSON string (for the MCP `preview` tool).
619pub async fn preview_to_string(
620    cfg: &PipelineConfig,
621    auth: &AuthCatalog,
622    limit: usize,
623) -> CliResult<String> {
624    let sources = preview_records(cfg, auth, limit).await?;
625    let doc: Vec<Value> = sources
626        .into_iter()
627        .map(|(id, records)| {
628            serde_json::json!({ "node": id, "count": records.len(), "records": records })
629        })
630        .collect();
631    Ok(
632        serde_json::to_string_pretty(&serde_json::json!({ "sources": doc }))
633            .unwrap_or_else(|_| "[]".to_string()),
634    )
635}
636
637/// Build and run the topology, returning a [`RunSummary`] shaped like a matrix
638/// run (one invocation per sink node, plus one per node failure under
639/// `on_error: continue`).
640pub async fn run_topology(
641    cfg: &PipelineConfig,
642    auth: &AuthCatalog,
643    run: TopologyRunOptions,
644) -> CliResult<RunSummary> {
645    for (block, consequence) in inert_blocks(cfg) {
646        tracing::warn!(
647            block,
648            "`{block}:` is not applied in topology mode (`pipeline.nodes`) — {consequence}"
649        );
650    }
651
652    let (topo, identities) = build_topology_meta(cfg, auth, &run).await?;
653
654    let pipeline_name = cfg.name.clone().unwrap_or_else(|| "unnamed".to_string());
655    let run_id = uuid::Uuid::now_v7().to_string();
656
657    let on_error = match cfg.execution.as_ref().map(|e| e.on_error) {
658        Some(crate::config::OnError::Stop) => TopologyOnError::Propagate,
659        _ => TopologyOnError::Continue,
660    };
661
662    let mut opts = TopologyOptions::new(pipeline_name.clone()).with_on_error(on_error);
663    opts.run_id = run_id.clone();
664
665    if let Some(state) = &cfg.pipeline.state {
666        let store = crate::state::build_state_store(state).await?;
667        // A preview must not advance a durable bookmark: the counting/truncating
668        // sinks return `Ok` without a real write, so a persisted bookmark would
669        // make the next real run resume past records nobody wrote (#456 C2,
670        // mirroring #321 H1 on the matrix path). Reads still pass through.
671        let store = if run.is_preview() {
672            std::sync::Arc::new(crate::executor::ReadOnlyStateStore { inner: store })
673                as std::sync::Arc<dyn faucet_core::StateStore>
674        } else {
675            store
676        };
677        opts = opts.with_state_store(store);
678    }
679    if let Some(dlq) = &cfg.pipeline.dlq {
680        opts = opts.with_dlq(crate::executor::build_dlq_config(dlq).await?);
681    }
682    if let Some(c) = run.cancel.clone() {
683        opts = opts.with_cancel(c);
684    }
685
686    // Lineage START, one per sink node — a topology's analogue of an invocation.
687    // Built before the run so a crash still leaves a START on record.
688    #[cfg(feature = "lineage")]
689    let lineage = crate::lineage_glue::build_emitter(cfg.lineage.as_ref())
690        .map_err(|e| CliError::Config(format!("lineage: {e}")))?;
691    #[cfg(feature = "lineage")]
692    let reaching_for_lineage = reaching_sources(cfg);
693    #[cfg(feature = "lineage")]
694    if let (Some(em), Some(lc)) = (lineage.as_ref(), cfg.lineage.as_ref()) {
695        for (node_id, _) in cfg
696            .pipeline
697            .nodes
698            .iter()
699            .filter(|(_, n)| matches!(n, NodeSpec::Sink { .. }))
700        {
701            if let Some(ctx) = lineage_ctx(
702                cfg,
703                &pipeline_name,
704                &run_id,
705                node_id,
706                &identities,
707                &reaching_for_lineage,
708                lc,
709                0,
710                None,
711            ) {
712                em.emit(faucet_lineage::EventType::Start, &ctx).await;
713            }
714        }
715    }
716
717    // `run_reported` rather than `run_with`: the post-run pass below emits one
718    // notification and evaluates one SLA per **sink node**, which needs to know
719    // which node failed (#459).
720    let state_store = opts.state_store.clone();
721    let cancelled = run.cancel.as_ref().is_some_and(|c| c.is_cancelled());
722    let reported = topo.run_reported(opts, build_governance(cfg)?).await?;
723
724    // Per-sink-node observability. A sink node is a topology's analogue of a
725    // matrix invocation — it owns a state key, a bookmark, and a record count —
726    // so the SLA and notification passes key off it, reusing the same standalone
727    // functions the executor calls rather than a parallel implementation.
728    if !run.is_preview() && !cancelled {
729        post_run_observability(
730            cfg,
731            PostRun {
732                pipeline_name: &pipeline_name,
733                reported: &reported,
734                state_store: state_store.as_ref(),
735                identities: &identities,
736                reaching: &reaching_sources(cfg),
737                clock: run.clock(),
738                run_id: &run_id,
739                #[cfg(feature = "lineage")]
740                lineage: lineage.as_ref(),
741            },
742        )
743        .await;
744    }
745
746    let mut invocations: Vec<InvocationOutcome> = reported
747        .result
748        .per_sink
749        .iter()
750        .map(|(node_id, records)| InvocationOutcome {
751            row_id: node_id.clone(),
752            parent_record_key: None,
753            records_written: *records,
754            error: None,
755            metrics: None,
756        })
757        .collect();
758    invocations.sort_by(|a, b| a.row_id.cmp(&b.row_id));
759
760    // Failures, attributed to the node that produced them instead of a flat
761    // "topology" row (#459).
762    for n in reported.nodes.iter().filter(|n| n.error.is_some()) {
763        invocations.push(InvocationOutcome {
764            row_id: n.node_id.clone(),
765            parent_record_key: None,
766            records_written: 0,
767            error: n.error.clone(),
768            metrics: None,
769        });
770    }
771
772    Ok(RunSummary { invocations })
773}
774
775/// Build the lineage context for one sink node: every source that reaches it as
776/// an input, the sink as the output.
777///
778/// `None` when the node has no identity or no reaching source — there is nothing
779/// truthful to emit in that case, and OpenLineage would rather have no event than
780/// one naming a dataset that does not exist.
781#[cfg(feature = "lineage")]
782#[allow(clippy::too_many_arguments)]
783fn lineage_ctx(
784    _cfg: &PipelineConfig,
785    pipeline_name: &str,
786    run_id: &str,
787    node_id: &str,
788    identities: &NodeIdentities,
789    reaching: &std::collections::HashMap<String, Vec<String>>,
790    lc: &faucet_lineage::LineageConfig,
791    records: u64,
792    error: Option<String>,
793) -> Option<faucet_lineage::RunLifecycle> {
794    let sink = identities.get(node_id)?;
795    let inputs: Vec<faucet_lineage::DatasetRef> = reaching
796        .get(node_id)?
797        .iter()
798        .filter_map(|src| {
799            let ident = identities.get(src)?;
800            Some(faucet_lineage::DatasetRef {
801                namespace: lc.namespace.clone(),
802                name: ident.dataset_uri.clone(),
803            })
804        })
805        .collect();
806    if inputs.is_empty() {
807        return None;
808    }
809    Some(faucet_lineage::RunLifecycle {
810        job_namespace: lc.namespace.clone(),
811        // One OpenLineage job per sink node, so a graph shows up as several
812        // related jobs rather than one opaque run.
813        job_name: format!("{pipeline_name}.{node_id}"),
814        run_id: run_id.to_string(),
815        parent: lc.parent_job.clone(),
816        inputs,
817        output: faucet_lineage::DatasetRef {
818            namespace: lc.namespace.clone(),
819            name: sink.dataset_uri.clone(),
820        },
821        started_at: chrono::Utc::now(),
822        finished_at: None,
823        records,
824        error,
825        input_schemas: Vec::new(),
826        output_schema: None,
827        // Derived from a single transform chain, which a graph's per-node chains
828        // are not — omitted rather than fabricated.
829        column_lineage: None,
830        source_code: None,
831    })
832}
833
834/// For each **sink** node, the source nodes that reach it, in deterministic order.
835///
836/// A linear or tee graph gives one source per sink; a merge or join gives several,
837/// which is why lineage and catalog model a list of inputs (#459). Pure reverse
838/// reachability over the declared edges — no connectors involved.
839pub fn reaching_sources(cfg: &PipelineConfig) -> std::collections::HashMap<String, Vec<String>> {
840    use std::collections::{HashMap, HashSet};
841    let mut rev: HashMap<&str, Vec<&str>> = HashMap::new();
842    for e in &cfg.pipeline.edges {
843        rev.entry(e.to.as_str()).or_default().push(e.from.as_str());
844    }
845    let is_source = |id: &str| matches!(cfg.pipeline.nodes.get(id), Some(NodeSpec::Source { .. }));
846
847    let mut out = HashMap::new();
848    for (id, node) in &cfg.pipeline.nodes {
849        if !matches!(node, NodeSpec::Sink { .. }) {
850            continue;
851        }
852        // Walk upstream; a DAG so a seen-set is enough to terminate.
853        let mut found: Vec<String> = Vec::new();
854        let mut seen: HashSet<&str> = HashSet::new();
855        let mut stack: Vec<&str> = rev.get(id.as_str()).cloned().unwrap_or_default();
856        while let Some(n) = stack.pop() {
857            if !seen.insert(n) {
858                continue;
859            }
860            if is_source(n) {
861                found.push(n.to_string());
862            }
863            if let Some(ups) = rev.get(n) {
864                stack.extend(ups.iter().copied());
865            }
866        }
867        found.sort();
868        out.insert(id.clone(), found);
869    }
870    out
871}
872
873/// Freshness/volume SLAs, notifications, lineage, and catalog — once per sink node.
874///
875/// Deliberately a thin adapter: `sla::evaluate_post_run` and the `NotifyEvent`
876/// constructors are already standalone, so topology mode calls exactly what the
877/// matrix executor calls. Neither can fail a run — an SLA violation is a signal
878/// and a notification is best-effort — so this returns nothing.
879struct PostRun<'a> {
880    pipeline_name: &'a str,
881    reported: &'a faucet_core::topology::TopologyRun,
882    state_store: Option<&'a std::sync::Arc<dyn faucet_core::StateStore>>,
883    identities: &'a NodeIdentities,
884    reaching: &'a std::collections::HashMap<String, Vec<String>>,
885    clock: DateTime<FixedOffset>,
886    run_id: &'a str,
887    #[cfg(feature = "lineage")]
888    lineage: Option<&'a std::sync::Arc<faucet_lineage::LineageEmitter>>,
889}
890
891async fn post_run_observability(cfg: &PipelineConfig, ctx: PostRun<'_>) {
892    let PostRun {
893        pipeline_name,
894        reported,
895        state_store,
896        identities,
897        reaching,
898        clock,
899        run_id,
900        #[cfg(feature = "lineage")]
901        lineage,
902    } = ctx;
903    #[cfg(feature = "catalog")]
904    let catalog = match cfg.catalog.as_ref() {
905        Some(spec) => match crate::catalog::connect_from_spec(spec).await {
906            Ok(h) => Some(h),
907            Err(e) => {
908                // Recording is best-effort; a bad catalog URL must not retro-fail
909                // a run whose data is already written.
910                tracing::error!(error = %e, "catalog connect failed; not recording");
911                None
912            }
913        },
914        None => None,
915    };
916    #[cfg(feature = "notify")]
917    let notifier = match crate::notify::Notifier::from_specs(&cfg.notifications) {
918        Ok(n) => n,
919        Err(e) => {
920            // A malformed block is a config error, but it must not fail a run that
921            // has already written its data.
922            tracing::error!(error = %e, "notifications config invalid; not notifying");
923            None
924        }
925    };
926    let now = chrono::Utc::now().timestamp();
927
928    for node in reported.nodes.iter().filter(|n| n.kind == "sink") {
929        let row = node.node_id.as_str();
930
931        // ── SLA (#202) ───────────────────────────────────────────────────────
932        let violations = match cfg.sla.as_ref() {
933            Some(spec) => {
934                let base_key = format!("{pipeline_name}::{row}");
935                let outcome = match &node.error {
936                    None => crate::sla::RunOutcome::Success {
937                        rows: node.records as u64,
938                    },
939                    Some(_) => crate::sla::RunOutcome::Failure,
940                };
941                let v = crate::sla::evaluate_post_run(
942                    spec,
943                    state_store,
944                    &base_key,
945                    pipeline_name,
946                    row,
947                    outcome,
948                    now,
949                )
950                .await;
951                for violation in &v {
952                    tracing::warn!(node = %row, kind = violation.kind(), "SLA violation: {violation}");
953                }
954                v
955            }
956            None => Vec::new(),
957        };
958
959        // ── Notifications (#280) ─────────────────────────────────────────────
960        #[cfg(feature = "notify")]
961        if let Some(notifier) = &notifier {
962            use crate::notify::NotifyEvent;
963            match &node.error {
964                None => {
965                    notifier
966                        .emit(NotifyEvent::run_success(
967                            pipeline_name,
968                            row,
969                            node.records as u64,
970                        ))
971                        .await;
972                }
973                Some(msg) => {
974                    notifier
975                        .emit(NotifyEvent::run_failure(pipeline_name, row, "sink", msg))
976                        .await;
977                }
978            }
979            for v in &violations {
980                notifier
981                    .emit(NotifyEvent::sla_breach(
982                        pipeline_name,
983                        row,
984                        v.kind(),
985                        v.to_string(),
986                    ))
987                    .await;
988            }
989        }
990        #[cfg(not(feature = "notify"))]
991        let _ = &violations;
992
993        // ── OpenLineage terminal event (#459) ────────────────────────────────
994        #[cfg(feature = "lineage")]
995        if let (Some(em), Some(lc)) = (lineage, cfg.lineage.as_ref())
996            && let Some(ctx) = lineage_ctx(
997                cfg,
998                pipeline_name,
999                run_id,
1000                row,
1001                identities,
1002                reaching,
1003                lc,
1004                node.records as u64,
1005                node.error.clone(),
1006            )
1007        {
1008            let ev = match node.error {
1009                None => faucet_lineage::EventType::Complete,
1010                Some(_) => faucet_lineage::EventType::Fail,
1011            };
1012            em.emit(ev, &ctx).await;
1013        }
1014
1015        // ── Data Movement Catalog (#279 / #459) ──────────────────────────────
1016        // One record per sink node: every source that reaches it as an input,
1017        // plus the sink itself. A successful node only — a failed one's partial
1018        // volume is not a signal, matching the matrix path.
1019        #[cfg(feature = "catalog")]
1020        if node.error.is_none()
1021            && let Some(handle) = catalog.as_ref()
1022            && let Some(sink_id) = identities.get(row)
1023        {
1024            use crate::catalog::model::canonicalize_uri;
1025            use crate::serve::history::catalog::{CatalogUpdate, DatasetObservation, DatasetRole};
1026
1027            let sources: Vec<DatasetObservation> = reaching
1028                .get(row)
1029                .map(Vec::as_slice)
1030                .unwrap_or_default()
1031                .iter()
1032                .filter_map(|src| {
1033                    let ident = identities.get(src)?;
1034                    Some(DatasetObservation {
1035                        uri: canonicalize_uri(&ident.dataset_uri, &ident.config, clock),
1036                        kind: ident.kind.clone(),
1037                        role: DatasetRole::Source,
1038                        // Each input reports what *it* read, so a merge's edge
1039                        // volumes sum to the sink instead of repeating its total.
1040                        records: reported
1041                            .nodes
1042                            .iter()
1043                            .find(|n| &n.node_id == src)
1044                            .map(|n| n.records as u64)
1045                            .unwrap_or(0),
1046                        schema: None,
1047                    })
1048                })
1049                .collect();
1050
1051            if sources.is_empty() {
1052                tracing::debug!(node = %row, "no source reaches this sink; nothing to catalog");
1053            } else {
1054                let update = CatalogUpdate {
1055                    run_id: run_id.to_string(),
1056                    pipeline: pipeline_name.to_string(),
1057                    row: row.to_string(),
1058                    recorded_at: chrono::Utc::now(),
1059                    sources,
1060                    sink: DatasetObservation {
1061                        uri: canonicalize_uri(&sink_id.dataset_uri, &sink_id.config, clock),
1062                        kind: sink_id.kind.clone(),
1063                        role: DatasetRole::Sink,
1064                        schema: None,
1065                        records: node.records as u64,
1066                    },
1067                    // Column lineage is derived from a single transform chain; a
1068                    // graph's per-node chains are not that, so it is left absent
1069                    // rather than fabricated.
1070                    column_lineage: None,
1071                };
1072                crate::catalog::record(handle, &update).await;
1073            }
1074        }
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081
1082    fn cfg(yaml: &str) -> PipelineConfig {
1083        serde_yaml::from_str(yaml).expect("valid config")
1084    }
1085
1086    #[cfg(feature = "lineage")]
1087    fn lineage_cfg() -> faucet_lineage::LineageConfig {
1088        serde_json::from_value(serde_json::json!({
1089            "namespace": "ns",
1090            "transport": { "type": "file", "config": { "path": "/tmp/ol.jsonl" } },
1091        }))
1092        .expect("valid lineage config")
1093    }
1094
1095    const LINEAR: &str = r#"version: 1
1096name: p
1097pipeline:
1098  sources:
1099    a: { type: csv, config: { path: /tmp/a.csv } }
1100  sinks:
1101    o: { type: jsonl, config: { path: /tmp/o.jsonl } }
1102  nodes:
1103    s: { kind: source, ref: a }
1104    w: { kind: sink, ref: o }
1105  edges:
1106    - { from: s, to: w }
1107"#;
1108
1109    /// The invariant #459 exists to hold: nothing is parsed-but-ignored, so
1110    /// `validate` has nothing to warn about. If this fails because a block was
1111    /// added to the list, wire the block instead of updating the assertion.
1112    #[test]
1113    fn no_block_is_inert() {
1114        let mut c = cfg(LINEAR);
1115        c.sla =
1116            Some(serde_json::from_value(serde_json::json!({ "max_staleness_secs": 60 })).unwrap());
1117        assert!(inert_blocks(&c).is_empty());
1118    }
1119
1120    /// Kind precedence: an inline `type` on the node wins over its template, so
1121    /// the exactly-once gate classifies the connector the run will actually build.
1122    #[test]
1123    fn resolved_kind_prefers_inline_type_over_template() {
1124        let c = cfg(r#"version: 1
1125name: p
1126pipeline:
1127  sinks:
1128    o: { type: jsonl, config: { path: /tmp/o.jsonl } }
1129  nodes:
1130    s: { kind: source, type: csv, config: { path: /tmp/a.csv } }
1131    w: { kind: sink, ref: o, type: stdout, config: {} }
1132  edges:
1133    - { from: s, to: w }
1134"#);
1135        assert_eq!(
1136            resolved_node_kind(&c, &c.pipeline.nodes["w"]).as_deref(),
1137            Some("stdout")
1138        );
1139        assert_eq!(
1140            resolved_node_kind(&c, &c.pipeline.nodes["s"]).as_deref(),
1141            Some("csv")
1142        );
1143    }
1144
1145    /// A non-connector node has no kind — the gate must skip it rather than
1146    /// treating an empty string as an unsupported connector.
1147    #[test]
1148    fn resolved_kind_is_none_for_a_structural_node() {
1149        let c = cfg(r#"version: 1
1150name: p
1151pipeline:
1152  sources:
1153    a: { type: csv, config: { path: /tmp/a.csv } }
1154  sinks:
1155    o: { type: jsonl, config: { path: /tmp/o.jsonl } }
1156  nodes:
1157    s: { kind: source, ref: a }
1158    f: { kind: tee, fanout: 1 }
1159    w: { kind: sink, ref: o }
1160  edges:
1161    - { from: s, to: f }
1162    - { from: f, to: w }
1163"#);
1164        assert!(resolved_node_kind(&c, &c.pipeline.nodes["f"]).is_none());
1165    }
1166
1167    /// A sink with no reaching source produces no lineage event. Emitting one
1168    /// would name an input dataset the run never read.
1169    #[cfg(feature = "lineage")]
1170    #[test]
1171    fn lineage_ctx_is_none_without_a_reaching_source() {
1172        let c = cfg(LINEAR);
1173        let mut identities = NodeIdentities::new();
1174        identities.insert(
1175            "w".to_string(),
1176            NodeIdentity {
1177                kind: "jsonl".into(),
1178                dataset_uri: "file:///tmp/o.jsonl".into(),
1179                config: Value::Null,
1180            },
1181        );
1182        let lc = lineage_cfg();
1183        // `reaching` deliberately empty: the sink is in the graph but no source
1184        // reaches it.
1185        let ctx = lineage_ctx(
1186            &c,
1187            "p",
1188            "run-1",
1189            "w",
1190            &identities,
1191            &std::collections::HashMap::new(),
1192            &lc,
1193            0,
1194            None,
1195        );
1196        assert!(ctx.is_none());
1197    }
1198
1199    /// A merge sink's event carries every reaching source as an input, and
1200    /// deliberately no column lineage — the per-column derivation is not knowable
1201    /// from the graph, so it is omitted rather than guessed.
1202    #[cfg(feature = "lineage")]
1203    #[test]
1204    fn lineage_ctx_carries_all_inputs_and_no_column_lineage() {
1205        let c = cfg(LINEAR);
1206        let mut identities = NodeIdentities::new();
1207        for (id, uri) in [
1208            ("sa", "file:///tmp/a.csv"),
1209            ("sb", "file:///tmp/b.csv"),
1210            ("w", "file:///tmp/o.jsonl"),
1211        ] {
1212            identities.insert(
1213                id.to_string(),
1214                NodeIdentity {
1215                    kind: "csv".into(),
1216                    dataset_uri: uri.into(),
1217                    config: Value::Null,
1218                },
1219            );
1220        }
1221        let lc = lineage_cfg();
1222        let reaching = std::collections::HashMap::from([(
1223            "w".to_string(),
1224            vec!["sa".to_string(), "sb".to_string()],
1225        )]);
1226        let ctx = lineage_ctx(&c, "p", "run-1", "w", &identities, &reaching, &lc, 7, None)
1227            .expect("both sources known");
1228        assert_eq!(ctx.job_name, "p.w");
1229        assert_eq!(
1230            ctx.inputs
1231                .iter()
1232                .map(|d| d.name.as_str())
1233                .collect::<Vec<_>>(),
1234            vec!["file:///tmp/a.csv", "file:///tmp/b.csv"]
1235        );
1236        assert_eq!(ctx.output.name, "file:///tmp/o.jsonl");
1237        assert_eq!(ctx.records, 7);
1238        assert!(ctx.column_lineage.is_none());
1239    }
1240
1241    /// An input whose identity was never recorded is dropped, not rendered as an
1242    /// empty dataset name.
1243    #[cfg(feature = "lineage")]
1244    #[test]
1245    fn lineage_ctx_skips_an_unknown_input() {
1246        let c = cfg(LINEAR);
1247        let mut identities = NodeIdentities::new();
1248        for (id, uri) in [("sa", "file:///tmp/a.csv"), ("w", "file:///tmp/o.jsonl")] {
1249            identities.insert(
1250                id.to_string(),
1251                NodeIdentity {
1252                    kind: "csv".into(),
1253                    dataset_uri: uri.into(),
1254                    config: Value::Null,
1255                },
1256            );
1257        }
1258        let lc = lineage_cfg();
1259        let reaching = std::collections::HashMap::from([(
1260            "w".to_string(),
1261            vec!["sa".to_string(), "ghost".to_string()],
1262        )]);
1263        let ctx = lineage_ctx(&c, "p", "run-1", "w", &identities, &reaching, &lc, 1, None)
1264            .expect("one known source is enough");
1265        assert_eq!(ctx.inputs.len(), 1);
1266    }
1267
1268    /// `reaching_sources` walks through structural nodes, so a join's two labelled
1269    /// inputs both surface on the downstream sink.
1270    #[test]
1271    fn reaching_sources_traverses_a_join() {
1272        let c = cfg(r#"version: 1
1273name: p
1274pipeline:
1275  sources:
1276    a: { type: csv, config: { path: /tmp/a.csv } }
1277    b: { type: csv, config: { path: /tmp/b.csv } }
1278  sinks:
1279    o: { type: jsonl, config: { path: /tmp/o.jsonl } }
1280  nodes:
1281    probe: { kind: source, ref: a }
1282    build: { kind: source, ref: b }
1283    j:
1284      kind: join
1285      mode: left
1286      build: { edge: build_in, key: id }
1287      probe: { edge: probe_in, key: id }
1288    w: { kind: sink, ref: o }
1289  edges:
1290    - { from: probe, to: j, as: probe_in }
1291    - { from: build, to: j, as: build_in }
1292    - { from: j, to: w }
1293"#);
1294        let reaching = reaching_sources(&c);
1295        assert_eq!(
1296            reaching["w"],
1297            vec!["build".to_string(), "probe".to_string()]
1298        );
1299    }
1300
1301    /// A cyclic edge set must not hang the traversal. Cycles are rejected by
1302    /// graph validation, but this helper also runs from `validate`, so it has to
1303    /// terminate on a config that has not been validated yet.
1304    #[test]
1305    fn reaching_sources_terminates_on_a_cycle() {
1306        let c = cfg(r#"version: 1
1307name: p
1308pipeline:
1309  sources:
1310    a: { type: csv, config: { path: /tmp/a.csv } }
1311  sinks:
1312    o: { type: jsonl, config: { path: /tmp/o.jsonl } }
1313  nodes:
1314    s: { kind: source, ref: a }
1315    t1: { kind: transform, transforms: [ { type: flatten, config: {} } ] }
1316    t2: { kind: transform, transforms: [ { type: flatten, config: {} } ] }
1317    w: { kind: sink, ref: o }
1318  edges:
1319    - { from: s, to: t1 }
1320    - { from: t1, to: t2 }
1321    - { from: t2, to: t1 }
1322    - { from: t2, to: w }
1323"#);
1324        assert_eq!(reaching_sources(&c)["w"], vec!["s".to_string()]);
1325    }
1326}