1use crate::auth_catalog::AuthCatalog;
25use crate::config::{ExecutionSpec, OnError};
26use crate::error::{CliError, CliResult};
27use crate::expand::{ExpandedNode, NodeRole};
28use crate::interpolate::interpolate_record;
29use crate::registry::{build_sink, build_source};
30use crate::state::build_state_store;
31use async_trait::async_trait;
32use chrono::{DateTime, FixedOffset};
33use faucet_core::observability::Labels;
34use faucet_core::{DlqConfig, FaucetError, OnBatchError, Pipeline, Sink, Source, StateStore};
35use serde_json::Value;
36use std::collections::{HashMap, HashSet};
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use std::sync::atomic::{AtomicUsize, Ordering};
40use std::time::Duration;
41use tokio::sync::{Mutex, Semaphore};
42
43type CapturedRecords = Arc<Mutex<HashMap<String, Vec<Arc<Value>>>>>;
47use tokio_util::sync::CancellationToken;
48
49pub struct ExecuteOptions {
51 pub pipeline_name: String,
54 pub run_id: Option<String>,
64 pub execution: Option<ExecutionSpec>,
67 pub dry_run: bool,
69 pub limit: Option<usize>,
71 pub state_path_override: Option<PathBuf>,
73 pub shard: Option<faucet_core::ShardSpec>,
78 pub auth: AuthCatalog,
82 pub clock: DateTime<FixedOffset>,
86 pub cancel: Option<CancellationToken>,
92 pub resilience: Option<faucet_core::ResiliencePolicy>,
97 pub sla: Option<crate::sla::SlaSpec>,
102 #[cfg(feature = "lineage")]
105 pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
106 #[cfg(feature = "lineage")]
110 pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
111 #[cfg(feature = "notify")]
117 pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
118 #[cfg(feature = "catalog")]
125 pub catalog: Option<crate::catalog::CatalogHandle>,
126}
127
128const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
133
134#[derive(Debug)]
136pub struct InvocationOutcome {
137 pub row_id: String,
138 pub parent_record_key: Option<String>,
141 pub records_written: usize,
142 pub error: Option<String>,
143 pub metrics: Option<InvocationMetrics>,
148}
149
150#[derive(Debug, Clone, Default)]
155pub struct InvocationMetrics {
156 pub source_kind: String,
157 pub sink_kind: String,
158 pub duration_ms: u64,
159 pub records_read: Option<u64>,
160 pub dlq_count: u64,
161 pub bookmark: Option<Value>,
162}
163
164struct PipelineStats {
168 records_written: usize,
169 records_read: Option<u64>,
170 dlq_count: u64,
171 bookmark: Option<Value>,
172}
173
174#[derive(Debug)]
176pub struct RunSummary {
177 pub invocations: Vec<InvocationOutcome>,
178}
179
180impl RunSummary {
181 pub fn failure_count(&self) -> usize {
182 self.invocations
183 .iter()
184 .filter(|i| i.error.is_some())
185 .count()
186 }
187 pub fn had_failures(&self) -> bool {
188 self.failure_count() > 0
189 }
190}
191
192fn default_concurrency() -> usize {
203 std::thread::available_parallelism()
204 .map(|n| n.get())
205 .unwrap_or(4)
206 .clamp(1, 8)
207}
208
209pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
212 let on_error = opts
213 .execution
214 .as_ref()
215 .map(|e| e.on_error)
216 .unwrap_or_default();
217 let max_concurrent = opts
218 .execution
219 .as_ref()
220 .and_then(|e| e.max_concurrent)
221 .unwrap_or_else(default_concurrency)
222 .max(1);
223 let semaphore = Arc::new(Semaphore::new(max_concurrent));
224
225 let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
230 for n in nodes.iter() {
231 if let NodeRole::Child { parent_id, .. } = &n.role {
232 children_of
233 .entry(parent_id.clone())
234 .or_default()
235 .push(n.id.clone());
236 }
237 }
238
239 let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
244
245 let mut outcomes: Vec<InvocationOutcome> = Vec::new();
246 let mut skipped_subtrees: HashSet<String> = HashSet::new();
247
248 let cancel = opts.cancel.clone().unwrap_or_default();
253 let opts = Arc::new(opts);
254
255 let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
259 let mut completed: HashSet<String> = HashSet::new();
260 let nodes_by_id: HashMap<String, ExpandedNode> =
261 nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
262
263 let projections = build_projections(&nodes_by_id, &children_of);
266
267 let bfs_order: Vec<String> = {
271 let mut ids: Vec<(usize, String)> = nodes_by_id
272 .values()
273 .map(|n| (n.row_index, n.id.clone()))
274 .collect();
275 ids.sort_by_key(|(i, _)| *i);
276 ids.into_iter().map(|(_, id)| id).collect()
277 };
278
279 while !remaining.is_empty() {
280 let ready: Vec<String> = bfs_order
286 .iter()
287 .filter(|id| remaining.contains(*id))
288 .filter(|id| {
289 let node = &nodes_by_id[*id];
290 let parent_done = match &node.role {
291 NodeRole::Root => true,
292 NodeRole::Child { parent_id, .. } => {
293 completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
294 }
295 };
296 parent_done
297 && node
298 .depends_on
299 .iter()
300 .all(|d| completed.contains(d) || skipped_subtrees.contains(d))
301 })
302 .cloned()
303 .collect();
304
305 if ready.is_empty() {
306 let mut stuck: Vec<String> = remaining.iter().cloned().collect();
311 stuck.sort();
312 return Err(CliError::Internal(format!(
313 "executor deadlock: {} node(s) never became ready (no completed/skipped \
314 parent or dependency): {}",
315 stuck.len(),
316 stuck.join(", ")
317 )));
318 }
319
320 let mut units: Vec<Unit> = Vec::new();
323 let level_records: HashMap<String, Vec<Arc<Value>>> = {
330 let consumed_parents: HashSet<&str> = ready
331 .iter()
332 .filter_map(|id| match &nodes_by_id[id].role {
333 NodeRole::Child { parent_id, .. } => Some(parent_id.as_str()),
334 NodeRole::Root => None,
335 })
336 .collect();
337 let mut cap = captured.lock().await;
338 consumed_parents
339 .iter()
340 .filter_map(|p| cap.remove(*p).map(|v| (p.to_string(), v)))
341 .collect()
342 };
343 for id in &ready {
344 let node = &nodes_by_id[id];
345 if let NodeRole::Child { parent_id, .. } = &node.role
348 && skipped_subtrees.contains(parent_id)
349 {
350 skipped_subtrees.insert(id.clone());
351 tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
352 continue;
353 }
354 if let Some(dep) = node
359 .depends_on
360 .iter()
361 .find(|d| skipped_subtrees.contains(d.as_str()))
362 {
363 skipped_subtrees.insert(id.clone());
364 tracing::warn!(
365 row = %id, dependency = %dep,
366 "skipping row: a depends_on row failed or was skipped"
367 );
368 continue;
369 }
370 match &node.role {
371 NodeRole::Root => {
372 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
373 let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
374 validate_unit_state_key(&node.id, uses_state, &state_key)?;
375 units.push(Unit {
376 node: node.clone(),
377 parent_record: None,
378 state_key,
379 parent_record_key: None,
380 });
381 }
382 NodeRole::Child {
383 parent_id,
384 parent_key,
385 } => {
386 let parent_records = level_records.get(parent_id).cloned().unwrap_or_default();
387 if parent_records.is_empty() {
388 tracing::info!(
389 row = %id, parent = %parent_id,
390 "parent produced no records — child skipped"
391 );
392 continue;
393 }
394 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
396 let mut seen_keys: HashSet<String> = HashSet::new();
397 for record in &parent_records {
398 let pk_value = resolve_parent_key(record, parent_key);
399 let pk_string = pk_value
400 .as_ref()
401 .map(value_to_string_brief)
402 .unwrap_or_else(|| "(missing)".to_string());
403 let state_key =
404 build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
405 validate_unit_state_key(&node.id, uses_state, &state_key)?;
406 if !seen_keys.insert(state_key.clone()) {
407 return Err(CliError::DuplicateStateKey {
408 id: node.id.clone(),
409 state_key,
410 });
411 }
412 units.push(Unit {
413 node: node.clone(),
414 parent_record: Some(record.clone()),
415 state_key,
416 parent_record_key: Some(pk_string),
417 });
418 }
419 }
420 }
421 }
422 drop(level_records);
423
424 let mut had_level_failure = false;
425 let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
426
427 let level_cancel = cancel.child_token();
439 let mut joinset = tokio::task::JoinSet::new();
440 let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
444 for unit in units {
445 let sem = Arc::clone(&semaphore);
446 let opts2 = Arc::clone(&opts);
447 let captured = Arc::clone(&captured);
448 let capture = projections.get(&unit.node.id).cloned();
449 let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
450 let unit_cancel = level_cancel.clone();
451 let handle = joinset.spawn(async move {
452 let _permit = sem.acquire().await.expect("semaphore not closed");
453 run_unit(&unit, capture, &captured, &opts2, unit_cancel).await
454 });
455 task_meta.insert(handle.id(), meta);
456 }
457
458 let mut stop_triggered = false;
459 let mut aborted = false;
460 let mut stop_deadline: Option<tokio::time::Instant> = None;
461 loop {
462 let joined = match stop_deadline {
467 Some(deadline) if !aborted => {
468 match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
469 Ok(j) => j,
470 Err(_) => {
471 tracing::warn!(
472 "on_error: stop — flush grace elapsed; aborting remaining \
473 in-flight invocations"
474 );
475 joinset.abort_all();
476 aborted = true;
477 continue;
478 }
479 }
480 }
481 _ => joinset.join_next_with_id().await,
482 };
483 let Some(joined) = joined else { break };
484 let outcome = match joined {
488 Ok((_id, outcome)) => outcome,
489 Err(e) if e.is_cancelled() => {
490 continue;
493 }
494 Err(e) => {
495 let (row_id, parent_record_key) = task_meta
496 .get(&e.id())
497 .cloned()
498 .unwrap_or_else(|| ("<unknown>".to_string(), None));
499 InvocationOutcome {
500 row_id,
501 parent_record_key,
502 records_written: 0,
503 error: Some(format!("pipeline invocation task panicked: {e}")),
504 metrics: None,
505 }
506 }
507 };
508
509 if let Some(err) = &outcome.error {
510 tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
511 had_level_failure = true;
512 nodes_with_any_failure.insert(outcome.row_id.clone());
513 if matches!(on_error, OnError::Stop) && !stop_triggered {
514 stop_triggered = true;
515 tracing::error!(
516 "on_error: stop — cancelling in-flight invocations (cooperative \
517 flush), then aborting any that don't stop within the grace window"
518 );
519 level_cancel.cancel();
523 stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
524 }
525 } else {
526 tracing::info!(
527 row = %outcome.row_id,
528 records_written = outcome.records_written,
529 "pipeline invocation completed"
530 );
531 }
532 outcomes.push(outcome);
533 }
534
535 for id in ready {
539 remaining.remove(&id);
540 if nodes_with_any_failure.contains(&id) {
541 skipped_subtrees.insert(id.clone());
542 if let Some(children) = children_of.get(&id) {
544 for cid in children {
545 skipped_subtrees.insert(cid.clone());
546 }
547 }
548 } else {
549 completed.insert(id);
550 }
551 }
552
553 if had_level_failure && matches!(on_error, OnError::Stop) {
554 tracing::error!("on_error: stop — aborting after first failure");
555 break;
557 }
558 }
559
560 Ok(RunSummary {
561 invocations: outcomes,
562 })
563}
564
565struct Unit {
568 node: ExpandedNode,
569 parent_record: Option<Arc<Value>>,
570 state_key: String,
571 parent_record_key: Option<String>,
572}
573
574async fn run_unit(
575 unit: &Unit,
576 capture: Option<Arc<Projection>>,
577 captured: &CapturedRecords,
578 opts: &ExecuteOptions,
579 cancel: CancellationToken,
580) -> InvocationOutcome {
581 let needs_capture = capture.is_some();
582 let started = std::time::Instant::now();
583 let result = run_one_invocation(
584 &unit.node,
585 unit.parent_record.as_deref(),
586 &unit.state_key,
587 capture,
588 opts,
589 cancel,
590 )
591 .await;
592 let duration_ms = started.elapsed().as_millis() as u64;
593 let row_id = unit.node.id.clone();
594 let parent_record_key = unit.parent_record_key.clone();
595 let base_metrics = || InvocationMetrics {
596 source_kind: unit.node.source.kind.clone(),
597 sink_kind: unit.node.sink.kind.clone(),
598 duration_ms,
599 ..Default::default()
600 };
601 match result {
602 Ok((records, stats)) => {
603 if needs_capture {
604 captured
605 .lock()
606 .await
607 .entry(row_id.clone())
608 .or_default()
609 .extend(records.into_iter().map(Arc::new));
612 }
613 InvocationOutcome {
614 row_id,
615 parent_record_key,
616 records_written: stats.records_written,
617 error: None,
618 metrics: Some(InvocationMetrics {
619 records_read: stats.records_read,
620 dlq_count: stats.dlq_count,
621 bookmark: stats.bookmark,
622 ..base_metrics()
623 }),
624 }
625 }
626 Err(e) => InvocationOutcome {
627 row_id,
628 parent_record_key,
629 records_written: 0,
630 error: Some(e.to_string()),
631 metrics: Some(base_metrics()),
632 },
633 }
634}
635
636pub(crate) fn build_state_key(
638 pipeline_name: &str,
639 row_id: &str,
640 parent_key: Option<&str>,
641) -> String {
642 match parent_key {
643 None => format!("{pipeline_name}::{row_id}"),
644 Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
645 }
646}
647
648fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
653 if uses_state {
654 faucet_core::state::validate_state_key(state_key).map_err(|e| {
655 CliError::InvalidStateKey {
656 id: node_id.to_owned(),
657 state_key: state_key.to_owned(),
658 reason: e.to_string(),
659 }
660 })?;
661 }
662 Ok(())
663}
664
665fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
667 let mut cur = record;
668 for segment in parent_key.split('.') {
669 cur = match cur {
670 Value::Object(m) => m.get(segment)?,
671 Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
672 _ => return None,
673 };
674 }
675 Some(cur.clone())
676}
677
678#[derive(Debug, Clone)]
682enum Projection {
683 Full,
686 Paths(Vec<Vec<String>>),
688}
689
690fn split_path(path: &str) -> Vec<String> {
692 path.split('.').map(|s| s.to_string()).collect()
693}
694
695fn minimal_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
699 paths.sort();
700 paths.dedup();
701 let mut kept: Vec<Vec<String>> = Vec::new();
702 for p in paths {
703 let covered = kept
704 .iter()
705 .any(|anc| p.len() >= anc.len() && p[..anc.len()] == anc[..]);
706 if !covered {
707 kept.push(p);
708 }
709 }
710 kept
711}
712
713fn walk_value(record: &Value, segments: &[String]) -> Option<Value> {
716 let mut cur = record;
717 for seg in segments {
718 cur = match cur {
719 Value::Object(m) => m.get(seg)?,
720 Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
721 _ => return None,
722 };
723 }
724 Some(cur.clone())
725}
726
727fn graft_object(out: &mut Value, segments: &[String], leaf: Value) {
732 if segments.is_empty() {
733 return;
734 }
735 let mut cur = out;
736 for seg in &segments[..segments.len() - 1] {
737 let map = match cur {
738 Value::Object(m) => m,
739 _ => return,
740 };
741 cur = map
742 .entry(seg.clone())
743 .or_insert_with(|| Value::Object(serde_json::Map::new()));
744 }
745 if let Value::Object(m) = cur {
746 m.insert(segments[segments.len() - 1].clone(), leaf);
747 }
748}
749
750fn project_record(record: &Value, projection: &Projection) -> Value {
755 match projection {
756 Projection::Full => record.clone(),
757 Projection::Paths(paths) => {
758 let mut out = Value::Object(serde_json::Map::new());
759 for segs in paths {
760 if let Some(v) = walk_value(record, segs) {
761 graft_object(&mut out, segs, v);
762 }
763 }
764 out
765 }
766 }
767}
768
769fn build_projections(
774 nodes_by_id: &HashMap<String, ExpandedNode>,
775 children_of: &HashMap<String, Vec<String>>,
776) -> HashMap<String, Arc<Projection>> {
777 let mut out = HashMap::new();
778 for (parent_id, child_ids) in children_of {
779 let mut raw: Vec<Vec<String>> = Vec::new();
780 let mut full = false;
781 for cid in child_ids {
782 let child = &nodes_by_id[cid];
783 if let NodeRole::Child { parent_key, .. } = &child.role {
784 if parent_key.is_empty() {
785 full = true;
786 } else {
787 raw.push(split_path(parent_key));
788 }
789 }
790 for dref in &child.deferred_refs {
791 if dref.referenced_id == *parent_id {
792 if dref.dotted_path.is_empty() {
793 full = true; } else {
795 raw.push(split_path(&dref.dotted_path));
796 }
797 }
798 }
799 }
800 let projection = if full || raw.is_empty() {
805 Projection::Full
806 } else {
807 Projection::Paths(minimal_paths(raw))
808 };
809 out.insert(parent_id.clone(), Arc::new(projection));
810 }
811 out
812}
813
814#[allow(clippy::too_many_arguments)]
825async fn build_pipeline<'a>(
826 source: &'a dyn Source,
827 sink: &'a dyn Sink,
828 node: &ExpandedNode,
829 opts: &ExecuteOptions,
830 state: Option<Arc<dyn StateStore>>,
831 cancel: &CancellationToken,
832 pipeline_name: &str,
833 row_id: &str,
834 run_id: &str,
835 cleanup_scope: Option<Value>,
836) -> CliResult<Pipeline<'a, dyn Source + 'a, dyn Sink + 'a>> {
837 let mut pipeline = Pipeline::new(source, sink)
838 .with_name(pipeline_name.to_owned())
839 .with_row(row_id.to_owned())
840 .with_run_id(run_id.to_owned());
841 if let Some(store) = state {
842 pipeline = pipeline.with_state_store(store);
843 }
844 if let Some(ref dlq_spec) = node.dlq {
845 let dlq_cfg = build_dlq_config(dlq_spec).await?;
846 pipeline = pipeline.with_dlq(dlq_cfg);
847 }
848 pipeline = pipeline.with_cancel(cancel.clone());
853 #[cfg(feature = "quality")]
857 if let Some(ref quality_spec) = node.quality {
858 let compiled = Arc::new(
859 faucet_core::CompiledQuality::compile(quality_spec)
860 .map_err(|e| CliError::Config(format!("quality: {e}")))?,
861 );
862 pipeline = pipeline.with_quality(compiled);
863 }
864 #[cfg(feature = "contract")]
868 if let Some(ref contract_spec) = node.contract {
869 let compiled = Arc::new(
870 faucet_core::CompiledContract::compile(contract_spec)
871 .map_err(|e| CliError::Config(format!("contract: {e}")))?,
872 );
873 pipeline = pipeline.with_contract(compiled);
874 }
875 #[cfg(feature = "masking")]
881 if let Some(ref masking_spec) = node.masking {
882 let sink_ids = [node.sink_ref.as_str(), node.sink.kind.as_str()];
883 let compiled = faucet_core::CompiledMasking::compile_for_sink(masking_spec, &sink_ids)
884 .map_err(|e| CliError::Config(format!("masking: {e}")))?;
885 if !compiled.is_empty() {
886 pipeline = pipeline.with_masking(Arc::new(compiled));
887 }
888 }
889 if let Some(ref sd) = node.schema {
891 pipeline = pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd));
892 }
893 if let Some(scope) = cleanup_scope {
908 let synthetic = opts.dry_run || opts.limit.is_some() || opts.shard.is_some();
909 if synthetic {
910 tracing::warn!(
911 row = %row_id,
912 "scoped cleanup skipped: --dry-run / --limit / shard runs do not write the \
913 authoritative record set for the scope, so a delete would remove live rows"
914 );
915 } else {
916 let map: std::collections::BTreeMap<String, Value> = scope
917 .as_object()
918 .map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
919 .unwrap_or_default();
920 let key: Vec<String> = node
921 .sink
922 .config
923 .get("key")
924 .and_then(|v| v.as_array())
925 .map(|a| {
926 a.iter()
927 .filter_map(|v| v.as_str().map(str::to_owned))
928 .collect()
929 })
930 .unwrap_or_default();
931 let policy = faucet_core::CleanupPolicy::new(map, key, faucet_core::DEFAULT_MAX_KEYS)
932 .map_err(|e| CliError::Config(format!("cleanup: {e}")))?;
933 pipeline = pipeline.with_cleanup(Arc::new(policy));
934 }
935 }
936 if let Some(ab) = opts
938 .execution
939 .as_ref()
940 .and_then(|e| e.adaptive_batch_size.clone())
941 {
942 ab.validate()
943 .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
944 pipeline = pipeline.with_adaptive(ab);
945 }
946 if let Some(policy) = opts.resilience.clone() {
949 pipeline = pipeline.with_resilience(policy);
950 }
951 let effective_delivery = if opts.dry_run || opts.limit.is_some() {
957 faucet_core::idempotency::DeliveryMode::AtLeastOnce
958 } else {
959 node.delivery
960 };
961 pipeline = pipeline.with_delivery(effective_delivery);
962 Ok(pipeline)
963}
964
965async fn run_one_invocation(
966 node: &ExpandedNode,
967 parent_record: Option<&Value>,
968 state_key: &str,
969 capture: Option<Arc<Projection>>,
970 opts: &ExecuteOptions,
971 cancel: CancellationToken,
972) -> CliResult<(Vec<Value>, PipelineStats)> {
973 let run_id = uuid::Uuid::now_v7().to_string();
976 #[cfg(feature = "notify")]
982 let invocation_started = std::time::Instant::now();
983 #[cfg(feature = "notify")]
984 let notify_run = crate::notify::RunContext::start(
985 Some(opts.run_id.clone().unwrap_or_else(|| run_id.clone())),
986 Some(run_id.clone()),
987 );
988 let pipeline_name = opts.pipeline_name.clone();
989 let row_id = node.id.clone();
990 #[cfg(feature = "lineage")]
991 let lineage = opts.lineage.clone();
992 #[cfg(feature = "lineage")]
993 let lineage_cfg = opts.lineage_cfg.clone();
994 let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
995 #[cfg(feature = "catalog")]
999 let catalog_active = opts.catalog.is_some()
1000 && matches!(node.role, NodeRole::Root)
1001 && !opts.dry_run
1002 && opts.limit.is_none()
1003 && opts.shard.is_none();
1004 let mut source_cfg = node.source.config.clone();
1006 let mut sink_cfg = node.sink.config.clone();
1007
1008 resolve_now_inplace(&mut source_cfg, opts.clock)?;
1011 resolve_now_inplace(&mut sink_cfg, opts.clock)?;
1012 reject_unresolved_backfill_tokens(&source_cfg, "source")?;
1018 reject_unresolved_backfill_tokens(&sink_cfg, "sink")?;
1019
1020 let mut cleanup_scope: Option<Value> = node
1024 .cleanup_scope
1025 .as_ref()
1026 .map(|m| Value::Object(m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()));
1027 if let Some(scope) = cleanup_scope.as_mut() {
1028 resolve_now_inplace(scope, opts.clock)?;
1029 reject_unresolved_backfill_tokens(scope, "complete_for")?;
1030 }
1031
1032 if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
1033 let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
1034 resolve_inplace(&mut source_cfg, &ctx)?;
1035 resolve_inplace(&mut sink_cfg, &ctx)?;
1036 if let Some(scope) = cleanup_scope.as_mut() {
1037 resolve_inplace(scope, &ctx)?;
1038 }
1039 }
1040
1041 let source = match node.source_override.as_ref().and_then(|o| o.take()) {
1046 Some(prebuilt) => prebuilt,
1047 None => {
1048 build_source(
1049 &node.source.kind,
1050 source_cfg,
1051 &opts.auth,
1052 opts.resilience.as_ref().map(|r| &r.retry),
1053 )
1054 .await?
1055 }
1056 };
1057
1058 #[cfg(feature = "catalog")]
1061 let source_dataset_uri = source.dataset_uri();
1062
1063 if let Some(shard) = &opts.shard {
1067 source
1068 .apply_shard(shard)
1069 .await
1070 .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
1071 }
1072 let raw_sink: Box<dyn Sink> = if opts.dry_run {
1073 Box::new(CountingSink::new())
1074 } else {
1075 build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
1076 };
1077 #[cfg(feature = "catalog")]
1078 let sink_dataset_uri = raw_sink.dataset_uri();
1079 let raw_sink: Box<dyn Sink> = match opts.limit {
1080 Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
1081 None => raw_sink,
1082 };
1083 let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
1084 let sink: Box<dyn Sink> = match &capture {
1085 Some(projection) => Box::new(CapturingSink::wrap(
1086 raw_sink,
1087 Arc::clone(&captured),
1088 Arc::clone(projection),
1089 )),
1090 None => raw_sink,
1091 };
1092
1093 #[cfg(feature = "lineage")]
1099 let (in_sample, out_sample) = {
1100 use std::sync::Arc as StdArc;
1101 let mut want = false;
1102 let mut cap = 0usize;
1103 if let (Some(_), Some(lc)) = (&lineage, &lineage_cfg) {
1104 let want_schema = lc.include_schema_facet || lc.include_column_lineage;
1105 if want_schema {
1106 cap = cap.max(lc.sample_records);
1107 }
1108 want = want_schema || lc.emit_on.running;
1109 }
1110 #[cfg(feature = "catalog")]
1114 if catalog_active {
1115 want = true;
1116 cap = cap.max(
1117 opts.catalog
1118 .as_ref()
1119 .map(|h| h.sample_records)
1120 .unwrap_or(crate::catalog::DEFAULT_SAMPLE_RECORDS),
1121 );
1122 }
1123 if want {
1124 (
1125 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1126 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1127 )
1128 } else {
1129 (None, None)
1130 }
1131 };
1132
1133 #[cfg(feature = "lineage")]
1136 let source: Box<dyn Source> = match &in_sample {
1137 Some(state) => Box::new(faucet_lineage::SamplingSource::new(
1138 source,
1139 std::sync::Arc::clone(state),
1140 )),
1141 None => source,
1142 };
1143
1144 let mut transforms = node.transforms.clone();
1149 for t in &mut transforms {
1150 resolve_now_inplace(&mut t.config, opts.clock)?;
1151 }
1152 #[cfg(feature = "arrow")]
1157 let source: Box<dyn Source> = {
1158 let (stages, batch_fns) = crate::transforms::compile_transforms_columnar(&transforms)?;
1159 if stages.is_empty() {
1160 source
1161 } else {
1162 Box::new(faucet_core::TransformingSource::new_with_batches(
1163 source,
1164 stages,
1165 batch_fns,
1166 obs_labels.clone(),
1167 )?)
1168 }
1169 };
1170 #[cfg(not(feature = "arrow"))]
1171 let source: Box<dyn Source> = {
1172 let stages = crate::transforms::compile_transforms(&transforms)?;
1173 if stages.is_empty() {
1174 source
1175 } else {
1176 Box::new(faucet_core::TransformingSource::new(
1177 source,
1178 stages,
1179 obs_labels.clone(),
1180 )?)
1181 }
1182 };
1183
1184 let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
1188 let state: Option<Arc<dyn StateStore>> = match state {
1193 Some(inner) if opts.dry_run || opts.limit.is_some() => {
1194 Some(Arc::new(ReadOnlyStateStore { inner }))
1195 }
1196 other => other,
1197 };
1198 let sla_store = state.clone();
1201 let effective_state_key = match &opts.shard {
1204 Some(shard) => format!("{state_key}::{}", shard.id),
1205 None => state_key.to_owned(),
1206 };
1207 let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
1208 Box::new(StateKeyOverride {
1209 inner: source,
1210 key: effective_state_key,
1211 })
1212 } else {
1213 source
1214 };
1215
1216 #[cfg(feature = "lineage")]
1219 let sink: Box<dyn Sink> = match &out_sample {
1220 Some(state) => Box::new(faucet_lineage::SamplingSink::new(
1221 sink,
1222 std::sync::Arc::clone(state),
1223 )),
1224 None => sink,
1225 };
1226
1227 let pipeline = build_pipeline(
1235 source.as_ref(),
1236 sink.as_ref(),
1237 node,
1238 opts,
1239 state,
1240 &cancel,
1241 &pipeline_name,
1242 &row_id,
1243 &run_id,
1244 cleanup_scope,
1245 )
1246 .await?;
1247 #[cfg(feature = "lineage")]
1249 let lineage_ctx = match (&lineage, &lineage_cfg) {
1250 (Some(em), Some(lc)) => {
1251 let job_name =
1252 crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
1253 let mut ctx = faucet_lineage::RunLifecycle {
1254 job_namespace: lc.namespace.clone(),
1255 job_name,
1256 run_id: run_id.clone(),
1257 parent: lc.parent_job.clone(),
1258 inputs: vec![faucet_lineage::DatasetRef {
1259 namespace: lc.namespace.clone(),
1260 name: source.dataset_uri(),
1261 }],
1262 output: faucet_lineage::DatasetRef {
1263 namespace: lc.namespace.clone(),
1264 name: sink.dataset_uri(),
1265 },
1266 started_at: chrono::Utc::now(),
1267 finished_at: None,
1268 records: 0,
1269 error: None,
1270 input_schemas: Vec::new(),
1271 output_schema: None,
1272 column_lineage: None,
1273 source_code: None,
1274 };
1275 em.emit(faucet_lineage::EventType::Start, &ctx).await;
1276 let hb_handle = if lc.emit_on.running {
1279 let em2 = std::sync::Arc::clone(em);
1280 let interval = lc.heartbeat_interval;
1281 let mut beat_ctx = ctx.clone();
1282 let counter = out_sample.clone();
1283 Some(tokio::spawn(async move {
1284 let mut tick = tokio::time::interval(interval);
1285 tick.tick().await; loop {
1287 tick.tick().await;
1288 if let Some(c) = &counter {
1289 beat_ctx.records = c.count();
1290 }
1291 em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
1292 .await;
1293 }
1294 }))
1295 } else {
1296 None
1297 };
1298 ctx.source_code = if lc.include_source_code_facet {
1299 Some(serde_json::to_string(&node.source.config).unwrap_or_default())
1300 } else {
1301 None
1302 };
1303 Some((std::sync::Arc::clone(em), ctx, hb_handle))
1304 }
1305 _ => None,
1306 };
1307
1308 let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1315 Ok(r) => sink.flush().await.map(|_| r),
1316 Err(e) => Err(e),
1317 };
1318
1319 #[cfg(feature = "lineage")]
1320 if let Some((em, mut ctx, hb)) = lineage_ctx {
1321 if let Some(h) = hb {
1322 h.abort();
1323 }
1324 ctx.finished_at = Some(chrono::Utc::now());
1325 if let Some(state) = &out_sample {
1326 ctx.records = state.count();
1327 if lineage_cfg
1328 .as_ref()
1329 .map(|l| l.include_schema_facet)
1330 .unwrap_or(false)
1331 {
1332 ctx.output_schema = Some(state.inferred_schema());
1333 }
1334 }
1335 if let Some(state) = &in_sample
1336 && lineage_cfg
1337 .as_ref()
1338 .map(|l| l.include_schema_facet || l.include_column_lineage)
1339 .unwrap_or(false)
1340 {
1341 let in_schema = state.inferred_schema();
1342 if lineage_cfg
1343 .as_ref()
1344 .map(|l| l.include_column_lineage)
1345 .unwrap_or(false)
1346 {
1347 let input_fields: Vec<String> =
1348 in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1349 #[cfg(feature = "masking")]
1350 let has_masking = node.masking.is_some();
1351 #[cfg(not(feature = "masking"))]
1352 let has_masking = false;
1353 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1354 ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1355 }
1356 if lineage_cfg
1357 .as_ref()
1358 .map(|l| l.include_schema_facet)
1359 .unwrap_or(false)
1360 {
1361 ctx.input_schemas = vec![Some(in_schema)];
1362 }
1363 }
1364 let ev = match &result {
1365 Err(e) => {
1366 ctx.error = Some(e.to_string());
1367 faucet_lineage::EventType::Fail
1368 }
1369 Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1370 Ok(_) => faucet_lineage::EventType::Complete,
1371 };
1372 em.emit(ev, &ctx).await;
1373 }
1374
1375 let is_notifiable_root = matches!(node.role, NodeRole::Root)
1385 && !opts.dry_run
1386 && opts.limit.is_none()
1387 && opts.shard.is_none()
1388 && !cancel.is_cancelled();
1389
1390 #[cfg_attr(not(feature = "notify"), allow(unused_variables))]
1391 let sla_violations = if let Some(spec) = &opts.sla
1392 && is_notifiable_root
1393 {
1394 let outcome = match &result {
1395 Ok(r) => crate::sla::RunOutcome::Success {
1396 rows: r.records_written as u64,
1397 },
1398 Err(_) => crate::sla::RunOutcome::Failure,
1399 };
1400 crate::sla::evaluate_post_run(
1401 spec,
1402 sla_store.as_ref(),
1403 state_key,
1404 &obs_labels.pipeline,
1405 &obs_labels.row,
1406 outcome,
1407 chrono::Utc::now().timestamp(),
1408 )
1409 .await
1410 } else {
1411 Vec::new()
1412 };
1413
1414 #[cfg(feature = "notify")]
1419 if let Some(notifier) = &opts.notifier
1420 && is_notifiable_root
1421 {
1422 use crate::notify::NotifyEvent;
1423 let pipeline = obs_labels.pipeline.to_string();
1424 let row = obs_labels.row.to_string();
1425 let run_ctx = notify_run.clone().finish(invocation_started);
1428 match &result {
1429 Ok(r) => {
1430 notifier
1431 .emit(
1432 NotifyEvent::run_success(
1433 pipeline.clone(),
1434 row.clone(),
1435 r.records_written as u64,
1436 )
1437 .with_run(run_ctx.clone()),
1438 )
1439 .await;
1440 if let Some(dlq) = &r.dlq
1441 && dlq.records_dlq > 0
1442 {
1443 notifier
1444 .emit(
1445 NotifyEvent::dlq_threshold(
1446 pipeline.clone(),
1447 row.clone(),
1448 dlq.records_dlq as u64,
1449 )
1450 .with_run(run_ctx.clone()),
1451 )
1452 .await;
1453 }
1454 }
1455 Err(e) => {
1456 notifier
1457 .emit(error_event(&pipeline, &row, e).with_run(run_ctx.clone()))
1458 .await;
1459 }
1460 }
1461 for v in &sla_violations {
1462 notifier
1463 .emit(
1464 NotifyEvent::sla_breach(pipeline.clone(), row.clone(), v.kind(), v.to_string())
1465 .with_run(run_ctx.clone()),
1466 )
1467 .await;
1468 }
1469 }
1470
1471 #[cfg(feature = "catalog")]
1476 if let Some(handle) = &opts.catalog
1477 && catalog_active
1478 && !cancel.is_cancelled()
1479 && let Ok(pipeline_result) = &result
1480 {
1481 use crate::catalog::model::{canonicalize_uri, schema_from_samples};
1482 use crate::serve::history::catalog::{CatalogUpdate, DatasetObservation, DatasetRole};
1483
1484 let records_written = pipeline_result.records_written as u64;
1485 let source_schema = in_sample
1486 .as_ref()
1487 .and_then(|s| schema_from_samples(&s.samples()));
1488 let sink_schema = out_sample
1489 .as_ref()
1490 .and_then(|s| schema_from_samples(&s.samples()));
1491 let records_read = in_sample
1494 .as_ref()
1495 .map(|s| s.count())
1496 .unwrap_or(records_written);
1497 let records_out = out_sample
1498 .as_ref()
1499 .map(|s| s.count())
1500 .unwrap_or(records_written);
1501
1502 let column_lineage = in_sample.as_ref().and_then(|s| {
1505 let input_fields: Vec<String> = s
1506 .inferred_schema()
1507 .fields
1508 .iter()
1509 .map(|(n, _)| n.clone())
1510 .collect();
1511 #[cfg(feature = "masking")]
1512 let has_masking = node.masking.is_some();
1513 #[cfg(not(feature = "masking"))]
1514 let has_masking = false;
1515 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1516 faucet_lineage::derive_column_lineage(&input_fields, &ops).map(|cl| {
1517 let fields: serde_json::Map<String, Value> = cl
1520 .edges
1521 .iter()
1522 .map(|(out, ins)| {
1523 (
1524 out.clone(),
1525 Value::Array(ins.iter().map(|s| Value::String(s.clone())).collect()),
1526 )
1527 })
1528 .collect();
1529 serde_json::json!({ "fields": fields })
1530 })
1531 });
1532
1533 let update = CatalogUpdate {
1534 run_id: handle.run_id.clone().unwrap_or_else(|| run_id.clone()),
1535 pipeline: obs_labels.pipeline.to_string(),
1536 row: obs_labels.row.to_string(),
1537 recorded_at: chrono::Utc::now(),
1538 sources: vec![DatasetObservation {
1542 uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
1543 kind: node.source.kind.clone(),
1544 role: DatasetRole::Source,
1545 schema: source_schema,
1546 records: records_read,
1547 }],
1548 sink: DatasetObservation {
1549 uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
1550 kind: node.sink.kind.clone(),
1551 role: DatasetRole::Sink,
1552 schema: sink_schema,
1553 records: records_out,
1554 },
1555 column_lineage,
1556 };
1557 crate::catalog::record(handle, &update).await;
1558 }
1559
1560 let result = result?;
1561
1562 #[cfg(feature = "lineage")]
1566 let records_read = in_sample.as_ref().map(|s| s.count());
1567 #[cfg(not(feature = "lineage"))]
1568 let records_read: Option<u64> = None;
1569 let stats = PipelineStats {
1570 records_written: result.records_written,
1571 records_read,
1572 dlq_count: result
1573 .dlq
1574 .as_ref()
1575 .map(|d| d.records_dlq as u64)
1576 .unwrap_or(0),
1577 bookmark: result.bookmark.clone(),
1578 };
1579
1580 let captured = if capture.is_some() {
1581 std::mem::take(&mut *captured.lock().await)
1582 } else {
1583 Vec::new()
1584 };
1585 Ok((captured, stats))
1586}
1587
1588async fn build_state_for_node(
1589 node: &ExpandedNode,
1590 state_path_override: Option<&Path>,
1591) -> CliResult<Option<Arc<dyn StateStore>>> {
1592 match (&node.state, state_path_override) {
1593 (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1594 (None, Some(path)) => Ok(Some(state_from_override(path))),
1595 (Some(spec), Some(path)) => {
1596 if spec.kind == "file" {
1597 Ok(Some(state_from_override(path)))
1598 } else {
1599 tracing::warn!(
1600 state = %spec.kind,
1601 "--state-path is only meaningful for the 'file' backend; ignoring override"
1602 );
1603 Ok(Some(build_state_store(spec).await?))
1604 }
1605 }
1606 (None, None) => Ok(None),
1607 }
1608}
1609
1610fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1611 Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1612}
1613
1614pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1617 let sink = build_sink(
1620 &spec.sink.kind,
1621 spec.sink.config.clone(),
1622 &AuthCatalog::new(),
1623 )
1624 .await?;
1625 Ok(DlqConfig {
1626 sink: Arc::from(sink),
1627 on_batch_error: match spec.on_batch_error {
1628 crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1629 crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1630 },
1631 max_failures_per_page: spec.max_failures_per_page,
1632 max_failures_total: spec.max_failures_total,
1633 include_original_payload: spec.include_original_payload,
1634 })
1635}
1636
1637#[cfg(feature = "notify")]
1641fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
1642 use crate::notify::NotifyEvent;
1643 match err {
1644 FaucetError::CircuitOpen { failures, cooldown } => {
1645 NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
1646 }
1647 FaucetError::ContractViolation { message, .. } => {
1648 NotifyEvent::contract_abort(pipeline, row, message.clone())
1649 }
1650 other => {
1651 NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
1652 }
1653 }
1654}
1655
1656#[cfg(feature = "notify")]
1660fn faucet_error_kind(err: &FaucetError) -> &'static str {
1661 match err {
1662 FaucetError::Config(_) => "config",
1663 FaucetError::Source(_) => "source",
1664 FaucetError::Sink(_) => "sink",
1665 FaucetError::State(_) => "state",
1666 FaucetError::QualityFailure { .. } => "quality",
1667 FaucetError::SchemaDrift { .. } => "schema_drift",
1668 _ => "error",
1669 }
1670}
1671
1672pub(crate) fn reject_unresolved_backfill_tokens(value: &Value, owner: &str) -> CliResult<()> {
1681 fn walk(value: &Value, owner: &str) -> CliResult<()> {
1682 match value {
1683 Value::String(s) if s.contains("${backfill.") => Err(CliError::Config(format!(
1684 "the {owner} config references a `${{backfill.*}}` token, which only `faucet backfill` resolves — run this config via `faucet backfill --from … --to …`, or remove the token"
1685 ))),
1686 Value::Array(a) => a.iter().try_for_each(|v| walk(v, owner)),
1687 Value::Object(m) => m.values().try_for_each(|v| walk(v, owner)),
1688 _ => Ok(()),
1689 }
1690 }
1691 walk(value, owner)
1692}
1693
1694pub(crate) fn resolve_now_inplace(
1695 value: &mut Value,
1696 clock: DateTime<FixedOffset>,
1697) -> CliResult<()> {
1698 match value {
1699 Value::String(s) => {
1700 *s = crate::interpolate::resolve_now(s, clock)?;
1701 Ok(())
1702 }
1703 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1704 Value::Object(m) => m
1705 .values_mut()
1706 .try_for_each(|v| resolve_now_inplace(v, clock)),
1707 _ => Ok(()),
1708 }
1709}
1710
1711fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1715 match value {
1716 Value::String(s) => {
1717 let resolved = interpolate_record(s, ctx)?;
1718 *s = resolved;
1719 Ok(())
1720 }
1721 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1722 Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1723 _ => Ok(()),
1724 }
1725}
1726
1727pub(crate) struct ReadOnlyStateStore {
1740 pub(crate) inner: Arc<dyn StateStore>,
1741}
1742
1743#[async_trait]
1744impl StateStore for ReadOnlyStateStore {
1745 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1746 self.inner.get(key).await
1747 }
1748 async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1749 Ok(())
1750 }
1751 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1752 Ok(())
1753 }
1754}
1755
1756struct StateKeyOverride {
1760 inner: Box<dyn Source>,
1761 key: String,
1762}
1763
1764#[async_trait]
1765impl Source for StateKeyOverride {
1766 async fn fetch_with_context(
1767 &self,
1768 ctx: &HashMap<String, Value>,
1769 ) -> Result<Vec<Value>, FaucetError> {
1770 self.inner.fetch_with_context(ctx).await
1771 }
1772 async fn fetch_with_context_incremental(
1773 &self,
1774 ctx: &HashMap<String, Value>,
1775 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1776 self.inner.fetch_with_context_incremental(ctx).await
1777 }
1778 fn stream_pages<'a>(
1784 &'a self,
1785 ctx: &'a HashMap<String, Value>,
1786 batch_size: usize,
1787 ) -> std::pin::Pin<
1788 Box<
1789 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
1790 + Send
1791 + 'a,
1792 >,
1793 > {
1794 self.inner.stream_pages(ctx, batch_size)
1795 }
1796 fn connector_name(&self) -> &'static str {
1797 self.inner.connector_name()
1798 }
1799 fn dataset_uri(&self) -> String {
1800 self.inner.dataset_uri()
1801 }
1802 fn state_key(&self) -> Option<String> {
1803 Some(self.key.clone())
1804 }
1805 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1806 self.inner.apply_start_bookmark(bookmark).await
1807 }
1808 fn supports_exactly_once(&self) -> bool {
1809 self.inner.supports_exactly_once()
1810 }
1811 fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
1812 self.inner.replay_guarantee()
1813 }
1814 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1815 self.inner.capture_resume_position().await
1816 }
1817}
1818
1819struct CapturingSink {
1823 inner: Box<dyn Sink>,
1824 captured: Arc<Mutex<Vec<Value>>>,
1825 projection: Arc<Projection>,
1826}
1827
1828impl CapturingSink {
1829 fn wrap(
1830 inner: Box<dyn Sink>,
1831 captured: Arc<Mutex<Vec<Value>>>,
1832 projection: Arc<Projection>,
1833 ) -> Self {
1834 Self {
1835 inner,
1836 captured,
1837 projection,
1838 }
1839 }
1840}
1841
1842#[async_trait]
1843impl Sink for CapturingSink {
1844 fn connector_name(&self) -> &'static str {
1845 self.inner.connector_name()
1846 }
1847 fn dataset_uri(&self) -> String {
1848 self.inner.dataset_uri()
1849 }
1850 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1851 let written = self.inner.write_batch(records).await?;
1852 let n = written.min(records.len());
1855 let mut buf = self.captured.lock().await;
1856 buf.extend(
1857 records
1858 .iter()
1859 .take(n)
1860 .map(|r| project_record(r, &self.projection)),
1861 );
1862 Ok(written)
1863 }
1864 async fn flush(&self) -> Result<(), FaucetError> {
1865 self.inner.flush().await
1866 }
1867 fn supports_idempotent_writes(&self) -> bool {
1871 self.inner.supports_idempotent_writes()
1872 }
1873 fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
1874 self.inner.sink_guarantee()
1875 }
1876 fn dedups_by_key(&self) -> bool {
1877 self.inner.dedups_by_key()
1878 }
1879 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
1880 self.inner.supported_write_modes()
1881 }
1882 async fn write_batch_idempotent(
1883 &self,
1884 records: &[Value],
1885 scope: &str,
1886 token: &str,
1887 ) -> Result<usize, FaucetError> {
1888 let written = self
1889 .inner
1890 .write_batch_idempotent(records, scope, token)
1891 .await?;
1892 let n = written.min(records.len());
1893 let mut buf = self.captured.lock().await;
1894 buf.extend(
1895 records
1896 .iter()
1897 .take(n)
1898 .map(|r| project_record(r, &self.projection)),
1899 );
1900 Ok(written)
1901 }
1902 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1903 self.inner.last_committed_token(scope).await
1904 }
1905 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1906 self.inner.current_schema().await
1907 }
1908 fn supports_schema_evolution(&self) -> bool {
1909 self.inner.supports_schema_evolution()
1910 }
1911 async fn evolve_schema(
1912 &self,
1913 evolution: &faucet_core::SchemaEvolution,
1914 ) -> Result<(), FaucetError> {
1915 self.inner.evolve_schema(evolution).await
1916 }
1917}
1918
1919pub(crate) struct LimitedSink {
1922 inner: Box<dyn Sink>,
1923 remaining: AtomicUsize,
1924}
1925
1926impl LimitedSink {
1927 pub(crate) fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1928 Self {
1929 inner,
1930 remaining: AtomicUsize::new(cap),
1931 }
1932 }
1933}
1934
1935#[async_trait]
1936impl Sink for LimitedSink {
1937 fn connector_name(&self) -> &'static str {
1938 self.inner.connector_name()
1939 }
1940 fn dataset_uri(&self) -> String {
1941 self.inner.dataset_uri()
1942 }
1943 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1944 let remaining = self.remaining.load(Ordering::Relaxed);
1945 if remaining == 0 {
1946 return Ok(0);
1947 }
1948 let take = remaining.min(records.len());
1949 let slice = &records[..take];
1950 let written = self.inner.write_batch(slice).await?;
1951 self.remaining
1952 .fetch_sub(written.min(remaining), Ordering::Relaxed);
1953 Ok(written)
1954 }
1955 async fn flush(&self) -> Result<(), FaucetError> {
1956 self.inner.flush().await
1957 }
1958}
1959
1960pub(crate) struct CountingSink {
1963 seen: AtomicUsize,
1964}
1965
1966impl CountingSink {
1967 pub(crate) fn new() -> Self {
1968 Self {
1969 seen: AtomicUsize::new(0),
1970 }
1971 }
1972}
1973
1974#[async_trait]
1975impl Sink for CountingSink {
1976 fn connector_name(&self) -> &'static str {
1977 "dry-run"
1978 }
1979 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1980 self.seen.fetch_add(records.len(), Ordering::Relaxed);
1981 Ok(records.len())
1982 }
1983}
1984
1985fn value_to_string_brief(v: &Value) -> String {
1988 match v {
1989 Value::String(s) => s.clone(),
1990 other => other.to_string(),
1991 }
1992}
1993
1994#[cfg(test)]
1995mod tests {
1996 use super::*;
1997 use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1998 use crate::expand::expand;
1999 use serde_json::json;
2000
2001 fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
2002 PipelineConfig {
2003 version: 1,
2004 name: Some("test".into()),
2005 vars: None,
2006 params: Default::default(),
2007 auth: None,
2008 pipeline: PipelineSpec {
2009 source: Some(ConnectorSpec {
2010 kind: "csv".into(),
2011 config: json!({"path": input.to_str().unwrap()}),
2012 transforms: None,
2013 inherit_transforms: true,
2014 status: None,
2015 tags: Vec::new(),
2016 complete_for: None,
2017 }),
2018 sink: Some(ConnectorSpec {
2019 kind: "jsonl".into(),
2020 config: json!({"path": output.to_str().unwrap()}),
2021 transforms: None,
2022 inherit_transforms: true,
2023 status: None,
2024 tags: Vec::new(),
2025 complete_for: None,
2026 }),
2027 sources: Default::default(),
2028 sinks: Default::default(),
2029 transforms: Vec::new(),
2030 state: None,
2031 dlq: None,
2032 #[cfg(feature = "quality")]
2033 quality: None,
2034 #[cfg(feature = "contract")]
2035 contract: None,
2036 #[cfg(feature = "masking")]
2037 masking: None,
2038 schema: None,
2039 nodes: std::collections::HashMap::new(),
2040 edges: Vec::new(),
2041 },
2042 matrix: Vec::new(),
2043 execution: None,
2044 selection: None,
2045 observability: None,
2046 delivery: faucet_core::DeliveryMode::default(),
2047 resilience: None,
2048 sla: None,
2049 shard: None,
2050 replication: None,
2051 backfill: None,
2052 partition: None,
2053 #[cfg(feature = "schedule")]
2054 schedule: None,
2055 #[cfg(feature = "lineage")]
2056 lineage: None,
2057 #[cfg(feature = "catalog")]
2058 catalog: None,
2059 #[cfg(feature = "notify")]
2060 notifications: Vec::new(),
2061 }
2062 }
2063
2064 #[tokio::test]
2065 async fn empty_matrix_runs_pipeline_once() {
2066 let dir = tempfile::tempdir().unwrap();
2067 let input = dir.path().join("in.csv");
2068 let output = dir.path().join("out.jsonl");
2069 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2070 let cfg = cfg_csv_to_jsonl(&input, &output);
2071 let nodes = expand(&cfg).unwrap();
2072 let summary = run_expanded(
2073 nodes,
2074 ExecuteOptions {
2075 pipeline_name: "t".into(),
2076 run_id: None,
2077 execution: None,
2078 dry_run: false,
2079 limit: None,
2080 state_path_override: None,
2081 shard: None,
2082 auth: Default::default(),
2083 clock: chrono::Utc::now().fixed_offset(),
2084 cancel: None,
2085 resilience: None,
2086 sla: None,
2087 #[cfg(feature = "lineage")]
2088 lineage: None,
2089 #[cfg(feature = "lineage")]
2090 lineage_cfg: None,
2091 #[cfg(feature = "notify")]
2092 notifier: None,
2093 #[cfg(feature = "catalog")]
2094 catalog: None,
2095 },
2096 )
2097 .await
2098 .unwrap();
2099 assert_eq!(summary.invocations.len(), 1);
2100 assert_eq!(summary.invocations[0].records_written, 2);
2101 assert!(!summary.had_failures());
2102 let body = std::fs::read_to_string(&output).unwrap();
2103 assert_eq!(body.lines().count(), 2);
2104 }
2105
2106 #[cfg(feature = "catalog")]
2108 fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
2109 let mut o = opts(name);
2110 o.catalog = Some(handle);
2111 o
2112 }
2113
2114 #[cfg(feature = "catalog")]
2115 #[tokio::test]
2116 async fn catalog_records_schema_timeline_across_two_runs() {
2117 use crate::catalog::CatalogHandle;
2121 use crate::serve::history::RunHistory as _;
2122 use crate::serve::history::catalog::{self, CatalogListFilter};
2123 use crate::serve::history::memory::MemoryHistory;
2124
2125 let dir = tempfile::tempdir().unwrap();
2126 let input = dir.path().join("in.csv");
2127 let output = dir.path().join("out.jsonl");
2128 let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
2129 let handle = CatalogHandle {
2130 store: store.clone(),
2131 run_id: None,
2132 sample_records: 10,
2133 };
2134
2135 std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
2136 let cfg = cfg_csv_to_jsonl(&input, &output);
2137 let nodes = expand(&cfg).unwrap();
2138 let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
2139 .await
2140 .unwrap();
2141 assert!(!summary.had_failures());
2142
2143 std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
2145 let nodes = expand(&cfg).unwrap();
2146 let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
2147 .await
2148 .unwrap();
2149 assert!(!summary.had_failures());
2150
2151 let page = store
2153 .catalog_list_datasets(&CatalogListFilter {
2154 limit: 10,
2155 ..Default::default()
2156 })
2157 .await
2158 .unwrap();
2159 assert_eq!(page.datasets.len(), 2, "source + sink datasets");
2160 for ds in &page.datasets {
2161 let detail = store
2162 .catalog_get_dataset(&ds.id)
2163 .await
2164 .unwrap()
2165 .expect("dataset detail");
2166 assert_eq!(detail.dataset.runs, 2);
2167 assert_eq!(
2168 detail.schema_timeline.len(),
2169 2,
2170 "exactly two timeline entries for {}",
2171 ds.uri
2172 );
2173 assert!(detail.schema_timeline[0].diff.is_none());
2174 let diff = detail.schema_timeline[1]
2175 .diff
2176 .as_ref()
2177 .expect("second version carries a diff");
2178 assert!(
2179 diff["added"]
2180 .as_array()
2181 .unwrap()
2182 .iter()
2183 .any(|c| c["column"] == "email"),
2184 "diff must show the added email column: {diff}"
2185 );
2186 assert_eq!(detail.stats.len(), 2, "one volume point per run");
2187 }
2188 let edges = store.catalog_lineage(None, 5).await.unwrap();
2190 assert_eq!(edges.len(), 1);
2191 assert_eq!(edges[0].runs, 2);
2192 assert_eq!(edges[0].last_records, 2);
2193 assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
2194 }
2195
2196 #[cfg(feature = "catalog")]
2199 struct FailingCatalogStore;
2200
2201 #[cfg(feature = "catalog")]
2202 #[async_trait]
2203 impl crate::serve::history::RunHistory for FailingCatalogStore {
2204 async fn claim_idempotency(
2205 &self,
2206 _: &str,
2207 _: &str,
2208 _: &str,
2209 _: std::time::Duration,
2210 ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
2211 Err(crate::serve::history::HistoryError::Backend("down".into()))
2212 }
2213 async fn upsert(
2214 &self,
2215 _: &crate::serve::history::RunRecord,
2216 ) -> Result<(), crate::serve::history::HistoryError> {
2217 Err(crate::serve::history::HistoryError::Backend("down".into()))
2218 }
2219 async fn get(
2220 &self,
2221 _: &str,
2222 ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
2223 {
2224 Err(crate::serve::history::HistoryError::Backend("down".into()))
2225 }
2226 async fn list(
2227 &self,
2228 _: &crate::serve::history::ListFilter,
2229 ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
2230 Err(crate::serve::history::HistoryError::Backend("down".into()))
2231 }
2232 async fn delete(
2233 &self,
2234 _: &str,
2235 ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
2236 {
2237 Err(crate::serve::history::HistoryError::Backend("down".into()))
2238 }
2239 async fn purge_expired(
2240 &self,
2241 _: std::time::Duration,
2242 ) -> Result<usize, crate::serve::history::HistoryError> {
2243 Err(crate::serve::history::HistoryError::Backend("down".into()))
2244 }
2245 async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
2246 Err(crate::serve::history::HistoryError::Backend("down".into()))
2247 }
2248 async fn catalog_record(
2249 &self,
2250 _: &crate::serve::history::catalog::CatalogUpdate,
2251 ) -> Result<(), crate::serve::history::HistoryError> {
2252 Err(crate::serve::history::HistoryError::Backend(
2253 "catalog write refused".into(),
2254 ))
2255 }
2256 fn degraded(&self) -> bool {
2257 false
2258 }
2259 }
2260
2261 #[cfg(feature = "catalog")]
2262 #[tokio::test]
2263 async fn catalog_write_failure_never_fails_the_run() {
2264 use crate::catalog::CatalogHandle;
2267 let dir = tempfile::tempdir().unwrap();
2268 let input = dir.path().join("in.csv");
2269 let output = dir.path().join("out.jsonl");
2270 std::fs::write(&input, "name\nalice\n").unwrap();
2271 let cfg = cfg_csv_to_jsonl(&input, &output);
2272 let nodes = expand(&cfg).unwrap();
2273 let handle = CatalogHandle {
2274 store: Arc::new(FailingCatalogStore),
2275 run_id: None,
2276 sample_records: 10,
2277 };
2278 let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
2279 .await
2280 .unwrap();
2281 assert!(
2282 !summary.had_failures(),
2283 "catalog failure must not fail the run"
2284 );
2285 assert_eq!(summary.invocations[0].records_written, 1);
2286 assert_eq!(
2287 std::fs::read_to_string(&output).unwrap().lines().count(),
2288 1,
2289 "sink output written despite the catalog error"
2290 );
2291 }
2292
2293 #[tokio::test]
2294 async fn matrix_two_independent_roots_both_run() {
2295 let dir = tempfile::tempdir().unwrap();
2297 let csv_a = dir.path().join("a.csv");
2298 let csv_b = dir.path().join("b.csv");
2299 let out_a = dir.path().join("a.jsonl");
2300 let out_b = dir.path().join("b.jsonl");
2301 std::fs::write(&csv_a, "name\nalice\n").unwrap();
2302 std::fs::write(&csv_b, "name\nbob\n").unwrap();
2303
2304 let yaml = format!(
2305 r#"version: 1
2306pipeline:
2307 source: {{ type: csv, config: {{ path: {a} }} }}
2308 sink: {{ type: jsonl, config: {{ path: {out_a} }} }}
2309matrix:
2310 - id: rowA
2311 - id: rowB
2312 source: {{ config: {{ path: {b} }} }}
2313 sink: {{ config: {{ path: {out_b} }} }}
2314"#,
2315 a = csv_a.display(),
2316 b = csv_b.display(),
2317 out_a = out_a.display(),
2318 out_b = out_b.display(),
2319 );
2320 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2321 let nodes = expand(&cfg).unwrap();
2322 let summary = run_expanded(
2323 nodes,
2324 ExecuteOptions {
2325 pipeline_name: "matrix".into(),
2326 run_id: None,
2327 execution: None,
2328 dry_run: false,
2329 limit: None,
2330 state_path_override: None,
2331 shard: None,
2332 auth: Default::default(),
2333 clock: chrono::Utc::now().fixed_offset(),
2334 cancel: None,
2335 resilience: None,
2336 sla: None,
2337 #[cfg(feature = "lineage")]
2338 lineage: None,
2339 #[cfg(feature = "lineage")]
2340 lineage_cfg: None,
2341 #[cfg(feature = "notify")]
2342 notifier: None,
2343 #[cfg(feature = "catalog")]
2344 catalog: None,
2345 },
2346 )
2347 .await
2348 .unwrap();
2349 assert_eq!(summary.invocations.len(), 2);
2350 assert!(out_a.exists());
2351 assert!(out_b.exists());
2352 }
2353
2354 #[tokio::test]
2355 async fn dag_child_fans_out_per_parent_record() {
2356 let dir = tempfile::tempdir().unwrap();
2359 let parent_csv = dir.path().join("parents.csv");
2360 let child_csv = dir.path().join("child.csv");
2361 std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
2362 std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
2363 let parent_out = dir.path().join("parents.jsonl");
2364 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2365
2366 let yaml = format!(
2367 r#"version: 1
2368pipeline:
2369 source: {{ type: csv, config: {{ path: {parent} }} }}
2370 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2371matrix:
2372 - id: parents
2373 - id: child
2374 parent: parents
2375 source: {{ config: {{ path: {child} }} }}
2376 sink: {{ config: {{ path: "{child_out}" }} }}
2377"#,
2378 parent = parent_csv.display(),
2379 parent_out = parent_out.display(),
2380 child = child_csv.display(),
2381 child_out = child_out_pattern.display(),
2382 );
2383 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2384 let nodes = expand(&cfg).unwrap();
2385 let summary = run_expanded(
2386 nodes,
2387 ExecuteOptions {
2388 pipeline_name: "dagtest".into(),
2389 run_id: None,
2390 execution: None,
2391 dry_run: false,
2392 limit: None,
2393 state_path_override: None,
2394 shard: None,
2395 auth: Default::default(),
2396 clock: chrono::Utc::now().fixed_offset(),
2397 cancel: None,
2398 resilience: None,
2399 sla: None,
2400 #[cfg(feature = "lineage")]
2401 lineage: None,
2402 #[cfg(feature = "lineage")]
2403 lineage_cfg: None,
2404 #[cfg(feature = "notify")]
2405 notifier: None,
2406 #[cfg(feature = "catalog")]
2407 catalog: None,
2408 },
2409 )
2410 .await
2411 .unwrap();
2412
2413 assert_eq!(summary.invocations.len(), 3);
2415 assert!(!summary.had_failures(), "{:?}", summary);
2416 assert!(dir.path().join("child-1.jsonl").exists());
2417 assert!(dir.path().join("child-2.jsonl").exists());
2418 }
2419
2420 #[tokio::test]
2421 async fn depends_on_root_runs_after_dependency() {
2422 let dir = tempfile::tempdir().unwrap();
2426 let input = dir.path().join("in.csv");
2427 let mid = dir.path().join("mid.csv");
2428 let out = dir.path().join("out.jsonl");
2429 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2430
2431 let yaml = format!(
2432 r#"version: 1
2433pipeline:
2434 source: {{ type: csv, config: {{ path: {input} }} }}
2435 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2436matrix:
2437 - id: stage
2438 sink: {{ type: csv, config: {{ path: {mid} }} }}
2439 - id: load
2440 depends_on: [stage]
2441 source: {{ config: {{ path: {mid} }} }}
2442"#,
2443 input = input.display(),
2444 mid = mid.display(),
2445 out = out.display(),
2446 );
2447 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2448 let nodes = expand(&cfg).unwrap();
2449 let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
2450 assert_eq!(summary.invocations.len(), 2, "{summary:?}");
2451 assert!(!summary.had_failures(), "{summary:?}");
2452 let load = summary
2453 .invocations
2454 .iter()
2455 .find(|i| i.row_id == "load")
2456 .unwrap();
2457 assert_eq!(load.records_written, 2);
2458 let written = std::fs::read_to_string(&out).unwrap();
2459 assert_eq!(written.lines().count(), 2);
2460 }
2461
2462 #[tokio::test]
2463 async fn diamond_dependency_waits_for_all_prerequisites() {
2464 let dir = tempfile::tempdir().unwrap();
2467 let input = dir.path().join("in.csv");
2468 let mid_a = dir.path().join("mid_a.csv");
2469 let mid_b = dir.path().join("mid_b.csv");
2470 let out = dir.path().join("out.jsonl");
2471 std::fs::write(&input, "name\nalice\n").unwrap();
2472
2473 let yaml = format!(
2474 r#"version: 1
2475pipeline:
2476 source: {{ type: csv, config: {{ path: {input} }} }}
2477 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2478matrix:
2479 - id: a
2480 sink: {{ type: csv, config: {{ path: {mid_a} }} }}
2481 - id: b
2482 sink: {{ type: csv, config: {{ path: {mid_b} }} }}
2483 - id: c
2484 depends_on: [a, b]
2485 source: {{ config: {{ path: {mid_a} }} }}
2486"#,
2487 input = input.display(),
2488 mid_a = mid_a.display(),
2489 mid_b = mid_b.display(),
2490 out = out.display(),
2491 );
2492 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2493 let nodes = expand(&cfg).unwrap();
2494 let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
2495 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2496 assert!(!summary.had_failures(), "{summary:?}");
2497 assert!(mid_b.exists(), "b must have run before c became ready");
2498 assert!(out.exists());
2499 }
2500
2501 #[tokio::test]
2502 async fn failed_dependency_skips_dependent() {
2503 let dir = tempfile::tempdir().unwrap();
2506 let good_input = dir.path().join("good.csv");
2507 let out = dir.path().join("out.jsonl");
2508 std::fs::write(&good_input, "name\nalice\n").unwrap();
2509
2510 let yaml = format!(
2511 r#"version: 1
2512pipeline:
2513 source: {{ type: csv, config: {{ path: {good} }} }}
2514 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2515matrix:
2516 - id: stage
2517 source: {{ config: {{ path: {missing} }} }}
2518 - id: load
2519 depends_on: [stage]
2520"#,
2521 good = good_input.display(),
2522 missing = dir.path().join("nonexistent.csv").display(),
2523 out = out.display(),
2524 );
2525 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2526 let nodes = expand(&cfg).unwrap();
2527 let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
2528 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2529 assert_eq!(summary.invocations[0].row_id, "stage");
2530 assert!(summary.invocations[0].error.is_some());
2531 assert!(
2532 !out.exists(),
2533 "dependent row must not run after its dependency failed"
2534 );
2535 }
2536
2537 #[tokio::test]
2538 async fn dependency_on_skipped_row_cascades() {
2539 let dir = tempfile::tempdir().unwrap();
2542 let good_input = dir.path().join("good.csv");
2543 let out = dir.path().join("q.jsonl");
2544 std::fs::write(&good_input, "id\n1\n").unwrap();
2545
2546 let yaml = format!(
2547 r#"version: 1
2548pipeline:
2549 source: {{ type: csv, config: {{ path: {good} }} }}
2550 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2551matrix:
2552 - id: p
2553 source: {{ config: {{ path: {missing} }} }}
2554 - id: c
2555 parent: p
2556 - id: q
2557 depends_on: [c]
2558"#,
2559 good = good_input.display(),
2560 missing = dir.path().join("nonexistent.csv").display(),
2561 out = out.display(),
2562 );
2563 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2564 let nodes = expand(&cfg).unwrap();
2565 let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
2566 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2567 assert_eq!(summary.invocations[0].row_id, "p");
2568 assert!(summary.invocations[0].error.is_some());
2569 assert!(
2570 !out.exists(),
2571 "q must be skipped when its dependency was skipped"
2572 );
2573 }
2574
2575 #[tokio::test]
2576 async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
2577 let dir = tempfile::tempdir().unwrap();
2589 let good_csv = dir.path().join("good.csv");
2590 std::fs::write(&good_csv, "x\n1\n").unwrap();
2591 let good_out = dir.path().join("good.jsonl");
2592 let bad_sink_dir = dir.path().to_path_buf();
2593
2594 let yaml = format!(
2595 r#"version: 1
2596pipeline:
2597 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2598 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2599matrix:
2600 - id: bad
2601 sink: {{ config: {{ path: {bad_dir} }} }}
2602 - id: good
2603execution:
2604 max_concurrent: 1
2605 on_error: stop
2606"#,
2607 good_csv = good_csv.display(),
2608 good_out = good_out.display(),
2609 bad_dir = bad_sink_dir.display(),
2610 );
2611 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2612 let nodes = expand(&cfg).unwrap();
2613 let summary = run_expanded(
2614 nodes,
2615 ExecuteOptions {
2616 pipeline_name: "stoptest".into(),
2617 run_id: None,
2618 execution: cfg.execution.clone(),
2619 dry_run: false,
2620 limit: None,
2621 state_path_override: None,
2622 shard: None,
2623 auth: Default::default(),
2624 clock: chrono::Utc::now().fixed_offset(),
2625 cancel: None,
2626 resilience: None,
2627 sla: None,
2628 #[cfg(feature = "lineage")]
2629 lineage: None,
2630 #[cfg(feature = "lineage")]
2631 lineage_cfg: None,
2632 #[cfg(feature = "notify")]
2633 notifier: None,
2634 #[cfg(feature = "catalog")]
2635 catalog: None,
2636 },
2637 )
2638 .await
2639 .unwrap();
2640
2641 assert!(summary.had_failures(), "the failing root must be reported");
2643
2644 let bad: Vec<_> = summary
2646 .invocations
2647 .iter()
2648 .filter(|o| o.row_id == "bad")
2649 .collect();
2650 assert_eq!(bad.len(), 1, "bad must run exactly once");
2651 assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
2652
2653 assert!(
2655 summary.invocations.len() <= 2,
2656 "at most the two roots may run, got {:?}",
2657 summary.invocations
2658 );
2659
2660 let good_wrote = summary
2667 .invocations
2668 .iter()
2669 .find(|o| o.row_id == "good" && o.error.is_none())
2670 .map(|o| o.records_written)
2671 .unwrap_or(0);
2672 if good_wrote > 0 {
2673 assert!(
2674 good_out.exists(),
2675 "a good that wrote records must have produced its output file"
2676 );
2677 }
2678 }
2679
2680 #[tokio::test]
2681 async fn invalid_pipeline_name_with_state_errors_up_front() {
2682 let dir = tempfile::tempdir().unwrap();
2686 let input = dir.path().join("in.csv");
2687 let output = dir.path().join("out.jsonl");
2688 std::fs::write(&input, "name\nalice\n").unwrap();
2689 let yaml = format!(
2690 r#"version: 1
2691pipeline:
2692 source: {{ type: csv, config: {{ path: {input} }} }}
2693 sink: {{ type: jsonl, config: {{ path: {output} }} }}
2694 state: {{ type: memory }}
2695"#,
2696 input = input.display(),
2697 output = output.display(),
2698 );
2699 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2700 let nodes = expand(&cfg).unwrap();
2701 let err = run_expanded(
2702 nodes,
2703 ExecuteOptions {
2704 pipeline_name: "bad name".into(), run_id: None,
2706 execution: None,
2707 dry_run: false,
2708 limit: None,
2709 state_path_override: None,
2710 shard: None,
2711 auth: Default::default(),
2712 clock: chrono::Utc::now().fixed_offset(),
2713 cancel: None,
2714 resilience: None,
2715 sla: None,
2716 #[cfg(feature = "lineage")]
2717 lineage: None,
2718 #[cfg(feature = "lineage")]
2719 lineage_cfg: None,
2720 #[cfg(feature = "notify")]
2721 notifier: None,
2722 #[cfg(feature = "catalog")]
2723 catalog: None,
2724 },
2725 )
2726 .await
2727 .expect_err("an invalid pipeline name must be rejected up front when state is configured");
2728 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2729 }
2730
2731 #[tokio::test]
2732 async fn invalid_parent_key_value_with_state_errors_up_front() {
2733 let dir = tempfile::tempdir().unwrap();
2736 let parent_csv = dir.path().join("parents.csv");
2737 let child_csv = dir.path().join("child.csv");
2738 std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
2740 std::fs::write(&child_csv, "x\nA\n").unwrap();
2741 let parent_out = dir.path().join("parents.jsonl");
2742 let child_out = dir.path().join("child.jsonl");
2743 let yaml = format!(
2744 r#"version: 1
2745pipeline:
2746 source: {{ type: csv, config: {{ path: {parent} }} }}
2747 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2748 state: {{ type: memory }}
2749matrix:
2750 - id: parents
2751 - id: child
2752 parent: parents
2753 source: {{ config: {{ path: {child} }} }}
2754 sink: {{ config: {{ path: {child_out} }} }}
2755"#,
2756 parent = parent_csv.display(),
2757 parent_out = parent_out.display(),
2758 child = child_csv.display(),
2759 child_out = child_out.display(),
2760 );
2761 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2762 let nodes = expand(&cfg).unwrap();
2763 let err = run_expanded(
2764 nodes,
2765 ExecuteOptions {
2766 pipeline_name: "ok".into(),
2767 run_id: None,
2768 execution: None,
2769 dry_run: false,
2770 limit: None,
2771 state_path_override: None,
2772 shard: None,
2773 auth: Default::default(),
2774 clock: chrono::Utc::now().fixed_offset(),
2775 cancel: None,
2776 resilience: None,
2777 sla: None,
2778 #[cfg(feature = "lineage")]
2779 lineage: None,
2780 #[cfg(feature = "lineage")]
2781 lineage_cfg: None,
2782 #[cfg(feature = "notify")]
2783 notifier: None,
2784 #[cfg(feature = "catalog")]
2785 catalog: None,
2786 },
2787 )
2788 .await
2789 .expect_err(
2790 "an illegal parent-key value must be rejected up front when state is configured",
2791 );
2792 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2793 }
2794
2795 #[tokio::test]
2796 async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
2797 let dir = tempfile::tempdir().unwrap();
2806 let bad_sink_dir = dir.path().to_path_buf();
2807 let good_csv = dir.path().join("good.csv");
2810 std::fs::write(&good_csv, "x\n1\n").unwrap();
2811 let yaml = format!(
2817 r#"version: 1
2818pipeline:
2819 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2820 sink: {{ type: jsonl, config: {{ path: {bad_dir} }} }}
2821matrix:
2822 - id: bad
2823 - id: good_a
2824 - id: good_b
2825execution:
2826 max_concurrent: 3
2827 on_error: stop
2828"#,
2829 good_csv = good_csv.display(),
2830 bad_dir = bad_sink_dir.display(),
2831 );
2832 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2833 let nodes = expand(&cfg).unwrap();
2834 let summary = run_expanded(
2835 nodes,
2836 ExecuteOptions {
2837 pipeline_name: "stop_parallel".into(),
2838 run_id: None,
2839 execution: cfg.execution.clone(),
2840 dry_run: false,
2841 limit: None,
2842 state_path_override: None,
2843 shard: None,
2844 auth: Default::default(),
2845 clock: chrono::Utc::now().fixed_offset(),
2846 cancel: None,
2847 resilience: None,
2848 sla: None,
2849 #[cfg(feature = "lineage")]
2850 lineage: None,
2851 #[cfg(feature = "lineage")]
2852 lineage_cfg: None,
2853 #[cfg(feature = "notify")]
2854 notifier: None,
2855 #[cfg(feature = "catalog")]
2856 catalog: None,
2857 },
2858 )
2859 .await
2860 .unwrap();
2861
2862 assert!(
2867 summary.had_failures(),
2868 "summary should record at least one failure: {summary:?}"
2869 );
2870 assert!(
2871 summary.invocations[0].error.is_some(),
2872 "first outcome must be the failure that triggered stop: {summary:?}"
2873 );
2874 for inv in &summary.invocations {
2878 assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
2879 }
2880 }
2881
2882 #[tokio::test]
2883 async fn on_error_continue_skips_failed_subtree_only() {
2884 let dir = tempfile::tempdir().unwrap();
2886 let good_csv = dir.path().join("good.csv");
2887 std::fs::write(&good_csv, "x\n1\n").unwrap();
2888 let good_out = dir.path().join("good.jsonl");
2889
2890 let yaml = format!(
2891 r#"version: 1
2892pipeline:
2893 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2894 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2895matrix:
2896 - id: bad
2897 sink: {{ config: {{ path: {bad_dir} }} }}
2898 - id: good
2899"#,
2900 good_csv = good_csv.display(),
2901 good_out = good_out.display(),
2902 bad_dir = dir.path().display(),
2903 );
2904 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2905 let nodes = expand(&cfg).unwrap();
2906 let summary = run_expanded(
2907 nodes,
2908 ExecuteOptions {
2909 pipeline_name: "continuetest".into(),
2910 run_id: None,
2911 execution: None,
2912 dry_run: false,
2913 limit: None,
2914 state_path_override: None,
2915 shard: None,
2916 auth: Default::default(),
2917 clock: chrono::Utc::now().fixed_offset(),
2918 cancel: None,
2919 resilience: None,
2920 sla: None,
2921 #[cfg(feature = "lineage")]
2922 lineage: None,
2923 #[cfg(feature = "lineage")]
2924 lineage_cfg: None,
2925 #[cfg(feature = "notify")]
2926 notifier: None,
2927 #[cfg(feature = "catalog")]
2928 catalog: None,
2929 },
2930 )
2931 .await
2932 .unwrap();
2933 assert_eq!(summary.invocations.len(), 2);
2934 assert_eq!(summary.failure_count(), 1);
2935 let good_outcome = summary
2936 .invocations
2937 .iter()
2938 .find(|i| i.row_id == "good")
2939 .unwrap();
2940 assert!(good_outcome.error.is_none());
2941 }
2942
2943 #[test]
2946 fn split_path_splits_on_dots() {
2947 assert_eq!(split_path("id"), vec!["id".to_string()]);
2948 assert_eq!(
2949 split_path("user.name"),
2950 vec!["user".to_string(), "name".to_string()]
2951 );
2952 }
2953
2954 #[test]
2955 fn minimal_paths_drops_descendants_of_kept_ancestors() {
2956 let paths = vec![
2957 vec!["user".into(), "name".into()],
2958 vec!["user".into()],
2959 vec!["id".into()],
2960 vec!["id".into()],
2961 ];
2962 let min = minimal_paths(paths);
2963 assert!(min.contains(&vec!["user".to_string()]));
2964 assert!(min.contains(&vec!["id".to_string()]));
2965 assert!(
2966 !min.contains(&vec!["user".to_string(), "name".to_string()]),
2967 "user.name must be dropped — covered by user"
2968 );
2969 assert_eq!(min.len(), 2);
2970 }
2971
2972 #[test]
2973 fn project_full_clones_whole_record() {
2974 let r = json!({"a": 1, "b": {"c": 2}});
2975 assert_eq!(project_record(&r, &Projection::Full), r);
2976 }
2977
2978 #[test]
2979 fn project_keeps_only_referenced_paths() {
2980 let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
2981 let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
2982 let got = project_record(&r, &p);
2983 assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
2984 assert!(got.get("blob").is_none());
2985 assert!(got["user"].get("age").is_none());
2986 }
2987
2988 #[test]
2989 fn project_array_index_path_resolves_same_as_original() {
2990 let r = json!({"tags": ["x", "y", "z"]});
2991 let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
2992 let got = project_record(&r, &p);
2993 assert_eq!(got, json!({"tags": {"0": "x"}}));
2994 assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
2995 assert_eq!(
2996 resolve_parent_key(&got, "tags.0"),
2997 resolve_parent_key(&r, "tags.0"),
2998 "reduced tree must resolve the same value as the original"
2999 );
3000 }
3001
3002 #[test]
3003 fn project_numeric_object_key_resolves_same_as_original() {
3004 let r = json!({"data": {"0": "x", "1": "y"}});
3009 let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
3010 let got = project_record(&r, &p);
3011 assert_eq!(got, json!({"data": {"0": "x"}}));
3012 assert_eq!(
3013 resolve_parent_key(&got, "data.0"),
3014 resolve_parent_key(&r, "data.0"),
3015 "numeric object-key path must resolve identically on the reduced tree"
3016 );
3017 }
3018
3019 #[test]
3020 fn project_missing_path_is_omitted() {
3021 let r = json!({"id": 1});
3022 let p = Projection::Paths(vec![vec!["nope".into()]]);
3023 assert_eq!(project_record(&r, &p), json!({}));
3024 }
3025
3026 #[test]
3027 fn build_projections_unions_parent_key_and_refs() {
3028 use crate::config::ConnectorSpec;
3029 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3030
3031 fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
3032 ExpandedNode {
3033 id: id.into(),
3034 row_index: 0,
3035 role: NodeRole::Child {
3036 parent_id: parent.into(),
3037 parent_key: parent_key.into(),
3038 },
3039 source: ConnectorSpec {
3040 kind: "csv".into(),
3041 config: json!({}),
3042 transforms: None,
3043 inherit_transforms: true,
3044 status: None,
3045 tags: Vec::new(),
3046 complete_for: None,
3047 },
3048 sink: ConnectorSpec {
3049 kind: "jsonl".into(),
3050 config: json!({}),
3051 transforms: None,
3052 inherit_transforms: true,
3053 status: None,
3054 tags: Vec::new(),
3055 complete_for: None,
3056 },
3057 transforms: Vec::new(),
3058 state: None,
3059 dlq: None,
3060 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3061 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3062 #[cfg(feature = "quality")]
3063 quality: None,
3064 #[cfg(feature = "contract")]
3065 contract: None,
3066 #[cfg(feature = "masking")]
3067 masking: None,
3068 sink_ref: "default".into(),
3069 schema: None,
3070 depends_on: Vec::new(),
3071 status: crate::config::SourceStatus::Active,
3072 tags: Vec::new(),
3073 cleanup_scope: None,
3074 deferred_refs: refs
3075 .iter()
3076 .map(|(rid, p)| DeferredRef {
3077 referenced_id: (*rid).into(),
3078 dotted_path: (*p).into(),
3079 token: format!("${{{rid}.{p}}}"),
3080 })
3081 .collect(),
3082 source_override: None,
3083 }
3084 }
3085
3086 let c1 = child("c1", "p", "id", &[("p", "user.name")]);
3087 let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
3088 let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
3089 let children_of =
3090 HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
3091
3092 let projs = build_projections(&nodes_by_id, &children_of);
3093 let p = projs.get("p").expect("projection for p");
3094 match &**p {
3095 Projection::Paths(paths) => {
3096 assert!(paths.contains(&vec!["id".to_string()]));
3097 assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
3098 assert!(paths.contains(&vec!["email".to_string()]));
3099 assert!(
3100 !paths.iter().any(|p| p == &vec!["x".to_string()]),
3101 "a ref to a different parent must not be captured under p"
3102 );
3103 }
3104 Projection::Full => panic!("expected Paths, got Full"),
3105 }
3106 }
3107
3108 #[test]
3109 fn build_projections_whole_record_ref_is_full() {
3110 use crate::config::ConnectorSpec;
3111 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3112 let c = ExpandedNode {
3113 id: "c".into(),
3114 row_index: 0,
3115 role: NodeRole::Child {
3116 parent_id: "p".into(),
3117 parent_key: "id".into(),
3118 },
3119 source: ConnectorSpec {
3120 kind: "csv".into(),
3121 config: json!({}),
3122 transforms: None,
3123 inherit_transforms: true,
3124 status: None,
3125 tags: Vec::new(),
3126 complete_for: None,
3127 },
3128 sink: ConnectorSpec {
3129 kind: "jsonl".into(),
3130 config: json!({}),
3131 transforms: None,
3132 inherit_transforms: true,
3133 status: None,
3134 tags: Vec::new(),
3135 complete_for: None,
3136 },
3137 transforms: Vec::new(),
3138 state: None,
3139 dlq: None,
3140 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3141 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3142 #[cfg(feature = "quality")]
3143 quality: None,
3144 #[cfg(feature = "contract")]
3145 contract: None,
3146 #[cfg(feature = "masking")]
3147 masking: None,
3148 sink_ref: "default".into(),
3149 schema: None,
3150 depends_on: Vec::new(),
3151 status: crate::config::SourceStatus::Active,
3152 tags: Vec::new(),
3153 cleanup_scope: None,
3154 deferred_refs: vec![DeferredRef {
3155 referenced_id: "p".into(),
3156 dotted_path: "".into(),
3157 token: "${p}".into(),
3158 }],
3159 source_override: None,
3160 };
3161 let nodes_by_id = HashMap::from([("c".to_string(), c)]);
3162 let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
3163 let projs = build_projections(&nodes_by_id, &children_of);
3164 assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
3165 }
3166
3167 fn opts(name: &str) -> ExecuteOptions {
3169 ExecuteOptions {
3170 pipeline_name: name.into(),
3171 run_id: None,
3172 execution: None,
3173 dry_run: false,
3174 limit: None,
3175 state_path_override: None,
3176 shard: None,
3177 auth: Default::default(),
3178 clock: chrono::Utc::now().fixed_offset(),
3179 cancel: None,
3180 resilience: None,
3181 sla: None,
3182 #[cfg(feature = "lineage")]
3183 lineage: None,
3184 #[cfg(feature = "lineage")]
3185 lineage_cfg: None,
3186 #[cfg(feature = "notify")]
3187 notifier: None,
3188 #[cfg(feature = "catalog")]
3189 catalog: None,
3190 }
3191 }
3192
3193 #[tokio::test]
3194 async fn dry_run_counts_records_without_writing_sink_file() {
3195 let dir = tempfile::tempdir().unwrap();
3198 let input = dir.path().join("in.csv");
3199 let output = dir.path().join("out.jsonl");
3200 std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
3201 let cfg = cfg_csv_to_jsonl(&input, &output);
3202 let nodes = expand(&cfg).unwrap();
3203 let mut o = opts("dry");
3204 o.dry_run = true;
3205 let summary = run_expanded(nodes, o).await.unwrap();
3206 assert_eq!(summary.invocations.len(), 1);
3207 assert_eq!(summary.invocations[0].records_written, 3);
3208 assert!(!summary.had_failures());
3209 assert!(
3210 !output.exists(),
3211 "dry-run must not create the real sink file"
3212 );
3213 }
3214
3215 #[tokio::test]
3216 async fn read_only_state_store_drops_writes_keeps_reads() {
3217 let inner = Arc::new(faucet_core::MemoryStateStore::new()) as Arc<dyn StateStore>;
3220 inner.put("k", &json!("v0")).await.unwrap();
3221 let ro = ReadOnlyStateStore {
3222 inner: inner.clone(),
3223 };
3224 assert_eq!(ro.get("k").await.unwrap(), Some(json!("v0")));
3225 ro.put("k", &json!("advanced")).await.unwrap();
3227 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3228 ro.delete("k").await.unwrap();
3230 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3231 }
3232
3233 #[tokio::test]
3234 async fn dry_run_with_state_does_not_persist_bookmark() {
3235 let dir = tempfile::tempdir().unwrap();
3239 let input = dir.path().join("in.csv");
3240 let output = dir.path().join("out.jsonl");
3241 let state_dir = dir.path().join("state");
3242 std::fs::create_dir_all(&state_dir).unwrap();
3243 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3244 let cfg = cfg_csv_to_jsonl(&input, &output);
3245 let nodes = expand(&cfg).unwrap();
3246 let mut o = opts("drystate");
3247 o.dry_run = true;
3248 o.state_path_override = Some(state_dir.clone());
3249 let summary = run_expanded(nodes, o).await.unwrap();
3250 assert!(!summary.had_failures());
3251 assert!(!output.exists(), "dry-run must not write the sink file");
3252 let persisted: Vec<_> = std::fs::read_dir(&state_dir)
3255 .unwrap()
3256 .filter_map(Result::ok)
3257 .collect();
3258 assert!(
3259 persisted.is_empty(),
3260 "dry-run must not persist any bookmark file, found: {persisted:?}"
3261 );
3262 }
3263
3264 #[tokio::test]
3265 async fn limit_caps_records_written_across_the_run() {
3266 let dir = tempfile::tempdir().unwrap();
3268 let input = dir.path().join("in.csv");
3269 let output = dir.path().join("out.jsonl");
3270 std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
3271 let cfg = cfg_csv_to_jsonl(&input, &output);
3272 let nodes = expand(&cfg).unwrap();
3273 let mut o = opts("lim");
3274 o.limit = Some(2);
3275 let summary = run_expanded(nodes, o).await.unwrap();
3276 assert_eq!(summary.invocations[0].records_written, 2);
3277 let body = std::fs::read_to_string(&output).unwrap();
3278 assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
3279 }
3280
3281 #[tokio::test]
3282 async fn duplicate_state_key_among_siblings_is_rejected() {
3283 let dir = tempfile::tempdir().unwrap();
3287 let parent_csv = dir.path().join("parents.csv");
3288 let child_csv = dir.path().join("child.csv");
3289 std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
3291 std::fs::write(&child_csv, "x\nA\n").unwrap();
3292 let parent_out = dir.path().join("parents.jsonl");
3293 let child_out = dir.path().join("child.jsonl");
3294 let yaml = format!(
3295 r#"version: 1
3296pipeline:
3297 source: {{ type: csv, config: {{ path: {parent} }} }}
3298 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3299 state: {{ type: memory }}
3300matrix:
3301 - id: parents
3302 - id: child
3303 parent: parents
3304 source: {{ config: {{ path: {child} }} }}
3305 sink: {{ config: {{ path: {child_out} }} }}
3306"#,
3307 parent = parent_csv.display(),
3308 parent_out = parent_out.display(),
3309 child = child_csv.display(),
3310 child_out = child_out.display(),
3311 );
3312 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3313 let nodes = expand(&cfg).unwrap();
3314 let err = run_expanded(nodes, opts("dupkey"))
3315 .await
3316 .expect_err("colliding sibling state keys must be rejected");
3317 match err {
3318 CliError::DuplicateStateKey { id, state_key } => {
3319 assert_eq!(id, "child");
3320 assert_eq!(state_key, "dupkey::child::dup");
3321 }
3322 other => panic!("expected DuplicateStateKey, got {other:?}"),
3323 }
3324 }
3325
3326 #[tokio::test]
3327 async fn state_path_override_writes_bookmark_file() {
3328 let dir = tempfile::tempdir().unwrap();
3333 let input = dir.path().join("in.csv");
3334 let output = dir.path().join("out.jsonl");
3335 let state_dir = dir.path().join("state");
3336 std::fs::write(&input, "name\nalice\n").unwrap();
3337 let cfg = cfg_csv_to_jsonl(&input, &output);
3338 let nodes = expand(&cfg).unwrap();
3339 let mut o = opts("statepath");
3340 o.state_path_override = Some(state_dir.clone());
3341 let summary = run_expanded(nodes, o).await.unwrap();
3342 assert!(!summary.had_failures());
3343 assert_eq!(summary.invocations[0].records_written, 1);
3347 }
3348
3349 #[tokio::test]
3350 async fn build_dlq_config_maps_spec_fields() {
3351 use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
3352 let dir = tempfile::tempdir().unwrap();
3353 let dlq_out = dir.path().join("dlq.jsonl");
3354 let spec = DlqSpec {
3355 sink: ConnectorSpec {
3356 kind: "jsonl".into(),
3357 config: json!({ "path": dlq_out.to_str().unwrap() }),
3358 transforms: None,
3359 inherit_transforms: true,
3360 status: None,
3361 tags: Vec::new(),
3362 complete_for: None,
3363 },
3364 on_batch_error: OnBatchErrorSpec::DlqAll,
3365 max_failures_per_page: Some(7),
3366 max_failures_total: Some(42),
3367 include_original_payload: false,
3368 };
3369 let cfg = build_dlq_config(&spec).await.unwrap();
3370 assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
3371 assert_eq!(cfg.max_failures_per_page, Some(7));
3372 assert_eq!(cfg.max_failures_total, Some(42));
3373 assert!(!cfg.include_original_payload);
3374 }
3375
3376 #[tokio::test]
3377 async fn build_state_for_node_arms() {
3378 let dir = tempfile::tempdir().unwrap();
3379
3380 let node = stub_node(None);
3382 assert!(build_state_for_node(&node, None).await.unwrap().is_none());
3383
3384 let p = dir.path().join("s1");
3386 assert!(
3387 build_state_for_node(&node, Some(&p))
3388 .await
3389 .unwrap()
3390 .is_some()
3391 );
3392
3393 let node_mem = stub_node(Some(crate::config::StateStoreSpec {
3395 kind: "memory".into(),
3396 config: json!({}),
3397 }));
3398 assert!(
3399 build_state_for_node(&node_mem, None)
3400 .await
3401 .unwrap()
3402 .is_some()
3403 );
3404
3405 let node_file = stub_node(Some(crate::config::StateStoreSpec {
3407 kind: "file".into(),
3408 config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
3409 }));
3410 let p2 = dir.path().join("override2");
3411 assert!(
3412 build_state_for_node(&node_file, Some(&p2))
3413 .await
3414 .unwrap()
3415 .is_some()
3416 );
3417
3418 let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
3421 kind: "memory".into(),
3422 config: json!({}),
3423 }));
3424 let p3 = dir.path().join("override3");
3425 assert!(
3426 build_state_for_node(&node_mem2, Some(&p3))
3427 .await
3428 .unwrap()
3429 .is_some()
3430 );
3431 }
3432
3433 fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
3435 use crate::config::ConnectorSpec;
3436 ExpandedNode {
3437 id: "n".into(),
3438 row_index: 0,
3439 role: NodeRole::Root,
3440 source: ConnectorSpec {
3441 kind: "csv".into(),
3442 config: json!({}),
3443 transforms: None,
3444 inherit_transforms: true,
3445 status: None,
3446 tags: Vec::new(),
3447 complete_for: None,
3448 },
3449 sink: ConnectorSpec {
3450 kind: "jsonl".into(),
3451 config: json!({}),
3452 transforms: None,
3453 inherit_transforms: true,
3454 status: None,
3455 tags: Vec::new(),
3456 complete_for: None,
3457 },
3458 transforms: Vec::new(),
3459 state,
3460 dlq: None,
3461 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3462 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3463 #[cfg(feature = "quality")]
3464 quality: None,
3465 #[cfg(feature = "contract")]
3466 contract: None,
3467 #[cfg(feature = "masking")]
3468 masking: None,
3469 sink_ref: "default".into(),
3470 schema: None,
3471 depends_on: Vec::new(),
3472 status: crate::config::SourceStatus::Active,
3473 tags: Vec::new(),
3474 cleanup_scope: None,
3475 deferred_refs: Vec::new(),
3476 source_override: None,
3477 }
3478 }
3479
3480 #[tokio::test]
3481 async fn state_key_override_delegates_and_overrides_key() {
3482 let dir = tempfile::tempdir().unwrap();
3484 let input = dir.path().join("in.csv");
3485 std::fs::write(&input, "name\nz\n").unwrap();
3486 let inner = build_source(
3487 "csv",
3488 json!({"path": input.to_str().unwrap()}),
3489 &AuthCatalog::new(),
3490 None,
3491 )
3492 .await
3493 .unwrap();
3494 let inner_name = inner.connector_name();
3496 let ov = StateKeyOverride {
3497 inner,
3498 key: "my::custom::key".into(),
3499 };
3500 assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
3501 assert_eq!(ov.connector_name(), inner_name);
3502 let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
3503 assert_eq!(rows.len(), 1);
3504 ov.apply_start_bookmark(json!({"any": "bookmark"}))
3506 .await
3507 .unwrap();
3508 assert!(!ov.supports_exactly_once());
3510 assert_eq!(
3511 ov.replay_guarantee(),
3512 faucet_core::ReplayGuarantee::NonDeterministic
3513 );
3514 assert_eq!(ov.capture_resume_position().await.unwrap(), None);
3515 }
3516
3517 #[tokio::test]
3518 async fn state_key_override_forwards_native_stream_pages() {
3519 struct PerPageBookmarkSource;
3525 #[async_trait]
3526 impl Source for PerPageBookmarkSource {
3527 async fn fetch_with_context(
3528 &self,
3529 _ctx: &HashMap<String, Value>,
3530 ) -> Result<Vec<Value>, FaucetError> {
3531 Ok(vec![json!({"id": 1}), json!({"id": 2})])
3532 }
3533 fn stream_pages<'a>(
3534 &'a self,
3535 _ctx: &'a HashMap<String, Value>,
3536 _batch_size: usize,
3537 ) -> std::pin::Pin<
3538 Box<
3539 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
3540 + Send
3541 + 'a,
3542 >,
3543 > {
3544 Box::pin(faucet_core::async_stream::try_stream! {
3545 yield faucet_core::StreamPage {
3546 records: vec![json!({"id": 1})],
3547 bookmark: Some(json!("bm-1")),
3548 };
3549 yield faucet_core::StreamPage {
3550 records: vec![json!({"id": 2})],
3551 bookmark: Some(json!("bm-2")),
3552 };
3553 })
3554 }
3555 fn state_key(&self) -> Option<String> {
3556 Some("native".into())
3557 }
3558 }
3559
3560 use futures::StreamExt;
3561 let ov = StateKeyOverride {
3562 inner: Box::new(PerPageBookmarkSource),
3563 key: "override".into(),
3564 };
3565 let ctx = HashMap::new();
3566 let pages: Vec<_> = ov
3567 .stream_pages(&ctx, 1000)
3568 .collect::<Vec<_>>()
3569 .await
3570 .into_iter()
3571 .collect::<Result<Vec<_>, _>>()
3572 .unwrap();
3573 assert_eq!(pages.len(), 2, "native page boundaries survive the wrap");
3574 assert_eq!(pages[0].bookmark, Some(json!("bm-1")));
3575 assert_eq!(pages[1].bookmark, Some(json!("bm-2")));
3576 }
3577
3578 #[tokio::test]
3579 async fn capturing_sink_forwards_capabilities_and_captures_idempotent_writes() {
3580 struct IdemSink;
3581 #[async_trait]
3582 impl Sink for IdemSink {
3583 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3584 Ok(records.len())
3585 }
3586 fn connector_name(&self) -> &'static str {
3587 "idem"
3588 }
3589 fn supports_idempotent_writes(&self) -> bool {
3590 true
3591 }
3592 fn dedups_by_key(&self) -> bool {
3593 true
3594 }
3595 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
3596 &[
3597 faucet_core::WriteMode::Append,
3598 faucet_core::WriteMode::Upsert,
3599 ]
3600 }
3601 async fn write_batch_idempotent(
3602 &self,
3603 records: &[Value],
3604 _scope: &str,
3605 _token: &str,
3606 ) -> Result<usize, FaucetError> {
3607 Ok(records.len())
3608 }
3609 async fn last_committed_token(
3610 &self,
3611 _scope: &str,
3612 ) -> Result<Option<String>, FaucetError> {
3613 Ok(Some("tok".into()))
3614 }
3615 }
3616
3617 let captured = Arc::new(Mutex::new(Vec::new()));
3618 let sink = CapturingSink::wrap(
3619 Box::new(IdemSink),
3620 Arc::clone(&captured),
3621 Arc::new(Projection::Full),
3622 );
3623 assert!(sink.supports_idempotent_writes());
3626 assert!(sink.dedups_by_key());
3627 assert_eq!(
3628 sink.sink_guarantee(),
3629 faucet_core::SinkGuarantee::AtomicWatermark
3630 );
3631 assert!(
3632 sink.supported_write_modes()
3633 .contains(&faucet_core::WriteMode::Upsert)
3634 );
3635 assert_eq!(
3636 sink.last_committed_token("k").await.unwrap(),
3637 Some("tok".into())
3638 );
3639 assert_eq!(sink.current_schema().await.unwrap(), None);
3640 assert!(!sink.supports_schema_evolution());
3641 let n = sink
3643 .write_batch_idempotent(&[json!({"id": 7})], "k", "t")
3644 .await
3645 .unwrap();
3646 assert_eq!(n, 1);
3647 assert_eq!(*captured.lock().await, vec![json!({"id": 7})]);
3648 }
3649
3650 #[tokio::test]
3651 async fn orphaned_child_surfaces_executor_deadlock() {
3652 use crate::config::ConnectorSpec;
3656 let orphan = ExpandedNode {
3657 id: "orphan".into(),
3658 row_index: 0,
3659 role: NodeRole::Child {
3660 parent_id: "missing-parent".into(),
3661 parent_key: "id".into(),
3662 },
3663 source: ConnectorSpec {
3664 kind: "csv".into(),
3665 config: json!({}),
3666 transforms: None,
3667 inherit_transforms: true,
3668 status: None,
3669 tags: Vec::new(),
3670 complete_for: None,
3671 },
3672 sink: ConnectorSpec {
3673 kind: "jsonl".into(),
3674 config: json!({}),
3675 transforms: None,
3676 inherit_transforms: true,
3677 status: None,
3678 tags: Vec::new(),
3679 complete_for: None,
3680 },
3681 transforms: Vec::new(),
3682 state: None,
3683 dlq: None,
3684 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3685 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3686 #[cfg(feature = "quality")]
3687 quality: None,
3688 #[cfg(feature = "contract")]
3689 contract: None,
3690 #[cfg(feature = "masking")]
3691 masking: None,
3692 sink_ref: "default".into(),
3693 schema: None,
3694 depends_on: Vec::new(),
3695 status: crate::config::SourceStatus::Active,
3696 tags: Vec::new(),
3697 cleanup_scope: None,
3698 deferred_refs: Vec::new(),
3699 source_override: None,
3700 };
3701 let err = run_expanded(vec![orphan], opts("deadlock"))
3702 .await
3703 .expect_err("an orphaned child must surface as an executor deadlock");
3704 match err {
3705 CliError::Internal(msg) => {
3706 assert!(msg.contains("executor deadlock"), "{msg}");
3707 assert!(msg.contains("orphan"), "{msg}");
3708 }
3709 other => panic!("expected Internal deadlock error, got {other:?}"),
3710 }
3711 }
3712
3713 #[test]
3714 fn value_to_string_brief_unquotes_strings_only() {
3715 assert_eq!(value_to_string_brief(&json!("hello")), "hello");
3716 assert_eq!(value_to_string_brief(&json!(42)), "42");
3717 assert_eq!(value_to_string_brief(&json!(true)), "true");
3718 assert_eq!(value_to_string_brief(&json!(null)), "null");
3719 assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
3720 }
3721
3722 #[test]
3723 fn build_state_key_with_and_without_parent() {
3724 assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
3725 assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
3726 }
3727
3728 #[test]
3729 fn resolve_parent_key_walks_objects_arrays_and_misses() {
3730 let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
3731 assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
3732 assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
3733 assert_eq!(resolve_parent_key(&r, "user.age"), None);
3735 assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
3737 assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
3739 }
3740
3741 #[tokio::test]
3742 async fn cooperative_cancel_returns_partial_ok() {
3743 let dir = tempfile::tempdir().unwrap();
3747 let input = dir.path().join("in.csv");
3748 let output = dir.path().join("out.jsonl");
3749 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3750 let cfg = cfg_csv_to_jsonl(&input, &output);
3751 let nodes = expand(&cfg).unwrap();
3752 let token = CancellationToken::new();
3753 token.cancel(); let mut o = opts("cancel");
3755 o.cancel = Some(token);
3756 let summary = run_expanded(nodes, o).await.unwrap();
3757 assert_eq!(summary.invocations.len(), 1);
3760 assert!(
3761 !summary.had_failures(),
3762 "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
3763 );
3764 }
3765
3766 #[tokio::test]
3767 async fn fanout_projects_away_unreferenced_parent_fields() {
3768 let dir = tempfile::tempdir().unwrap();
3772 let parent_csv = dir.path().join("parents.csv");
3773 let child_csv = dir.path().join("child.csv");
3774 std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
3775 std::fs::write(&child_csv, "x\nA\n").unwrap();
3776 let parent_out = dir.path().join("parents.jsonl");
3777 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3778
3779 let yaml = format!(
3780 r#"version: 1
3781pipeline:
3782 source: {{ type: csv, config: {{ path: {parent} }} }}
3783 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3784matrix:
3785 - id: parents
3786 - id: child
3787 parent: parents
3788 source: {{ config: {{ path: {child} }} }}
3789 sink: {{ config: {{ path: "{child_out}" }} }}
3790"#,
3791 parent = parent_csv.display(),
3792 parent_out = parent_out.display(),
3793 child = child_csv.display(),
3794 child_out = child_out_pattern.display(),
3795 );
3796 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3797 let nodes = expand(&cfg).unwrap();
3798 let summary = run_expanded(
3799 nodes,
3800 ExecuteOptions {
3801 pipeline_name: "projtest".into(),
3802 run_id: None,
3803 execution: None,
3804 dry_run: false,
3805 limit: None,
3806 state_path_override: None,
3807 shard: None,
3808 auth: Default::default(),
3809 clock: chrono::Utc::now().fixed_offset(),
3810 cancel: None,
3811 resilience: None,
3812 sla: None,
3813 #[cfg(feature = "lineage")]
3814 lineage: None,
3815 #[cfg(feature = "lineage")]
3816 lineage_cfg: None,
3817 #[cfg(feature = "notify")]
3818 notifier: None,
3819 #[cfg(feature = "catalog")]
3820 catalog: None,
3821 },
3822 )
3823 .await
3824 .unwrap();
3825
3826 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3828 assert!(!summary.had_failures(), "{summary:?}");
3829 assert!(dir.path().join("child-1.jsonl").exists());
3831 assert!(dir.path().join("child-2.jsonl").exists());
3832 }
3833}