1use 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
27pub fn is_topology(cfg: &PipelineConfig) -> bool {
29 !cfg.pipeline.nodes.is_empty()
30}
31
32#[derive(Default, Clone)]
35pub struct TopologyRunOptions {
36 pub cancel: Option<CancellationToken>,
38 pub dry_run: bool,
41 pub limit: Option<usize>,
44 pub clock: Option<DateTime<FixedOffset>>,
46}
47
48impl TopologyRunOptions {
49 fn clock(&self) -> DateTime<FixedOffset> {
51 self.clock
52 .unwrap_or_else(|| chrono::Utc::now().fixed_offset())
53 }
54
55 fn is_preview(&self) -> bool {
57 self.dry_run || self.limit.is_some()
58 }
59}
60
61pub fn inert_blocks(cfg: &PipelineConfig) -> Vec<(&'static str, &'static str)> {
74 let _ = cfg;
75 Vec::new()
76}
77
78pub fn validate_topology_spec(cfg: &PipelineConfig) -> CliResult<()> {
88 if !cfg.matrix.is_empty() {
89 return Err(CliError::MatrixAndNodesBothPresent);
90 }
91 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
115fn 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
144fn 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 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 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 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 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 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
227fn 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
277pub async fn build_topology(cfg: &PipelineConfig, auth: &AuthCatalog) -> CliResult<Topology> {
280 build_topology_with(cfg, auth, &TopologyRunOptions::default()).await
281}
282
283#[derive(Debug, Clone)]
288pub struct NodeIdentity {
289 pub kind: String,
291 pub dataset_uri: String,
293 pub config: Value,
295}
296
297fn 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
332pub type NodeIdentities = std::collections::HashMap<String, NodeIdentity>;
334
335pub 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
346pub 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 validate_topology_spec(cfg)?;
373
374 let clock = opts.clock();
375 let spec = &cfg.pipeline;
376 let mut builder = Topology::builder();
377
378 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 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 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
490fn 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 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 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
558pub 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
606pub 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
618pub 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
637pub 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 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 #[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 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 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 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#[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 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 column_lineage: None,
830 source_code: None,
831 })
832}
833
834pub 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 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
873struct 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 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 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 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 #[cfg(feature = "notify")]
961 if let Some(notifier) = ¬ifier {
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 #[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 #[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 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: 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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}