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 execution: Option<ExecutionSpec>,
57 pub dry_run: bool,
59 pub limit: Option<usize>,
61 pub state_path_override: Option<PathBuf>,
63 pub shard: Option<faucet_core::ShardSpec>,
68 pub auth: AuthCatalog,
72 pub clock: DateTime<FixedOffset>,
76 pub cancel: Option<CancellationToken>,
82 pub resilience: Option<faucet_core::ResiliencePolicy>,
87 pub sla: Option<crate::sla::SlaSpec>,
92 #[cfg(feature = "lineage")]
95 pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
96 #[cfg(feature = "lineage")]
100 pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
101 #[cfg(feature = "notify")]
107 pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
108 #[cfg(feature = "catalog")]
115 pub catalog: Option<crate::catalog::CatalogHandle>,
116}
117
118const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
123
124#[derive(Debug)]
126pub struct InvocationOutcome {
127 pub row_id: String,
128 pub parent_record_key: Option<String>,
131 pub records_written: usize,
132 pub error: Option<String>,
133 pub metrics: Option<InvocationMetrics>,
138}
139
140#[derive(Debug, Clone, Default)]
145pub struct InvocationMetrics {
146 pub source_kind: String,
147 pub sink_kind: String,
148 pub duration_ms: u64,
149 pub records_read: Option<u64>,
150 pub dlq_count: u64,
151 pub bookmark: Option<Value>,
152}
153
154struct PipelineStats {
158 records_written: usize,
159 records_read: Option<u64>,
160 dlq_count: u64,
161 bookmark: Option<Value>,
162}
163
164#[derive(Debug)]
166pub struct RunSummary {
167 pub invocations: Vec<InvocationOutcome>,
168}
169
170impl RunSummary {
171 pub fn failure_count(&self) -> usize {
172 self.invocations
173 .iter()
174 .filter(|i| i.error.is_some())
175 .count()
176 }
177 pub fn had_failures(&self) -> bool {
178 self.failure_count() > 0
179 }
180}
181
182fn default_concurrency() -> usize {
193 std::thread::available_parallelism()
194 .map(|n| n.get())
195 .unwrap_or(4)
196 .clamp(1, 8)
197}
198
199pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
202 let on_error = opts
203 .execution
204 .as_ref()
205 .map(|e| e.on_error)
206 .unwrap_or_default();
207 let max_concurrent = opts
208 .execution
209 .as_ref()
210 .and_then(|e| e.max_concurrent)
211 .unwrap_or_else(default_concurrency)
212 .max(1);
213 let semaphore = Arc::new(Semaphore::new(max_concurrent));
214
215 let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
220 for n in nodes.iter() {
221 if let NodeRole::Child { parent_id, .. } = &n.role {
222 children_of
223 .entry(parent_id.clone())
224 .or_default()
225 .push(n.id.clone());
226 }
227 }
228
229 let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
234
235 let mut outcomes: Vec<InvocationOutcome> = Vec::new();
236 let mut skipped_subtrees: HashSet<String> = HashSet::new();
237
238 let cancel = opts.cancel.clone().unwrap_or_default();
243 let opts = Arc::new(opts);
244
245 let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
249 let mut completed: HashSet<String> = HashSet::new();
250 let nodes_by_id: HashMap<String, ExpandedNode> =
251 nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
252
253 let projections = build_projections(&nodes_by_id, &children_of);
256
257 let bfs_order: Vec<String> = {
261 let mut ids: Vec<(usize, String)> = nodes_by_id
262 .values()
263 .map(|n| (n.row_index, n.id.clone()))
264 .collect();
265 ids.sort_by_key(|(i, _)| *i);
266 ids.into_iter().map(|(_, id)| id).collect()
267 };
268
269 while !remaining.is_empty() {
270 let ready: Vec<String> = bfs_order
276 .iter()
277 .filter(|id| remaining.contains(*id))
278 .filter(|id| {
279 let node = &nodes_by_id[*id];
280 let parent_done = match &node.role {
281 NodeRole::Root => true,
282 NodeRole::Child { parent_id, .. } => {
283 completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
284 }
285 };
286 parent_done
287 && node
288 .depends_on
289 .iter()
290 .all(|d| completed.contains(d) || skipped_subtrees.contains(d))
291 })
292 .cloned()
293 .collect();
294
295 if ready.is_empty() {
296 let mut stuck: Vec<String> = remaining.iter().cloned().collect();
301 stuck.sort();
302 return Err(CliError::Internal(format!(
303 "executor deadlock: {} node(s) never became ready (no completed/skipped \
304 parent or dependency): {}",
305 stuck.len(),
306 stuck.join(", ")
307 )));
308 }
309
310 let mut units: Vec<Unit> = Vec::new();
313 let level_records: HashMap<String, Vec<Arc<Value>>> = {
320 let consumed_parents: HashSet<&str> = ready
321 .iter()
322 .filter_map(|id| match &nodes_by_id[id].role {
323 NodeRole::Child { parent_id, .. } => Some(parent_id.as_str()),
324 NodeRole::Root => None,
325 })
326 .collect();
327 let mut cap = captured.lock().await;
328 consumed_parents
329 .iter()
330 .filter_map(|p| cap.remove(*p).map(|v| (p.to_string(), v)))
331 .collect()
332 };
333 for id in &ready {
334 let node = &nodes_by_id[id];
335 if let NodeRole::Child { parent_id, .. } = &node.role
338 && skipped_subtrees.contains(parent_id)
339 {
340 skipped_subtrees.insert(id.clone());
341 tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
342 continue;
343 }
344 if let Some(dep) = node
349 .depends_on
350 .iter()
351 .find(|d| skipped_subtrees.contains(d.as_str()))
352 {
353 skipped_subtrees.insert(id.clone());
354 tracing::warn!(
355 row = %id, dependency = %dep,
356 "skipping row: a depends_on row failed or was skipped"
357 );
358 continue;
359 }
360 match &node.role {
361 NodeRole::Root => {
362 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
363 let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
364 validate_unit_state_key(&node.id, uses_state, &state_key)?;
365 units.push(Unit {
366 node: node.clone(),
367 parent_record: None,
368 state_key,
369 parent_record_key: None,
370 });
371 }
372 NodeRole::Child {
373 parent_id,
374 parent_key,
375 } => {
376 let parent_records = level_records.get(parent_id).cloned().unwrap_or_default();
377 if parent_records.is_empty() {
378 tracing::info!(
379 row = %id, parent = %parent_id,
380 "parent produced no records — child skipped"
381 );
382 continue;
383 }
384 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
386 let mut seen_keys: HashSet<String> = HashSet::new();
387 for record in &parent_records {
388 let pk_value = resolve_parent_key(record, parent_key);
389 let pk_string = pk_value
390 .as_ref()
391 .map(value_to_string_brief)
392 .unwrap_or_else(|| "(missing)".to_string());
393 let state_key =
394 build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
395 validate_unit_state_key(&node.id, uses_state, &state_key)?;
396 if !seen_keys.insert(state_key.clone()) {
397 return Err(CliError::DuplicateStateKey {
398 id: node.id.clone(),
399 state_key,
400 });
401 }
402 units.push(Unit {
403 node: node.clone(),
404 parent_record: Some(record.clone()),
405 state_key,
406 parent_record_key: Some(pk_string),
407 });
408 }
409 }
410 }
411 }
412 drop(level_records);
413
414 let mut had_level_failure = false;
415 let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
416
417 let level_cancel = cancel.child_token();
429 let mut joinset = tokio::task::JoinSet::new();
430 let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
434 for unit in units {
435 let sem = Arc::clone(&semaphore);
436 let opts2 = Arc::clone(&opts);
437 let captured = Arc::clone(&captured);
438 let capture = projections.get(&unit.node.id).cloned();
439 let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
440 let unit_cancel = level_cancel.clone();
441 let handle = joinset.spawn(async move {
442 let _permit = sem.acquire().await.expect("semaphore not closed");
443 run_unit(&unit, capture, &captured, &opts2, unit_cancel).await
444 });
445 task_meta.insert(handle.id(), meta);
446 }
447
448 let mut stop_triggered = false;
449 let mut aborted = false;
450 let mut stop_deadline: Option<tokio::time::Instant> = None;
451 loop {
452 let joined = match stop_deadline {
457 Some(deadline) if !aborted => {
458 match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
459 Ok(j) => j,
460 Err(_) => {
461 tracing::warn!(
462 "on_error: stop — flush grace elapsed; aborting remaining \
463 in-flight invocations"
464 );
465 joinset.abort_all();
466 aborted = true;
467 continue;
468 }
469 }
470 }
471 _ => joinset.join_next_with_id().await,
472 };
473 let Some(joined) = joined else { break };
474 let outcome = match joined {
478 Ok((_id, outcome)) => outcome,
479 Err(e) if e.is_cancelled() => {
480 continue;
483 }
484 Err(e) => {
485 let (row_id, parent_record_key) = task_meta
486 .get(&e.id())
487 .cloned()
488 .unwrap_or_else(|| ("<unknown>".to_string(), None));
489 InvocationOutcome {
490 row_id,
491 parent_record_key,
492 records_written: 0,
493 error: Some(format!("pipeline invocation task panicked: {e}")),
494 metrics: None,
495 }
496 }
497 };
498
499 if let Some(err) = &outcome.error {
500 tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
501 had_level_failure = true;
502 nodes_with_any_failure.insert(outcome.row_id.clone());
503 if matches!(on_error, OnError::Stop) && !stop_triggered {
504 stop_triggered = true;
505 tracing::error!(
506 "on_error: stop — cancelling in-flight invocations (cooperative \
507 flush), then aborting any that don't stop within the grace window"
508 );
509 level_cancel.cancel();
513 stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
514 }
515 } else {
516 tracing::info!(
517 row = %outcome.row_id,
518 records_written = outcome.records_written,
519 "pipeline invocation completed"
520 );
521 }
522 outcomes.push(outcome);
523 }
524
525 for id in ready {
529 remaining.remove(&id);
530 if nodes_with_any_failure.contains(&id) {
531 skipped_subtrees.insert(id.clone());
532 if let Some(children) = children_of.get(&id) {
534 for cid in children {
535 skipped_subtrees.insert(cid.clone());
536 }
537 }
538 } else {
539 completed.insert(id);
540 }
541 }
542
543 if had_level_failure && matches!(on_error, OnError::Stop) {
544 tracing::error!("on_error: stop — aborting after first failure");
545 break;
547 }
548 }
549
550 Ok(RunSummary {
551 invocations: outcomes,
552 })
553}
554
555struct Unit {
558 node: ExpandedNode,
559 parent_record: Option<Arc<Value>>,
560 state_key: String,
561 parent_record_key: Option<String>,
562}
563
564async fn run_unit(
565 unit: &Unit,
566 capture: Option<Arc<Projection>>,
567 captured: &CapturedRecords,
568 opts: &ExecuteOptions,
569 cancel: CancellationToken,
570) -> InvocationOutcome {
571 let needs_capture = capture.is_some();
572 let started = std::time::Instant::now();
573 let result = run_one_invocation(
574 &unit.node,
575 unit.parent_record.as_deref(),
576 &unit.state_key,
577 capture,
578 opts,
579 cancel,
580 )
581 .await;
582 let duration_ms = started.elapsed().as_millis() as u64;
583 let row_id = unit.node.id.clone();
584 let parent_record_key = unit.parent_record_key.clone();
585 let base_metrics = || InvocationMetrics {
586 source_kind: unit.node.source.kind.clone(),
587 sink_kind: unit.node.sink.kind.clone(),
588 duration_ms,
589 ..Default::default()
590 };
591 match result {
592 Ok((records, stats)) => {
593 if needs_capture {
594 captured
595 .lock()
596 .await
597 .entry(row_id.clone())
598 .or_default()
599 .extend(records.into_iter().map(Arc::new));
602 }
603 InvocationOutcome {
604 row_id,
605 parent_record_key,
606 records_written: stats.records_written,
607 error: None,
608 metrics: Some(InvocationMetrics {
609 records_read: stats.records_read,
610 dlq_count: stats.dlq_count,
611 bookmark: stats.bookmark,
612 ..base_metrics()
613 }),
614 }
615 }
616 Err(e) => InvocationOutcome {
617 row_id,
618 parent_record_key,
619 records_written: 0,
620 error: Some(e.to_string()),
621 metrics: Some(base_metrics()),
622 },
623 }
624}
625
626pub(crate) fn build_state_key(
628 pipeline_name: &str,
629 row_id: &str,
630 parent_key: Option<&str>,
631) -> String {
632 match parent_key {
633 None => format!("{pipeline_name}::{row_id}"),
634 Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
635 }
636}
637
638fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
643 if uses_state {
644 faucet_core::state::validate_state_key(state_key).map_err(|e| {
645 CliError::InvalidStateKey {
646 id: node_id.to_owned(),
647 state_key: state_key.to_owned(),
648 reason: e.to_string(),
649 }
650 })?;
651 }
652 Ok(())
653}
654
655fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
657 let mut cur = record;
658 for segment in parent_key.split('.') {
659 cur = match cur {
660 Value::Object(m) => m.get(segment)?,
661 Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
662 _ => return None,
663 };
664 }
665 Some(cur.clone())
666}
667
668#[derive(Debug, Clone)]
672enum Projection {
673 Full,
676 Paths(Vec<Vec<String>>),
678}
679
680fn split_path(path: &str) -> Vec<String> {
682 path.split('.').map(|s| s.to_string()).collect()
683}
684
685fn minimal_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
689 paths.sort();
690 paths.dedup();
691 let mut kept: Vec<Vec<String>> = Vec::new();
692 for p in paths {
693 let covered = kept
694 .iter()
695 .any(|anc| p.len() >= anc.len() && p[..anc.len()] == anc[..]);
696 if !covered {
697 kept.push(p);
698 }
699 }
700 kept
701}
702
703fn walk_value(record: &Value, segments: &[String]) -> Option<Value> {
706 let mut cur = record;
707 for seg in segments {
708 cur = match cur {
709 Value::Object(m) => m.get(seg)?,
710 Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
711 _ => return None,
712 };
713 }
714 Some(cur.clone())
715}
716
717fn graft_object(out: &mut Value, segments: &[String], leaf: Value) {
722 if segments.is_empty() {
723 return;
724 }
725 let mut cur = out;
726 for seg in &segments[..segments.len() - 1] {
727 let map = match cur {
728 Value::Object(m) => m,
729 _ => return,
730 };
731 cur = map
732 .entry(seg.clone())
733 .or_insert_with(|| Value::Object(serde_json::Map::new()));
734 }
735 if let Value::Object(m) = cur {
736 m.insert(segments[segments.len() - 1].clone(), leaf);
737 }
738}
739
740fn project_record(record: &Value, projection: &Projection) -> Value {
745 match projection {
746 Projection::Full => record.clone(),
747 Projection::Paths(paths) => {
748 let mut out = Value::Object(serde_json::Map::new());
749 for segs in paths {
750 if let Some(v) = walk_value(record, segs) {
751 graft_object(&mut out, segs, v);
752 }
753 }
754 out
755 }
756 }
757}
758
759fn build_projections(
764 nodes_by_id: &HashMap<String, ExpandedNode>,
765 children_of: &HashMap<String, Vec<String>>,
766) -> HashMap<String, Arc<Projection>> {
767 let mut out = HashMap::new();
768 for (parent_id, child_ids) in children_of {
769 let mut raw: Vec<Vec<String>> = Vec::new();
770 let mut full = false;
771 for cid in child_ids {
772 let child = &nodes_by_id[cid];
773 if let NodeRole::Child { parent_key, .. } = &child.role {
774 if parent_key.is_empty() {
775 full = true;
776 } else {
777 raw.push(split_path(parent_key));
778 }
779 }
780 for dref in &child.deferred_refs {
781 if dref.referenced_id == *parent_id {
782 if dref.dotted_path.is_empty() {
783 full = true; } else {
785 raw.push(split_path(&dref.dotted_path));
786 }
787 }
788 }
789 }
790 let projection = if full || raw.is_empty() {
795 Projection::Full
796 } else {
797 Projection::Paths(minimal_paths(raw))
798 };
799 out.insert(parent_id.clone(), Arc::new(projection));
800 }
801 out
802}
803
804#[allow(clippy::too_many_arguments)]
815async fn build_pipeline<'a>(
816 source: &'a dyn Source,
817 sink: &'a dyn Sink,
818 node: &ExpandedNode,
819 opts: &ExecuteOptions,
820 state: Option<Arc<dyn StateStore>>,
821 cancel: &CancellationToken,
822 pipeline_name: &str,
823 row_id: &str,
824 run_id: &str,
825) -> CliResult<Pipeline<'a, dyn Source + 'a, dyn Sink + 'a>> {
826 let mut pipeline = Pipeline::new(source, sink)
827 .with_name(pipeline_name.to_owned())
828 .with_row(row_id.to_owned())
829 .with_run_id(run_id.to_owned());
830 if let Some(store) = state {
831 pipeline = pipeline.with_state_store(store);
832 }
833 if let Some(ref dlq_spec) = node.dlq {
834 let dlq_cfg = build_dlq_config(dlq_spec).await?;
835 pipeline = pipeline.with_dlq(dlq_cfg);
836 }
837 pipeline = pipeline.with_cancel(cancel.clone());
842 #[cfg(feature = "quality")]
846 if let Some(ref quality_spec) = node.quality {
847 let compiled = Arc::new(
848 faucet_core::CompiledQuality::compile(quality_spec)
849 .map_err(|e| CliError::Config(format!("quality: {e}")))?,
850 );
851 pipeline = pipeline.with_quality(compiled);
852 }
853 #[cfg(feature = "contract")]
857 if let Some(ref contract_spec) = node.contract {
858 let compiled = Arc::new(
859 faucet_core::CompiledContract::compile(contract_spec)
860 .map_err(|e| CliError::Config(format!("contract: {e}")))?,
861 );
862 pipeline = pipeline.with_contract(compiled);
863 }
864 #[cfg(feature = "masking")]
870 if let Some(ref masking_spec) = node.masking {
871 let sink_ids = [node.sink_ref.as_str(), node.sink.kind.as_str()];
872 let compiled = faucet_core::CompiledMasking::compile_for_sink(masking_spec, &sink_ids)
873 .map_err(|e| CliError::Config(format!("masking: {e}")))?;
874 if !compiled.is_empty() {
875 pipeline = pipeline.with_masking(Arc::new(compiled));
876 }
877 }
878 if let Some(ref sd) = node.schema {
880 pipeline = pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd));
881 }
882 if let Some(ab) = opts
884 .execution
885 .as_ref()
886 .and_then(|e| e.adaptive_batch_size.clone())
887 {
888 ab.validate()
889 .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
890 pipeline = pipeline.with_adaptive(ab);
891 }
892 if let Some(policy) = opts.resilience.clone() {
895 pipeline = pipeline.with_resilience(policy);
896 }
897 let effective_delivery = if opts.dry_run || opts.limit.is_some() {
903 faucet_core::idempotency::DeliveryMode::AtLeastOnce
904 } else {
905 node.delivery
906 };
907 pipeline = pipeline.with_delivery(effective_delivery);
908 Ok(pipeline)
909}
910
911async fn run_one_invocation(
912 node: &ExpandedNode,
913 parent_record: Option<&Value>,
914 state_key: &str,
915 capture: Option<Arc<Projection>>,
916 opts: &ExecuteOptions,
917 cancel: CancellationToken,
918) -> CliResult<(Vec<Value>, PipelineStats)> {
919 let run_id = uuid::Uuid::now_v7().to_string();
922 let pipeline_name = opts.pipeline_name.clone();
923 let row_id = node.id.clone();
924 #[cfg(feature = "lineage")]
925 let lineage = opts.lineage.clone();
926 #[cfg(feature = "lineage")]
927 let lineage_cfg = opts.lineage_cfg.clone();
928 let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
929 #[cfg(feature = "catalog")]
933 let catalog_active = opts.catalog.is_some()
934 && matches!(node.role, NodeRole::Root)
935 && !opts.dry_run
936 && opts.limit.is_none()
937 && opts.shard.is_none();
938 let mut source_cfg = node.source.config.clone();
940 let mut sink_cfg = node.sink.config.clone();
941
942 resolve_now_inplace(&mut source_cfg, opts.clock)?;
945 resolve_now_inplace(&mut sink_cfg, opts.clock)?;
946 reject_unresolved_backfill_tokens(&source_cfg, "source")?;
952 reject_unresolved_backfill_tokens(&sink_cfg, "sink")?;
953
954 if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
955 let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
956 resolve_inplace(&mut source_cfg, &ctx)?;
957 resolve_inplace(&mut sink_cfg, &ctx)?;
958 }
959
960 let source = match node.source_override.as_ref().and_then(|o| o.take()) {
965 Some(prebuilt) => prebuilt,
966 None => {
967 build_source(
968 &node.source.kind,
969 source_cfg,
970 &opts.auth,
971 opts.resilience.as_ref().map(|r| &r.retry),
972 )
973 .await?
974 }
975 };
976
977 #[cfg(feature = "catalog")]
980 let source_dataset_uri = source.dataset_uri();
981
982 if let Some(shard) = &opts.shard {
986 source
987 .apply_shard(shard)
988 .await
989 .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
990 }
991 let raw_sink: Box<dyn Sink> = if opts.dry_run {
992 Box::new(CountingSink::new())
993 } else {
994 build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
995 };
996 #[cfg(feature = "catalog")]
997 let sink_dataset_uri = raw_sink.dataset_uri();
998 let raw_sink: Box<dyn Sink> = match opts.limit {
999 Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
1000 None => raw_sink,
1001 };
1002 let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
1003 let sink: Box<dyn Sink> = match &capture {
1004 Some(projection) => Box::new(CapturingSink::wrap(
1005 raw_sink,
1006 Arc::clone(&captured),
1007 Arc::clone(projection),
1008 )),
1009 None => raw_sink,
1010 };
1011
1012 #[cfg(feature = "lineage")]
1018 let (in_sample, out_sample) = {
1019 use std::sync::Arc as StdArc;
1020 let mut want = false;
1021 let mut cap = 0usize;
1022 if let (Some(_), Some(lc)) = (&lineage, &lineage_cfg) {
1023 let want_schema = lc.include_schema_facet || lc.include_column_lineage;
1024 if want_schema {
1025 cap = cap.max(lc.sample_records);
1026 }
1027 want = want_schema || lc.emit_on.running;
1028 }
1029 #[cfg(feature = "catalog")]
1033 if catalog_active {
1034 want = true;
1035 cap = cap.max(
1036 opts.catalog
1037 .as_ref()
1038 .map(|h| h.sample_records)
1039 .unwrap_or(crate::catalog::DEFAULT_SAMPLE_RECORDS),
1040 );
1041 }
1042 if want {
1043 (
1044 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1045 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1046 )
1047 } else {
1048 (None, None)
1049 }
1050 };
1051
1052 #[cfg(feature = "lineage")]
1055 let source: Box<dyn Source> = match &in_sample {
1056 Some(state) => Box::new(faucet_lineage::SamplingSource::new(
1057 source,
1058 std::sync::Arc::clone(state),
1059 )),
1060 None => source,
1061 };
1062
1063 let mut transforms = node.transforms.clone();
1068 for t in &mut transforms {
1069 resolve_now_inplace(&mut t.config, opts.clock)?;
1070 }
1071 #[cfg(feature = "arrow")]
1076 let source: Box<dyn Source> = {
1077 let (stages, batch_fns) = crate::transforms::compile_transforms_columnar(&transforms)?;
1078 if stages.is_empty() {
1079 source
1080 } else {
1081 Box::new(faucet_core::TransformingSource::new_with_batches(
1082 source,
1083 stages,
1084 batch_fns,
1085 obs_labels.clone(),
1086 )?)
1087 }
1088 };
1089 #[cfg(not(feature = "arrow"))]
1090 let source: Box<dyn Source> = {
1091 let stages = crate::transforms::compile_transforms(&transforms)?;
1092 if stages.is_empty() {
1093 source
1094 } else {
1095 Box::new(faucet_core::TransformingSource::new(
1096 source,
1097 stages,
1098 obs_labels.clone(),
1099 )?)
1100 }
1101 };
1102
1103 let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
1107 let state: Option<Arc<dyn StateStore>> = match state {
1112 Some(inner) if opts.dry_run || opts.limit.is_some() => {
1113 Some(Arc::new(ReadOnlyStateStore { inner }))
1114 }
1115 other => other,
1116 };
1117 let sla_store = state.clone();
1120 let effective_state_key = match &opts.shard {
1123 Some(shard) => format!("{state_key}::{}", shard.id),
1124 None => state_key.to_owned(),
1125 };
1126 let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
1127 Box::new(StateKeyOverride {
1128 inner: source,
1129 key: effective_state_key,
1130 })
1131 } else {
1132 source
1133 };
1134
1135 #[cfg(feature = "lineage")]
1138 let sink: Box<dyn Sink> = match &out_sample {
1139 Some(state) => Box::new(faucet_lineage::SamplingSink::new(
1140 sink,
1141 std::sync::Arc::clone(state),
1142 )),
1143 None => sink,
1144 };
1145
1146 let pipeline = build_pipeline(
1154 source.as_ref(),
1155 sink.as_ref(),
1156 node,
1157 opts,
1158 state,
1159 &cancel,
1160 &pipeline_name,
1161 &row_id,
1162 &run_id,
1163 )
1164 .await?;
1165 #[cfg(feature = "lineage")]
1167 let lineage_ctx = match (&lineage, &lineage_cfg) {
1168 (Some(em), Some(lc)) => {
1169 let job_name =
1170 crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
1171 let mut ctx = faucet_lineage::RunLifecycle {
1172 job_namespace: lc.namespace.clone(),
1173 job_name,
1174 run_id: run_id.clone(),
1175 parent: lc.parent_job.clone(),
1176 input: faucet_lineage::DatasetRef {
1177 namespace: lc.namespace.clone(),
1178 name: source.dataset_uri(),
1179 },
1180 output: faucet_lineage::DatasetRef {
1181 namespace: lc.namespace.clone(),
1182 name: sink.dataset_uri(),
1183 },
1184 started_at: chrono::Utc::now(),
1185 finished_at: None,
1186 records: 0,
1187 error: None,
1188 input_schema: None,
1189 output_schema: None,
1190 column_lineage: None,
1191 source_code: None,
1192 };
1193 em.emit(faucet_lineage::EventType::Start, &ctx).await;
1194 let hb_handle = if lc.emit_on.running {
1197 let em2 = std::sync::Arc::clone(em);
1198 let interval = lc.heartbeat_interval;
1199 let mut beat_ctx = ctx.clone();
1200 let counter = out_sample.clone();
1201 Some(tokio::spawn(async move {
1202 let mut tick = tokio::time::interval(interval);
1203 tick.tick().await; loop {
1205 tick.tick().await;
1206 if let Some(c) = &counter {
1207 beat_ctx.records = c.count();
1208 }
1209 em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
1210 .await;
1211 }
1212 }))
1213 } else {
1214 None
1215 };
1216 ctx.source_code = if lc.include_source_code_facet {
1217 Some(serde_json::to_string(&node.source.config).unwrap_or_default())
1218 } else {
1219 None
1220 };
1221 Some((std::sync::Arc::clone(em), ctx, hb_handle))
1222 }
1223 _ => None,
1224 };
1225
1226 let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1233 Ok(r) => sink.flush().await.map(|_| r),
1234 Err(e) => Err(e),
1235 };
1236
1237 #[cfg(feature = "lineage")]
1238 if let Some((em, mut ctx, hb)) = lineage_ctx {
1239 if let Some(h) = hb {
1240 h.abort();
1241 }
1242 ctx.finished_at = Some(chrono::Utc::now());
1243 if let Some(state) = &out_sample {
1244 ctx.records = state.count();
1245 if lineage_cfg
1246 .as_ref()
1247 .map(|l| l.include_schema_facet)
1248 .unwrap_or(false)
1249 {
1250 ctx.output_schema = Some(state.inferred_schema());
1251 }
1252 }
1253 if let Some(state) = &in_sample
1254 && lineage_cfg
1255 .as_ref()
1256 .map(|l| l.include_schema_facet || l.include_column_lineage)
1257 .unwrap_or(false)
1258 {
1259 let in_schema = state.inferred_schema();
1260 if lineage_cfg
1261 .as_ref()
1262 .map(|l| l.include_column_lineage)
1263 .unwrap_or(false)
1264 {
1265 let input_fields: Vec<String> =
1266 in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1267 #[cfg(feature = "masking")]
1268 let has_masking = node.masking.is_some();
1269 #[cfg(not(feature = "masking"))]
1270 let has_masking = false;
1271 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1272 ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1273 }
1274 if lineage_cfg
1275 .as_ref()
1276 .map(|l| l.include_schema_facet)
1277 .unwrap_or(false)
1278 {
1279 ctx.input_schema = Some(in_schema);
1280 }
1281 }
1282 let ev = match &result {
1283 Err(e) => {
1284 ctx.error = Some(e.to_string());
1285 faucet_lineage::EventType::Fail
1286 }
1287 Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1288 Ok(_) => faucet_lineage::EventType::Complete,
1289 };
1290 em.emit(ev, &ctx).await;
1291 }
1292
1293 let is_notifiable_root = matches!(node.role, NodeRole::Root)
1303 && !opts.dry_run
1304 && opts.limit.is_none()
1305 && opts.shard.is_none()
1306 && !cancel.is_cancelled();
1307
1308 #[cfg_attr(not(feature = "notify"), allow(unused_variables))]
1309 let sla_violations = if let Some(spec) = &opts.sla
1310 && is_notifiable_root
1311 {
1312 let outcome = match &result {
1313 Ok(r) => crate::sla::RunOutcome::Success {
1314 rows: r.records_written as u64,
1315 },
1316 Err(_) => crate::sla::RunOutcome::Failure,
1317 };
1318 crate::sla::evaluate_post_run(
1319 spec,
1320 sla_store.as_ref(),
1321 state_key,
1322 &obs_labels.pipeline,
1323 &obs_labels.row,
1324 outcome,
1325 chrono::Utc::now().timestamp(),
1326 )
1327 .await
1328 } else {
1329 Vec::new()
1330 };
1331
1332 #[cfg(feature = "notify")]
1337 if let Some(notifier) = &opts.notifier
1338 && is_notifiable_root
1339 {
1340 use crate::notify::NotifyEvent;
1341 let pipeline = obs_labels.pipeline.to_string();
1342 let row = obs_labels.row.to_string();
1343 match &result {
1344 Ok(r) => {
1345 notifier
1346 .emit(NotifyEvent::run_success(
1347 pipeline.clone(),
1348 row.clone(),
1349 r.records_written as u64,
1350 ))
1351 .await;
1352 if let Some(dlq) = &r.dlq
1353 && dlq.records_dlq > 0
1354 {
1355 notifier
1356 .emit(NotifyEvent::dlq_threshold(
1357 pipeline.clone(),
1358 row.clone(),
1359 dlq.records_dlq as u64,
1360 ))
1361 .await;
1362 }
1363 }
1364 Err(e) => {
1365 notifier.emit(error_event(&pipeline, &row, e)).await;
1366 }
1367 }
1368 for v in &sla_violations {
1369 notifier
1370 .emit(NotifyEvent::sla_breach(
1371 pipeline.clone(),
1372 row.clone(),
1373 v.kind(),
1374 v.to_string(),
1375 ))
1376 .await;
1377 }
1378 }
1379
1380 #[cfg(feature = "catalog")]
1385 if let Some(handle) = &opts.catalog
1386 && catalog_active
1387 && !cancel.is_cancelled()
1388 && let Ok(pipeline_result) = &result
1389 {
1390 use crate::catalog::model::{canonicalize_uri, schema_from_samples};
1391 use crate::serve::history::catalog::{CatalogUpdate, DatasetObservation, DatasetRole};
1392
1393 let records_written = pipeline_result.records_written as u64;
1394 let source_schema = in_sample
1395 .as_ref()
1396 .and_then(|s| schema_from_samples(&s.samples()));
1397 let sink_schema = out_sample
1398 .as_ref()
1399 .and_then(|s| schema_from_samples(&s.samples()));
1400 let records_read = in_sample
1403 .as_ref()
1404 .map(|s| s.count())
1405 .unwrap_or(records_written);
1406 let records_out = out_sample
1407 .as_ref()
1408 .map(|s| s.count())
1409 .unwrap_or(records_written);
1410
1411 let column_lineage = in_sample.as_ref().and_then(|s| {
1414 let input_fields: Vec<String> = s
1415 .inferred_schema()
1416 .fields
1417 .iter()
1418 .map(|(n, _)| n.clone())
1419 .collect();
1420 #[cfg(feature = "masking")]
1421 let has_masking = node.masking.is_some();
1422 #[cfg(not(feature = "masking"))]
1423 let has_masking = false;
1424 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1425 faucet_lineage::derive_column_lineage(&input_fields, &ops).map(|cl| {
1426 let fields: serde_json::Map<String, Value> = cl
1429 .edges
1430 .iter()
1431 .map(|(out, ins)| {
1432 (
1433 out.clone(),
1434 Value::Array(ins.iter().map(|s| Value::String(s.clone())).collect()),
1435 )
1436 })
1437 .collect();
1438 serde_json::json!({ "fields": fields })
1439 })
1440 });
1441
1442 let update = CatalogUpdate {
1443 run_id: handle.run_id.clone().unwrap_or_else(|| run_id.clone()),
1444 pipeline: obs_labels.pipeline.to_string(),
1445 row: obs_labels.row.to_string(),
1446 recorded_at: chrono::Utc::now(),
1447 source: DatasetObservation {
1448 uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
1449 kind: node.source.kind.clone(),
1450 role: DatasetRole::Source,
1451 schema: source_schema,
1452 records: records_read,
1453 },
1454 sink: DatasetObservation {
1455 uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
1456 kind: node.sink.kind.clone(),
1457 role: DatasetRole::Sink,
1458 schema: sink_schema,
1459 records: records_out,
1460 },
1461 column_lineage,
1462 };
1463 crate::catalog::record(handle, &update).await;
1464 }
1465
1466 let result = result?;
1467
1468 #[cfg(feature = "lineage")]
1472 let records_read = in_sample.as_ref().map(|s| s.count());
1473 #[cfg(not(feature = "lineage"))]
1474 let records_read: Option<u64> = None;
1475 let stats = PipelineStats {
1476 records_written: result.records_written,
1477 records_read,
1478 dlq_count: result
1479 .dlq
1480 .as_ref()
1481 .map(|d| d.records_dlq as u64)
1482 .unwrap_or(0),
1483 bookmark: result.bookmark.clone(),
1484 };
1485
1486 let captured = if capture.is_some() {
1487 std::mem::take(&mut *captured.lock().await)
1488 } else {
1489 Vec::new()
1490 };
1491 Ok((captured, stats))
1492}
1493
1494async fn build_state_for_node(
1495 node: &ExpandedNode,
1496 state_path_override: Option<&Path>,
1497) -> CliResult<Option<Arc<dyn StateStore>>> {
1498 match (&node.state, state_path_override) {
1499 (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1500 (None, Some(path)) => Ok(Some(state_from_override(path))),
1501 (Some(spec), Some(path)) => {
1502 if spec.kind == "file" {
1503 Ok(Some(state_from_override(path)))
1504 } else {
1505 tracing::warn!(
1506 state = %spec.kind,
1507 "--state-path is only meaningful for the 'file' backend; ignoring override"
1508 );
1509 Ok(Some(build_state_store(spec).await?))
1510 }
1511 }
1512 (None, None) => Ok(None),
1513 }
1514}
1515
1516fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1517 Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1518}
1519
1520pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1523 let sink = build_sink(
1526 &spec.sink.kind,
1527 spec.sink.config.clone(),
1528 &AuthCatalog::new(),
1529 )
1530 .await?;
1531 Ok(DlqConfig {
1532 sink: Arc::from(sink),
1533 on_batch_error: match spec.on_batch_error {
1534 crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1535 crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1536 },
1537 max_failures_per_page: spec.max_failures_per_page,
1538 max_failures_total: spec.max_failures_total,
1539 include_original_payload: spec.include_original_payload,
1540 })
1541}
1542
1543#[cfg(feature = "notify")]
1547fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
1548 use crate::notify::NotifyEvent;
1549 match err {
1550 FaucetError::CircuitOpen { failures, cooldown } => {
1551 NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
1552 }
1553 FaucetError::ContractViolation { message, .. } => {
1554 NotifyEvent::contract_abort(pipeline, row, message.clone())
1555 }
1556 other => {
1557 NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
1558 }
1559 }
1560}
1561
1562#[cfg(feature = "notify")]
1566fn faucet_error_kind(err: &FaucetError) -> &'static str {
1567 match err {
1568 FaucetError::Config(_) => "config",
1569 FaucetError::Source(_) => "source",
1570 FaucetError::Sink(_) => "sink",
1571 FaucetError::State(_) => "state",
1572 FaucetError::QualityFailure { .. } => "quality",
1573 FaucetError::SchemaDrift { .. } => "schema_drift",
1574 _ => "error",
1575 }
1576}
1577
1578fn reject_unresolved_backfill_tokens(value: &Value, owner: &str) -> CliResult<()> {
1587 fn walk(value: &Value, owner: &str) -> CliResult<()> {
1588 match value {
1589 Value::String(s) if s.contains("${backfill.") => Err(CliError::Config(format!(
1590 "the {owner} config references a `${{backfill.*}}` token, which only `faucet backfill` resolves — run this config via `faucet backfill --from … --to …`, or remove the token"
1591 ))),
1592 Value::Array(a) => a.iter().try_for_each(|v| walk(v, owner)),
1593 Value::Object(m) => m.values().try_for_each(|v| walk(v, owner)),
1594 _ => Ok(()),
1595 }
1596 }
1597 walk(value, owner)
1598}
1599
1600pub(crate) fn resolve_now_inplace(
1601 value: &mut Value,
1602 clock: DateTime<FixedOffset>,
1603) -> CliResult<()> {
1604 match value {
1605 Value::String(s) => {
1606 *s = crate::interpolate::resolve_now(s, clock)?;
1607 Ok(())
1608 }
1609 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1610 Value::Object(m) => m
1611 .values_mut()
1612 .try_for_each(|v| resolve_now_inplace(v, clock)),
1613 _ => Ok(()),
1614 }
1615}
1616
1617fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1621 match value {
1622 Value::String(s) => {
1623 let resolved = interpolate_record(s, ctx)?;
1624 *s = resolved;
1625 Ok(())
1626 }
1627 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1628 Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1629 _ => Ok(()),
1630 }
1631}
1632
1633struct ReadOnlyStateStore {
1646 inner: Arc<dyn StateStore>,
1647}
1648
1649#[async_trait]
1650impl StateStore for ReadOnlyStateStore {
1651 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1652 self.inner.get(key).await
1653 }
1654 async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1655 Ok(())
1656 }
1657 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1658 Ok(())
1659 }
1660}
1661
1662struct StateKeyOverride {
1666 inner: Box<dyn Source>,
1667 key: String,
1668}
1669
1670#[async_trait]
1671impl Source for StateKeyOverride {
1672 async fn fetch_with_context(
1673 &self,
1674 ctx: &HashMap<String, Value>,
1675 ) -> Result<Vec<Value>, FaucetError> {
1676 self.inner.fetch_with_context(ctx).await
1677 }
1678 async fn fetch_with_context_incremental(
1679 &self,
1680 ctx: &HashMap<String, Value>,
1681 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1682 self.inner.fetch_with_context_incremental(ctx).await
1683 }
1684 fn stream_pages<'a>(
1690 &'a self,
1691 ctx: &'a HashMap<String, Value>,
1692 batch_size: usize,
1693 ) -> std::pin::Pin<
1694 Box<
1695 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
1696 + Send
1697 + 'a,
1698 >,
1699 > {
1700 self.inner.stream_pages(ctx, batch_size)
1701 }
1702 fn connector_name(&self) -> &'static str {
1703 self.inner.connector_name()
1704 }
1705 fn dataset_uri(&self) -> String {
1706 self.inner.dataset_uri()
1707 }
1708 fn state_key(&self) -> Option<String> {
1709 Some(self.key.clone())
1710 }
1711 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1712 self.inner.apply_start_bookmark(bookmark).await
1713 }
1714 fn supports_exactly_once(&self) -> bool {
1715 self.inner.supports_exactly_once()
1716 }
1717 fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
1718 self.inner.replay_guarantee()
1719 }
1720 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1721 self.inner.capture_resume_position().await
1722 }
1723}
1724
1725struct CapturingSink {
1729 inner: Box<dyn Sink>,
1730 captured: Arc<Mutex<Vec<Value>>>,
1731 projection: Arc<Projection>,
1732}
1733
1734impl CapturingSink {
1735 fn wrap(
1736 inner: Box<dyn Sink>,
1737 captured: Arc<Mutex<Vec<Value>>>,
1738 projection: Arc<Projection>,
1739 ) -> Self {
1740 Self {
1741 inner,
1742 captured,
1743 projection,
1744 }
1745 }
1746}
1747
1748#[async_trait]
1749impl Sink for CapturingSink {
1750 fn connector_name(&self) -> &'static str {
1751 self.inner.connector_name()
1752 }
1753 fn dataset_uri(&self) -> String {
1754 self.inner.dataset_uri()
1755 }
1756 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1757 let written = self.inner.write_batch(records).await?;
1758 let n = written.min(records.len());
1761 let mut buf = self.captured.lock().await;
1762 buf.extend(
1763 records
1764 .iter()
1765 .take(n)
1766 .map(|r| project_record(r, &self.projection)),
1767 );
1768 Ok(written)
1769 }
1770 async fn flush(&self) -> Result<(), FaucetError> {
1771 self.inner.flush().await
1772 }
1773 fn supports_idempotent_writes(&self) -> bool {
1777 self.inner.supports_idempotent_writes()
1778 }
1779 fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
1780 self.inner.sink_guarantee()
1781 }
1782 fn dedups_by_key(&self) -> bool {
1783 self.inner.dedups_by_key()
1784 }
1785 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
1786 self.inner.supported_write_modes()
1787 }
1788 async fn write_batch_idempotent(
1789 &self,
1790 records: &[Value],
1791 scope: &str,
1792 token: &str,
1793 ) -> Result<usize, FaucetError> {
1794 let written = self
1795 .inner
1796 .write_batch_idempotent(records, scope, token)
1797 .await?;
1798 let n = written.min(records.len());
1799 let mut buf = self.captured.lock().await;
1800 buf.extend(
1801 records
1802 .iter()
1803 .take(n)
1804 .map(|r| project_record(r, &self.projection)),
1805 );
1806 Ok(written)
1807 }
1808 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1809 self.inner.last_committed_token(scope).await
1810 }
1811 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1812 self.inner.current_schema().await
1813 }
1814 fn supports_schema_evolution(&self) -> bool {
1815 self.inner.supports_schema_evolution()
1816 }
1817 async fn evolve_schema(
1818 &self,
1819 evolution: &faucet_core::SchemaEvolution,
1820 ) -> Result<(), FaucetError> {
1821 self.inner.evolve_schema(evolution).await
1822 }
1823}
1824
1825struct LimitedSink {
1828 inner: Box<dyn Sink>,
1829 remaining: AtomicUsize,
1830}
1831
1832impl LimitedSink {
1833 fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1834 Self {
1835 inner,
1836 remaining: AtomicUsize::new(cap),
1837 }
1838 }
1839}
1840
1841#[async_trait]
1842impl Sink for LimitedSink {
1843 fn connector_name(&self) -> &'static str {
1844 self.inner.connector_name()
1845 }
1846 fn dataset_uri(&self) -> String {
1847 self.inner.dataset_uri()
1848 }
1849 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1850 let remaining = self.remaining.load(Ordering::Relaxed);
1851 if remaining == 0 {
1852 return Ok(0);
1853 }
1854 let take = remaining.min(records.len());
1855 let slice = &records[..take];
1856 let written = self.inner.write_batch(slice).await?;
1857 self.remaining
1858 .fetch_sub(written.min(remaining), Ordering::Relaxed);
1859 Ok(written)
1860 }
1861 async fn flush(&self) -> Result<(), FaucetError> {
1862 self.inner.flush().await
1863 }
1864}
1865
1866struct CountingSink {
1869 seen: AtomicUsize,
1870}
1871
1872impl CountingSink {
1873 fn new() -> Self {
1874 Self {
1875 seen: AtomicUsize::new(0),
1876 }
1877 }
1878}
1879
1880#[async_trait]
1881impl Sink for CountingSink {
1882 fn connector_name(&self) -> &'static str {
1883 "dry-run"
1884 }
1885 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1886 self.seen.fetch_add(records.len(), Ordering::Relaxed);
1887 Ok(records.len())
1888 }
1889}
1890
1891fn value_to_string_brief(v: &Value) -> String {
1894 match v {
1895 Value::String(s) => s.clone(),
1896 other => other.to_string(),
1897 }
1898}
1899
1900#[cfg(test)]
1901mod tests {
1902 use super::*;
1903 use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1904 use crate::expand::expand;
1905 use serde_json::json;
1906
1907 fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
1908 PipelineConfig {
1909 version: 1,
1910 name: Some("test".into()),
1911 vars: None,
1912 auth: None,
1913 pipeline: PipelineSpec {
1914 source: Some(ConnectorSpec {
1915 kind: "csv".into(),
1916 config: json!({"path": input.to_str().unwrap()}),
1917 transforms: None,
1918 inherit_transforms: true,
1919 status: None,
1920 tags: Vec::new(),
1921 }),
1922 sink: Some(ConnectorSpec {
1923 kind: "jsonl".into(),
1924 config: json!({"path": output.to_str().unwrap()}),
1925 transforms: None,
1926 inherit_transforms: true,
1927 status: None,
1928 tags: Vec::new(),
1929 }),
1930 sources: Default::default(),
1931 sinks: Default::default(),
1932 transforms: Vec::new(),
1933 state: None,
1934 dlq: None,
1935 #[cfg(feature = "quality")]
1936 quality: None,
1937 #[cfg(feature = "contract")]
1938 contract: None,
1939 #[cfg(feature = "masking")]
1940 masking: None,
1941 schema: None,
1942 nodes: std::collections::HashMap::new(),
1943 edges: Vec::new(),
1944 },
1945 matrix: Vec::new(),
1946 execution: None,
1947 selection: None,
1948 observability: None,
1949 delivery: faucet_core::DeliveryMode::default(),
1950 resilience: None,
1951 sla: None,
1952 shard: None,
1953 replication: None,
1954 backfill: None,
1955 #[cfg(feature = "schedule")]
1956 schedule: None,
1957 #[cfg(feature = "lineage")]
1958 lineage: None,
1959 #[cfg(feature = "catalog")]
1960 catalog: None,
1961 #[cfg(feature = "notify")]
1962 notifications: Vec::new(),
1963 }
1964 }
1965
1966 #[tokio::test]
1967 async fn empty_matrix_runs_pipeline_once() {
1968 let dir = tempfile::tempdir().unwrap();
1969 let input = dir.path().join("in.csv");
1970 let output = dir.path().join("out.jsonl");
1971 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
1972 let cfg = cfg_csv_to_jsonl(&input, &output);
1973 let nodes = expand(&cfg).unwrap();
1974 let summary = run_expanded(
1975 nodes,
1976 ExecuteOptions {
1977 pipeline_name: "t".into(),
1978 execution: None,
1979 dry_run: false,
1980 limit: None,
1981 state_path_override: None,
1982 shard: None,
1983 auth: Default::default(),
1984 clock: chrono::Utc::now().fixed_offset(),
1985 cancel: None,
1986 resilience: None,
1987 sla: None,
1988 #[cfg(feature = "lineage")]
1989 lineage: None,
1990 #[cfg(feature = "lineage")]
1991 lineage_cfg: None,
1992 #[cfg(feature = "notify")]
1993 notifier: None,
1994 #[cfg(feature = "catalog")]
1995 catalog: None,
1996 },
1997 )
1998 .await
1999 .unwrap();
2000 assert_eq!(summary.invocations.len(), 1);
2001 assert_eq!(summary.invocations[0].records_written, 2);
2002 assert!(!summary.had_failures());
2003 let body = std::fs::read_to_string(&output).unwrap();
2004 assert_eq!(body.lines().count(), 2);
2005 }
2006
2007 #[cfg(feature = "catalog")]
2009 fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
2010 let mut o = opts(name);
2011 o.catalog = Some(handle);
2012 o
2013 }
2014
2015 #[cfg(feature = "catalog")]
2016 #[tokio::test]
2017 async fn catalog_records_schema_timeline_across_two_runs() {
2018 use crate::catalog::CatalogHandle;
2022 use crate::serve::history::RunHistory as _;
2023 use crate::serve::history::catalog::{self, CatalogListFilter};
2024 use crate::serve::history::memory::MemoryHistory;
2025
2026 let dir = tempfile::tempdir().unwrap();
2027 let input = dir.path().join("in.csv");
2028 let output = dir.path().join("out.jsonl");
2029 let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
2030 let handle = CatalogHandle {
2031 store: store.clone(),
2032 run_id: None,
2033 sample_records: 10,
2034 };
2035
2036 std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
2037 let cfg = cfg_csv_to_jsonl(&input, &output);
2038 let nodes = expand(&cfg).unwrap();
2039 let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
2040 .await
2041 .unwrap();
2042 assert!(!summary.had_failures());
2043
2044 std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
2046 let nodes = expand(&cfg).unwrap();
2047 let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
2048 .await
2049 .unwrap();
2050 assert!(!summary.had_failures());
2051
2052 let page = store
2054 .catalog_list_datasets(&CatalogListFilter {
2055 limit: 10,
2056 ..Default::default()
2057 })
2058 .await
2059 .unwrap();
2060 assert_eq!(page.datasets.len(), 2, "source + sink datasets");
2061 for ds in &page.datasets {
2062 let detail = store
2063 .catalog_get_dataset(&ds.id)
2064 .await
2065 .unwrap()
2066 .expect("dataset detail");
2067 assert_eq!(detail.dataset.runs, 2);
2068 assert_eq!(
2069 detail.schema_timeline.len(),
2070 2,
2071 "exactly two timeline entries for {}",
2072 ds.uri
2073 );
2074 assert!(detail.schema_timeline[0].diff.is_none());
2075 let diff = detail.schema_timeline[1]
2076 .diff
2077 .as_ref()
2078 .expect("second version carries a diff");
2079 assert!(
2080 diff["added"]
2081 .as_array()
2082 .unwrap()
2083 .iter()
2084 .any(|c| c["column"] == "email"),
2085 "diff must show the added email column: {diff}"
2086 );
2087 assert_eq!(detail.stats.len(), 2, "one volume point per run");
2088 }
2089 let edges = store.catalog_lineage(None, 5).await.unwrap();
2091 assert_eq!(edges.len(), 1);
2092 assert_eq!(edges[0].runs, 2);
2093 assert_eq!(edges[0].last_records, 2);
2094 assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
2095 }
2096
2097 #[cfg(feature = "catalog")]
2100 struct FailingCatalogStore;
2101
2102 #[cfg(feature = "catalog")]
2103 #[async_trait]
2104 impl crate::serve::history::RunHistory for FailingCatalogStore {
2105 async fn claim_idempotency(
2106 &self,
2107 _: &str,
2108 _: &str,
2109 _: &str,
2110 _: std::time::Duration,
2111 ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
2112 Err(crate::serve::history::HistoryError::Backend("down".into()))
2113 }
2114 async fn upsert(
2115 &self,
2116 _: &crate::serve::history::RunRecord,
2117 ) -> Result<(), crate::serve::history::HistoryError> {
2118 Err(crate::serve::history::HistoryError::Backend("down".into()))
2119 }
2120 async fn get(
2121 &self,
2122 _: &str,
2123 ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
2124 {
2125 Err(crate::serve::history::HistoryError::Backend("down".into()))
2126 }
2127 async fn list(
2128 &self,
2129 _: &crate::serve::history::ListFilter,
2130 ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
2131 Err(crate::serve::history::HistoryError::Backend("down".into()))
2132 }
2133 async fn delete(
2134 &self,
2135 _: &str,
2136 ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
2137 {
2138 Err(crate::serve::history::HistoryError::Backend("down".into()))
2139 }
2140 async fn purge_expired(
2141 &self,
2142 _: std::time::Duration,
2143 ) -> Result<usize, crate::serve::history::HistoryError> {
2144 Err(crate::serve::history::HistoryError::Backend("down".into()))
2145 }
2146 async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
2147 Err(crate::serve::history::HistoryError::Backend("down".into()))
2148 }
2149 async fn catalog_record(
2150 &self,
2151 _: &crate::serve::history::catalog::CatalogUpdate,
2152 ) -> Result<(), crate::serve::history::HistoryError> {
2153 Err(crate::serve::history::HistoryError::Backend(
2154 "catalog write refused".into(),
2155 ))
2156 }
2157 fn degraded(&self) -> bool {
2158 false
2159 }
2160 }
2161
2162 #[cfg(feature = "catalog")]
2163 #[tokio::test]
2164 async fn catalog_write_failure_never_fails_the_run() {
2165 use crate::catalog::CatalogHandle;
2168 let dir = tempfile::tempdir().unwrap();
2169 let input = dir.path().join("in.csv");
2170 let output = dir.path().join("out.jsonl");
2171 std::fs::write(&input, "name\nalice\n").unwrap();
2172 let cfg = cfg_csv_to_jsonl(&input, &output);
2173 let nodes = expand(&cfg).unwrap();
2174 let handle = CatalogHandle {
2175 store: Arc::new(FailingCatalogStore),
2176 run_id: None,
2177 sample_records: 10,
2178 };
2179 let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
2180 .await
2181 .unwrap();
2182 assert!(
2183 !summary.had_failures(),
2184 "catalog failure must not fail the run"
2185 );
2186 assert_eq!(summary.invocations[0].records_written, 1);
2187 assert_eq!(
2188 std::fs::read_to_string(&output).unwrap().lines().count(),
2189 1,
2190 "sink output written despite the catalog error"
2191 );
2192 }
2193
2194 #[tokio::test]
2195 async fn matrix_two_independent_roots_both_run() {
2196 let dir = tempfile::tempdir().unwrap();
2198 let csv_a = dir.path().join("a.csv");
2199 let csv_b = dir.path().join("b.csv");
2200 let out_a = dir.path().join("a.jsonl");
2201 let out_b = dir.path().join("b.jsonl");
2202 std::fs::write(&csv_a, "name\nalice\n").unwrap();
2203 std::fs::write(&csv_b, "name\nbob\n").unwrap();
2204
2205 let yaml = format!(
2206 r#"version: 1
2207pipeline:
2208 source: {{ type: csv, config: {{ path: {a} }} }}
2209 sink: {{ type: jsonl, config: {{ path: {out_a} }} }}
2210matrix:
2211 - id: rowA
2212 - id: rowB
2213 source: {{ config: {{ path: {b} }} }}
2214 sink: {{ config: {{ path: {out_b} }} }}
2215"#,
2216 a = csv_a.display(),
2217 b = csv_b.display(),
2218 out_a = out_a.display(),
2219 out_b = out_b.display(),
2220 );
2221 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2222 let nodes = expand(&cfg).unwrap();
2223 let summary = run_expanded(
2224 nodes,
2225 ExecuteOptions {
2226 pipeline_name: "matrix".into(),
2227 execution: None,
2228 dry_run: false,
2229 limit: None,
2230 state_path_override: None,
2231 shard: None,
2232 auth: Default::default(),
2233 clock: chrono::Utc::now().fixed_offset(),
2234 cancel: None,
2235 resilience: None,
2236 sla: None,
2237 #[cfg(feature = "lineage")]
2238 lineage: None,
2239 #[cfg(feature = "lineage")]
2240 lineage_cfg: None,
2241 #[cfg(feature = "notify")]
2242 notifier: None,
2243 #[cfg(feature = "catalog")]
2244 catalog: None,
2245 },
2246 )
2247 .await
2248 .unwrap();
2249 assert_eq!(summary.invocations.len(), 2);
2250 assert!(out_a.exists());
2251 assert!(out_b.exists());
2252 }
2253
2254 #[tokio::test]
2255 async fn dag_child_fans_out_per_parent_record() {
2256 let dir = tempfile::tempdir().unwrap();
2259 let parent_csv = dir.path().join("parents.csv");
2260 let child_csv = dir.path().join("child.csv");
2261 std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
2262 std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
2263 let parent_out = dir.path().join("parents.jsonl");
2264 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2265
2266 let yaml = format!(
2267 r#"version: 1
2268pipeline:
2269 source: {{ type: csv, config: {{ path: {parent} }} }}
2270 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2271matrix:
2272 - id: parents
2273 - id: child
2274 parent: parents
2275 source: {{ config: {{ path: {child} }} }}
2276 sink: {{ config: {{ path: "{child_out}" }} }}
2277"#,
2278 parent = parent_csv.display(),
2279 parent_out = parent_out.display(),
2280 child = child_csv.display(),
2281 child_out = child_out_pattern.display(),
2282 );
2283 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2284 let nodes = expand(&cfg).unwrap();
2285 let summary = run_expanded(
2286 nodes,
2287 ExecuteOptions {
2288 pipeline_name: "dagtest".into(),
2289 execution: None,
2290 dry_run: false,
2291 limit: None,
2292 state_path_override: None,
2293 shard: None,
2294 auth: Default::default(),
2295 clock: chrono::Utc::now().fixed_offset(),
2296 cancel: None,
2297 resilience: None,
2298 sla: None,
2299 #[cfg(feature = "lineage")]
2300 lineage: None,
2301 #[cfg(feature = "lineage")]
2302 lineage_cfg: None,
2303 #[cfg(feature = "notify")]
2304 notifier: None,
2305 #[cfg(feature = "catalog")]
2306 catalog: None,
2307 },
2308 )
2309 .await
2310 .unwrap();
2311
2312 assert_eq!(summary.invocations.len(), 3);
2314 assert!(!summary.had_failures(), "{:?}", summary);
2315 assert!(dir.path().join("child-1.jsonl").exists());
2316 assert!(dir.path().join("child-2.jsonl").exists());
2317 }
2318
2319 #[tokio::test]
2320 async fn depends_on_root_runs_after_dependency() {
2321 let dir = tempfile::tempdir().unwrap();
2325 let input = dir.path().join("in.csv");
2326 let mid = dir.path().join("mid.csv");
2327 let out = dir.path().join("out.jsonl");
2328 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2329
2330 let yaml = format!(
2331 r#"version: 1
2332pipeline:
2333 source: {{ type: csv, config: {{ path: {input} }} }}
2334 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2335matrix:
2336 - id: stage
2337 sink: {{ type: csv, config: {{ path: {mid} }} }}
2338 - id: load
2339 depends_on: [stage]
2340 source: {{ config: {{ path: {mid} }} }}
2341"#,
2342 input = input.display(),
2343 mid = mid.display(),
2344 out = out.display(),
2345 );
2346 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2347 let nodes = expand(&cfg).unwrap();
2348 let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
2349 assert_eq!(summary.invocations.len(), 2, "{summary:?}");
2350 assert!(!summary.had_failures(), "{summary:?}");
2351 let load = summary
2352 .invocations
2353 .iter()
2354 .find(|i| i.row_id == "load")
2355 .unwrap();
2356 assert_eq!(load.records_written, 2);
2357 let written = std::fs::read_to_string(&out).unwrap();
2358 assert_eq!(written.lines().count(), 2);
2359 }
2360
2361 #[tokio::test]
2362 async fn diamond_dependency_waits_for_all_prerequisites() {
2363 let dir = tempfile::tempdir().unwrap();
2366 let input = dir.path().join("in.csv");
2367 let mid_a = dir.path().join("mid_a.csv");
2368 let mid_b = dir.path().join("mid_b.csv");
2369 let out = dir.path().join("out.jsonl");
2370 std::fs::write(&input, "name\nalice\n").unwrap();
2371
2372 let yaml = format!(
2373 r#"version: 1
2374pipeline:
2375 source: {{ type: csv, config: {{ path: {input} }} }}
2376 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2377matrix:
2378 - id: a
2379 sink: {{ type: csv, config: {{ path: {mid_a} }} }}
2380 - id: b
2381 sink: {{ type: csv, config: {{ path: {mid_b} }} }}
2382 - id: c
2383 depends_on: [a, b]
2384 source: {{ config: {{ path: {mid_a} }} }}
2385"#,
2386 input = input.display(),
2387 mid_a = mid_a.display(),
2388 mid_b = mid_b.display(),
2389 out = out.display(),
2390 );
2391 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2392 let nodes = expand(&cfg).unwrap();
2393 let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
2394 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2395 assert!(!summary.had_failures(), "{summary:?}");
2396 assert!(mid_b.exists(), "b must have run before c became ready");
2397 assert!(out.exists());
2398 }
2399
2400 #[tokio::test]
2401 async fn failed_dependency_skips_dependent() {
2402 let dir = tempfile::tempdir().unwrap();
2405 let good_input = dir.path().join("good.csv");
2406 let out = dir.path().join("out.jsonl");
2407 std::fs::write(&good_input, "name\nalice\n").unwrap();
2408
2409 let yaml = format!(
2410 r#"version: 1
2411pipeline:
2412 source: {{ type: csv, config: {{ path: {good} }} }}
2413 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2414matrix:
2415 - id: stage
2416 source: {{ config: {{ path: {missing} }} }}
2417 - id: load
2418 depends_on: [stage]
2419"#,
2420 good = good_input.display(),
2421 missing = dir.path().join("nonexistent.csv").display(),
2422 out = out.display(),
2423 );
2424 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2425 let nodes = expand(&cfg).unwrap();
2426 let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
2427 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2428 assert_eq!(summary.invocations[0].row_id, "stage");
2429 assert!(summary.invocations[0].error.is_some());
2430 assert!(
2431 !out.exists(),
2432 "dependent row must not run after its dependency failed"
2433 );
2434 }
2435
2436 #[tokio::test]
2437 async fn dependency_on_skipped_row_cascades() {
2438 let dir = tempfile::tempdir().unwrap();
2441 let good_input = dir.path().join("good.csv");
2442 let out = dir.path().join("q.jsonl");
2443 std::fs::write(&good_input, "id\n1\n").unwrap();
2444
2445 let yaml = format!(
2446 r#"version: 1
2447pipeline:
2448 source: {{ type: csv, config: {{ path: {good} }} }}
2449 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2450matrix:
2451 - id: p
2452 source: {{ config: {{ path: {missing} }} }}
2453 - id: c
2454 parent: p
2455 - id: q
2456 depends_on: [c]
2457"#,
2458 good = good_input.display(),
2459 missing = dir.path().join("nonexistent.csv").display(),
2460 out = out.display(),
2461 );
2462 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2463 let nodes = expand(&cfg).unwrap();
2464 let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
2465 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2466 assert_eq!(summary.invocations[0].row_id, "p");
2467 assert!(summary.invocations[0].error.is_some());
2468 assert!(
2469 !out.exists(),
2470 "q must be skipped when its dependency was skipped"
2471 );
2472 }
2473
2474 #[tokio::test]
2475 async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
2476 let dir = tempfile::tempdir().unwrap();
2488 let good_csv = dir.path().join("good.csv");
2489 std::fs::write(&good_csv, "x\n1\n").unwrap();
2490 let good_out = dir.path().join("good.jsonl");
2491 let bad_sink_dir = dir.path().to_path_buf();
2492
2493 let yaml = format!(
2494 r#"version: 1
2495pipeline:
2496 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2497 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2498matrix:
2499 - id: bad
2500 sink: {{ config: {{ path: {bad_dir} }} }}
2501 - id: good
2502execution:
2503 max_concurrent: 1
2504 on_error: stop
2505"#,
2506 good_csv = good_csv.display(),
2507 good_out = good_out.display(),
2508 bad_dir = bad_sink_dir.display(),
2509 );
2510 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2511 let nodes = expand(&cfg).unwrap();
2512 let summary = run_expanded(
2513 nodes,
2514 ExecuteOptions {
2515 pipeline_name: "stoptest".into(),
2516 execution: cfg.execution.clone(),
2517 dry_run: false,
2518 limit: None,
2519 state_path_override: None,
2520 shard: None,
2521 auth: Default::default(),
2522 clock: chrono::Utc::now().fixed_offset(),
2523 cancel: None,
2524 resilience: None,
2525 sla: None,
2526 #[cfg(feature = "lineage")]
2527 lineage: None,
2528 #[cfg(feature = "lineage")]
2529 lineage_cfg: None,
2530 #[cfg(feature = "notify")]
2531 notifier: None,
2532 #[cfg(feature = "catalog")]
2533 catalog: None,
2534 },
2535 )
2536 .await
2537 .unwrap();
2538
2539 assert!(summary.had_failures(), "the failing root must be reported");
2541
2542 let bad: Vec<_> = summary
2544 .invocations
2545 .iter()
2546 .filter(|o| o.row_id == "bad")
2547 .collect();
2548 assert_eq!(bad.len(), 1, "bad must run exactly once");
2549 assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
2550
2551 assert!(
2553 summary.invocations.len() <= 2,
2554 "at most the two roots may run, got {:?}",
2555 summary.invocations
2556 );
2557
2558 let good_wrote = summary
2565 .invocations
2566 .iter()
2567 .find(|o| o.row_id == "good" && o.error.is_none())
2568 .map(|o| o.records_written)
2569 .unwrap_or(0);
2570 if good_wrote > 0 {
2571 assert!(
2572 good_out.exists(),
2573 "a good that wrote records must have produced its output file"
2574 );
2575 }
2576 }
2577
2578 #[tokio::test]
2579 async fn invalid_pipeline_name_with_state_errors_up_front() {
2580 let dir = tempfile::tempdir().unwrap();
2584 let input = dir.path().join("in.csv");
2585 let output = dir.path().join("out.jsonl");
2586 std::fs::write(&input, "name\nalice\n").unwrap();
2587 let yaml = format!(
2588 r#"version: 1
2589pipeline:
2590 source: {{ type: csv, config: {{ path: {input} }} }}
2591 sink: {{ type: jsonl, config: {{ path: {output} }} }}
2592 state: {{ type: memory }}
2593"#,
2594 input = input.display(),
2595 output = output.display(),
2596 );
2597 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2598 let nodes = expand(&cfg).unwrap();
2599 let err = run_expanded(
2600 nodes,
2601 ExecuteOptions {
2602 pipeline_name: "bad name".into(), execution: None,
2604 dry_run: false,
2605 limit: None,
2606 state_path_override: None,
2607 shard: None,
2608 auth: Default::default(),
2609 clock: chrono::Utc::now().fixed_offset(),
2610 cancel: None,
2611 resilience: None,
2612 sla: None,
2613 #[cfg(feature = "lineage")]
2614 lineage: None,
2615 #[cfg(feature = "lineage")]
2616 lineage_cfg: None,
2617 #[cfg(feature = "notify")]
2618 notifier: None,
2619 #[cfg(feature = "catalog")]
2620 catalog: None,
2621 },
2622 )
2623 .await
2624 .expect_err("an invalid pipeline name must be rejected up front when state is configured");
2625 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2626 }
2627
2628 #[tokio::test]
2629 async fn invalid_parent_key_value_with_state_errors_up_front() {
2630 let dir = tempfile::tempdir().unwrap();
2633 let parent_csv = dir.path().join("parents.csv");
2634 let child_csv = dir.path().join("child.csv");
2635 std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
2637 std::fs::write(&child_csv, "x\nA\n").unwrap();
2638 let parent_out = dir.path().join("parents.jsonl");
2639 let child_out = dir.path().join("child.jsonl");
2640 let yaml = format!(
2641 r#"version: 1
2642pipeline:
2643 source: {{ type: csv, config: {{ path: {parent} }} }}
2644 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2645 state: {{ type: memory }}
2646matrix:
2647 - id: parents
2648 - id: child
2649 parent: parents
2650 source: {{ config: {{ path: {child} }} }}
2651 sink: {{ config: {{ path: {child_out} }} }}
2652"#,
2653 parent = parent_csv.display(),
2654 parent_out = parent_out.display(),
2655 child = child_csv.display(),
2656 child_out = child_out.display(),
2657 );
2658 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2659 let nodes = expand(&cfg).unwrap();
2660 let err = run_expanded(
2661 nodes,
2662 ExecuteOptions {
2663 pipeline_name: "ok".into(),
2664 execution: None,
2665 dry_run: false,
2666 limit: None,
2667 state_path_override: None,
2668 shard: None,
2669 auth: Default::default(),
2670 clock: chrono::Utc::now().fixed_offset(),
2671 cancel: None,
2672 resilience: None,
2673 sla: None,
2674 #[cfg(feature = "lineage")]
2675 lineage: None,
2676 #[cfg(feature = "lineage")]
2677 lineage_cfg: None,
2678 #[cfg(feature = "notify")]
2679 notifier: None,
2680 #[cfg(feature = "catalog")]
2681 catalog: None,
2682 },
2683 )
2684 .await
2685 .expect_err(
2686 "an illegal parent-key value must be rejected up front when state is configured",
2687 );
2688 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2689 }
2690
2691 #[tokio::test]
2692 async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
2693 let dir = tempfile::tempdir().unwrap();
2702 let bad_sink_dir = dir.path().to_path_buf();
2703 let good_csv = dir.path().join("good.csv");
2706 std::fs::write(&good_csv, "x\n1\n").unwrap();
2707 let yaml = format!(
2713 r#"version: 1
2714pipeline:
2715 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2716 sink: {{ type: jsonl, config: {{ path: {bad_dir} }} }}
2717matrix:
2718 - id: bad
2719 - id: good_a
2720 - id: good_b
2721execution:
2722 max_concurrent: 3
2723 on_error: stop
2724"#,
2725 good_csv = good_csv.display(),
2726 bad_dir = bad_sink_dir.display(),
2727 );
2728 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2729 let nodes = expand(&cfg).unwrap();
2730 let summary = run_expanded(
2731 nodes,
2732 ExecuteOptions {
2733 pipeline_name: "stop_parallel".into(),
2734 execution: cfg.execution.clone(),
2735 dry_run: false,
2736 limit: None,
2737 state_path_override: None,
2738 shard: None,
2739 auth: Default::default(),
2740 clock: chrono::Utc::now().fixed_offset(),
2741 cancel: None,
2742 resilience: None,
2743 sla: None,
2744 #[cfg(feature = "lineage")]
2745 lineage: None,
2746 #[cfg(feature = "lineage")]
2747 lineage_cfg: None,
2748 #[cfg(feature = "notify")]
2749 notifier: None,
2750 #[cfg(feature = "catalog")]
2751 catalog: None,
2752 },
2753 )
2754 .await
2755 .unwrap();
2756
2757 assert!(
2762 summary.had_failures(),
2763 "summary should record at least one failure: {summary:?}"
2764 );
2765 assert!(
2766 summary.invocations[0].error.is_some(),
2767 "first outcome must be the failure that triggered stop: {summary:?}"
2768 );
2769 for inv in &summary.invocations {
2773 assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
2774 }
2775 }
2776
2777 #[tokio::test]
2778 async fn on_error_continue_skips_failed_subtree_only() {
2779 let dir = tempfile::tempdir().unwrap();
2781 let good_csv = dir.path().join("good.csv");
2782 std::fs::write(&good_csv, "x\n1\n").unwrap();
2783 let good_out = dir.path().join("good.jsonl");
2784
2785 let yaml = format!(
2786 r#"version: 1
2787pipeline:
2788 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2789 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2790matrix:
2791 - id: bad
2792 sink: {{ config: {{ path: {bad_dir} }} }}
2793 - id: good
2794"#,
2795 good_csv = good_csv.display(),
2796 good_out = good_out.display(),
2797 bad_dir = dir.path().display(),
2798 );
2799 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2800 let nodes = expand(&cfg).unwrap();
2801 let summary = run_expanded(
2802 nodes,
2803 ExecuteOptions {
2804 pipeline_name: "continuetest".into(),
2805 execution: None,
2806 dry_run: false,
2807 limit: None,
2808 state_path_override: None,
2809 shard: None,
2810 auth: Default::default(),
2811 clock: chrono::Utc::now().fixed_offset(),
2812 cancel: None,
2813 resilience: None,
2814 sla: None,
2815 #[cfg(feature = "lineage")]
2816 lineage: None,
2817 #[cfg(feature = "lineage")]
2818 lineage_cfg: None,
2819 #[cfg(feature = "notify")]
2820 notifier: None,
2821 #[cfg(feature = "catalog")]
2822 catalog: None,
2823 },
2824 )
2825 .await
2826 .unwrap();
2827 assert_eq!(summary.invocations.len(), 2);
2828 assert_eq!(summary.failure_count(), 1);
2829 let good_outcome = summary
2830 .invocations
2831 .iter()
2832 .find(|i| i.row_id == "good")
2833 .unwrap();
2834 assert!(good_outcome.error.is_none());
2835 }
2836
2837 #[test]
2840 fn split_path_splits_on_dots() {
2841 assert_eq!(split_path("id"), vec!["id".to_string()]);
2842 assert_eq!(
2843 split_path("user.name"),
2844 vec!["user".to_string(), "name".to_string()]
2845 );
2846 }
2847
2848 #[test]
2849 fn minimal_paths_drops_descendants_of_kept_ancestors() {
2850 let paths = vec![
2851 vec!["user".into(), "name".into()],
2852 vec!["user".into()],
2853 vec!["id".into()],
2854 vec!["id".into()],
2855 ];
2856 let min = minimal_paths(paths);
2857 assert!(min.contains(&vec!["user".to_string()]));
2858 assert!(min.contains(&vec!["id".to_string()]));
2859 assert!(
2860 !min.contains(&vec!["user".to_string(), "name".to_string()]),
2861 "user.name must be dropped — covered by user"
2862 );
2863 assert_eq!(min.len(), 2);
2864 }
2865
2866 #[test]
2867 fn project_full_clones_whole_record() {
2868 let r = json!({"a": 1, "b": {"c": 2}});
2869 assert_eq!(project_record(&r, &Projection::Full), r);
2870 }
2871
2872 #[test]
2873 fn project_keeps_only_referenced_paths() {
2874 let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
2875 let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
2876 let got = project_record(&r, &p);
2877 assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
2878 assert!(got.get("blob").is_none());
2879 assert!(got["user"].get("age").is_none());
2880 }
2881
2882 #[test]
2883 fn project_array_index_path_resolves_same_as_original() {
2884 let r = json!({"tags": ["x", "y", "z"]});
2885 let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
2886 let got = project_record(&r, &p);
2887 assert_eq!(got, json!({"tags": {"0": "x"}}));
2888 assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
2889 assert_eq!(
2890 resolve_parent_key(&got, "tags.0"),
2891 resolve_parent_key(&r, "tags.0"),
2892 "reduced tree must resolve the same value as the original"
2893 );
2894 }
2895
2896 #[test]
2897 fn project_numeric_object_key_resolves_same_as_original() {
2898 let r = json!({"data": {"0": "x", "1": "y"}});
2903 let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
2904 let got = project_record(&r, &p);
2905 assert_eq!(got, json!({"data": {"0": "x"}}));
2906 assert_eq!(
2907 resolve_parent_key(&got, "data.0"),
2908 resolve_parent_key(&r, "data.0"),
2909 "numeric object-key path must resolve identically on the reduced tree"
2910 );
2911 }
2912
2913 #[test]
2914 fn project_missing_path_is_omitted() {
2915 let r = json!({"id": 1});
2916 let p = Projection::Paths(vec![vec!["nope".into()]]);
2917 assert_eq!(project_record(&r, &p), json!({}));
2918 }
2919
2920 #[test]
2921 fn build_projections_unions_parent_key_and_refs() {
2922 use crate::config::ConnectorSpec;
2923 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2924
2925 fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
2926 ExpandedNode {
2927 id: id.into(),
2928 row_index: 0,
2929 role: NodeRole::Child {
2930 parent_id: parent.into(),
2931 parent_key: parent_key.into(),
2932 },
2933 source: ConnectorSpec {
2934 kind: "csv".into(),
2935 config: json!({}),
2936 transforms: None,
2937 inherit_transforms: true,
2938 status: None,
2939 tags: Vec::new(),
2940 },
2941 sink: ConnectorSpec {
2942 kind: "jsonl".into(),
2943 config: json!({}),
2944 transforms: None,
2945 inherit_transforms: true,
2946 status: None,
2947 tags: Vec::new(),
2948 },
2949 transforms: Vec::new(),
2950 state: None,
2951 dlq: None,
2952 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2953 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
2954 #[cfg(feature = "quality")]
2955 quality: None,
2956 #[cfg(feature = "contract")]
2957 contract: None,
2958 #[cfg(feature = "masking")]
2959 masking: None,
2960 sink_ref: "default".into(),
2961 schema: None,
2962 depends_on: Vec::new(),
2963 status: crate::config::SourceStatus::Active,
2964 tags: Vec::new(),
2965 deferred_refs: refs
2966 .iter()
2967 .map(|(rid, p)| DeferredRef {
2968 referenced_id: (*rid).into(),
2969 dotted_path: (*p).into(),
2970 token: format!("${{{rid}.{p}}}"),
2971 })
2972 .collect(),
2973 source_override: None,
2974 }
2975 }
2976
2977 let c1 = child("c1", "p", "id", &[("p", "user.name")]);
2978 let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
2979 let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
2980 let children_of =
2981 HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
2982
2983 let projs = build_projections(&nodes_by_id, &children_of);
2984 let p = projs.get("p").expect("projection for p");
2985 match &**p {
2986 Projection::Paths(paths) => {
2987 assert!(paths.contains(&vec!["id".to_string()]));
2988 assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
2989 assert!(paths.contains(&vec!["email".to_string()]));
2990 assert!(
2991 !paths.iter().any(|p| p == &vec!["x".to_string()]),
2992 "a ref to a different parent must not be captured under p"
2993 );
2994 }
2995 Projection::Full => panic!("expected Paths, got Full"),
2996 }
2997 }
2998
2999 #[test]
3000 fn build_projections_whole_record_ref_is_full() {
3001 use crate::config::ConnectorSpec;
3002 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3003 let c = ExpandedNode {
3004 id: "c".into(),
3005 row_index: 0,
3006 role: NodeRole::Child {
3007 parent_id: "p".into(),
3008 parent_key: "id".into(),
3009 },
3010 source: ConnectorSpec {
3011 kind: "csv".into(),
3012 config: json!({}),
3013 transforms: None,
3014 inherit_transforms: true,
3015 status: None,
3016 tags: Vec::new(),
3017 },
3018 sink: ConnectorSpec {
3019 kind: "jsonl".into(),
3020 config: json!({}),
3021 transforms: None,
3022 inherit_transforms: true,
3023 status: None,
3024 tags: Vec::new(),
3025 },
3026 transforms: Vec::new(),
3027 state: None,
3028 dlq: None,
3029 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3030 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3031 #[cfg(feature = "quality")]
3032 quality: None,
3033 #[cfg(feature = "contract")]
3034 contract: None,
3035 #[cfg(feature = "masking")]
3036 masking: None,
3037 sink_ref: "default".into(),
3038 schema: None,
3039 depends_on: Vec::new(),
3040 status: crate::config::SourceStatus::Active,
3041 tags: Vec::new(),
3042 deferred_refs: vec![DeferredRef {
3043 referenced_id: "p".into(),
3044 dotted_path: "".into(),
3045 token: "${p}".into(),
3046 }],
3047 source_override: None,
3048 };
3049 let nodes_by_id = HashMap::from([("c".to_string(), c)]);
3050 let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
3051 let projs = build_projections(&nodes_by_id, &children_of);
3052 assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
3053 }
3054
3055 fn opts(name: &str) -> ExecuteOptions {
3057 ExecuteOptions {
3058 pipeline_name: name.into(),
3059 execution: None,
3060 dry_run: false,
3061 limit: None,
3062 state_path_override: None,
3063 shard: None,
3064 auth: Default::default(),
3065 clock: chrono::Utc::now().fixed_offset(),
3066 cancel: None,
3067 resilience: None,
3068 sla: None,
3069 #[cfg(feature = "lineage")]
3070 lineage: None,
3071 #[cfg(feature = "lineage")]
3072 lineage_cfg: None,
3073 #[cfg(feature = "notify")]
3074 notifier: None,
3075 #[cfg(feature = "catalog")]
3076 catalog: None,
3077 }
3078 }
3079
3080 #[tokio::test]
3081 async fn dry_run_counts_records_without_writing_sink_file() {
3082 let dir = tempfile::tempdir().unwrap();
3085 let input = dir.path().join("in.csv");
3086 let output = dir.path().join("out.jsonl");
3087 std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
3088 let cfg = cfg_csv_to_jsonl(&input, &output);
3089 let nodes = expand(&cfg).unwrap();
3090 let mut o = opts("dry");
3091 o.dry_run = true;
3092 let summary = run_expanded(nodes, o).await.unwrap();
3093 assert_eq!(summary.invocations.len(), 1);
3094 assert_eq!(summary.invocations[0].records_written, 3);
3095 assert!(!summary.had_failures());
3096 assert!(
3097 !output.exists(),
3098 "dry-run must not create the real sink file"
3099 );
3100 }
3101
3102 #[tokio::test]
3103 async fn read_only_state_store_drops_writes_keeps_reads() {
3104 let inner = Arc::new(faucet_core::MemoryStateStore::new()) as Arc<dyn StateStore>;
3107 inner.put("k", &json!("v0")).await.unwrap();
3108 let ro = ReadOnlyStateStore {
3109 inner: inner.clone(),
3110 };
3111 assert_eq!(ro.get("k").await.unwrap(), Some(json!("v0")));
3112 ro.put("k", &json!("advanced")).await.unwrap();
3114 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3115 ro.delete("k").await.unwrap();
3117 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3118 }
3119
3120 #[tokio::test]
3121 async fn dry_run_with_state_does_not_persist_bookmark() {
3122 let dir = tempfile::tempdir().unwrap();
3126 let input = dir.path().join("in.csv");
3127 let output = dir.path().join("out.jsonl");
3128 let state_dir = dir.path().join("state");
3129 std::fs::create_dir_all(&state_dir).unwrap();
3130 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3131 let cfg = cfg_csv_to_jsonl(&input, &output);
3132 let nodes = expand(&cfg).unwrap();
3133 let mut o = opts("drystate");
3134 o.dry_run = true;
3135 o.state_path_override = Some(state_dir.clone());
3136 let summary = run_expanded(nodes, o).await.unwrap();
3137 assert!(!summary.had_failures());
3138 assert!(!output.exists(), "dry-run must not write the sink file");
3139 let persisted: Vec<_> = std::fs::read_dir(&state_dir)
3142 .unwrap()
3143 .filter_map(Result::ok)
3144 .collect();
3145 assert!(
3146 persisted.is_empty(),
3147 "dry-run must not persist any bookmark file, found: {persisted:?}"
3148 );
3149 }
3150
3151 #[tokio::test]
3152 async fn limit_caps_records_written_across_the_run() {
3153 let dir = tempfile::tempdir().unwrap();
3155 let input = dir.path().join("in.csv");
3156 let output = dir.path().join("out.jsonl");
3157 std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
3158 let cfg = cfg_csv_to_jsonl(&input, &output);
3159 let nodes = expand(&cfg).unwrap();
3160 let mut o = opts("lim");
3161 o.limit = Some(2);
3162 let summary = run_expanded(nodes, o).await.unwrap();
3163 assert_eq!(summary.invocations[0].records_written, 2);
3164 let body = std::fs::read_to_string(&output).unwrap();
3165 assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
3166 }
3167
3168 #[tokio::test]
3169 async fn duplicate_state_key_among_siblings_is_rejected() {
3170 let dir = tempfile::tempdir().unwrap();
3174 let parent_csv = dir.path().join("parents.csv");
3175 let child_csv = dir.path().join("child.csv");
3176 std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
3178 std::fs::write(&child_csv, "x\nA\n").unwrap();
3179 let parent_out = dir.path().join("parents.jsonl");
3180 let child_out = dir.path().join("child.jsonl");
3181 let yaml = format!(
3182 r#"version: 1
3183pipeline:
3184 source: {{ type: csv, config: {{ path: {parent} }} }}
3185 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3186 state: {{ type: memory }}
3187matrix:
3188 - id: parents
3189 - id: child
3190 parent: parents
3191 source: {{ config: {{ path: {child} }} }}
3192 sink: {{ config: {{ path: {child_out} }} }}
3193"#,
3194 parent = parent_csv.display(),
3195 parent_out = parent_out.display(),
3196 child = child_csv.display(),
3197 child_out = child_out.display(),
3198 );
3199 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3200 let nodes = expand(&cfg).unwrap();
3201 let err = run_expanded(nodes, opts("dupkey"))
3202 .await
3203 .expect_err("colliding sibling state keys must be rejected");
3204 match err {
3205 CliError::DuplicateStateKey { id, state_key } => {
3206 assert_eq!(id, "child");
3207 assert_eq!(state_key, "dupkey::child::dup");
3208 }
3209 other => panic!("expected DuplicateStateKey, got {other:?}"),
3210 }
3211 }
3212
3213 #[tokio::test]
3214 async fn state_path_override_writes_bookmark_file() {
3215 let dir = tempfile::tempdir().unwrap();
3220 let input = dir.path().join("in.csv");
3221 let output = dir.path().join("out.jsonl");
3222 let state_dir = dir.path().join("state");
3223 std::fs::write(&input, "name\nalice\n").unwrap();
3224 let cfg = cfg_csv_to_jsonl(&input, &output);
3225 let nodes = expand(&cfg).unwrap();
3226 let mut o = opts("statepath");
3227 o.state_path_override = Some(state_dir.clone());
3228 let summary = run_expanded(nodes, o).await.unwrap();
3229 assert!(!summary.had_failures());
3230 assert_eq!(summary.invocations[0].records_written, 1);
3234 }
3235
3236 #[tokio::test]
3237 async fn build_dlq_config_maps_spec_fields() {
3238 use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
3239 let dir = tempfile::tempdir().unwrap();
3240 let dlq_out = dir.path().join("dlq.jsonl");
3241 let spec = DlqSpec {
3242 sink: ConnectorSpec {
3243 kind: "jsonl".into(),
3244 config: json!({ "path": dlq_out.to_str().unwrap() }),
3245 transforms: None,
3246 inherit_transforms: true,
3247 status: None,
3248 tags: Vec::new(),
3249 },
3250 on_batch_error: OnBatchErrorSpec::DlqAll,
3251 max_failures_per_page: Some(7),
3252 max_failures_total: Some(42),
3253 include_original_payload: false,
3254 };
3255 let cfg = build_dlq_config(&spec).await.unwrap();
3256 assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
3257 assert_eq!(cfg.max_failures_per_page, Some(7));
3258 assert_eq!(cfg.max_failures_total, Some(42));
3259 assert!(!cfg.include_original_payload);
3260 }
3261
3262 #[tokio::test]
3263 async fn build_state_for_node_arms() {
3264 let dir = tempfile::tempdir().unwrap();
3265
3266 let node = stub_node(None);
3268 assert!(build_state_for_node(&node, None).await.unwrap().is_none());
3269
3270 let p = dir.path().join("s1");
3272 assert!(
3273 build_state_for_node(&node, Some(&p))
3274 .await
3275 .unwrap()
3276 .is_some()
3277 );
3278
3279 let node_mem = stub_node(Some(crate::config::StateStoreSpec {
3281 kind: "memory".into(),
3282 config: json!({}),
3283 }));
3284 assert!(
3285 build_state_for_node(&node_mem, None)
3286 .await
3287 .unwrap()
3288 .is_some()
3289 );
3290
3291 let node_file = stub_node(Some(crate::config::StateStoreSpec {
3293 kind: "file".into(),
3294 config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
3295 }));
3296 let p2 = dir.path().join("override2");
3297 assert!(
3298 build_state_for_node(&node_file, Some(&p2))
3299 .await
3300 .unwrap()
3301 .is_some()
3302 );
3303
3304 let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
3307 kind: "memory".into(),
3308 config: json!({}),
3309 }));
3310 let p3 = dir.path().join("override3");
3311 assert!(
3312 build_state_for_node(&node_mem2, Some(&p3))
3313 .await
3314 .unwrap()
3315 .is_some()
3316 );
3317 }
3318
3319 fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
3321 use crate::config::ConnectorSpec;
3322 ExpandedNode {
3323 id: "n".into(),
3324 row_index: 0,
3325 role: NodeRole::Root,
3326 source: ConnectorSpec {
3327 kind: "csv".into(),
3328 config: json!({}),
3329 transforms: None,
3330 inherit_transforms: true,
3331 status: None,
3332 tags: Vec::new(),
3333 },
3334 sink: ConnectorSpec {
3335 kind: "jsonl".into(),
3336 config: json!({}),
3337 transforms: None,
3338 inherit_transforms: true,
3339 status: None,
3340 tags: Vec::new(),
3341 },
3342 transforms: Vec::new(),
3343 state,
3344 dlq: None,
3345 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3346 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3347 #[cfg(feature = "quality")]
3348 quality: None,
3349 #[cfg(feature = "contract")]
3350 contract: None,
3351 #[cfg(feature = "masking")]
3352 masking: None,
3353 sink_ref: "default".into(),
3354 schema: None,
3355 depends_on: Vec::new(),
3356 status: crate::config::SourceStatus::Active,
3357 tags: Vec::new(),
3358 deferred_refs: Vec::new(),
3359 source_override: None,
3360 }
3361 }
3362
3363 #[tokio::test]
3364 async fn state_key_override_delegates_and_overrides_key() {
3365 let dir = tempfile::tempdir().unwrap();
3367 let input = dir.path().join("in.csv");
3368 std::fs::write(&input, "name\nz\n").unwrap();
3369 let inner = build_source(
3370 "csv",
3371 json!({"path": input.to_str().unwrap()}),
3372 &AuthCatalog::new(),
3373 None,
3374 )
3375 .await
3376 .unwrap();
3377 let inner_name = inner.connector_name();
3379 let ov = StateKeyOverride {
3380 inner,
3381 key: "my::custom::key".into(),
3382 };
3383 assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
3384 assert_eq!(ov.connector_name(), inner_name);
3385 let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
3386 assert_eq!(rows.len(), 1);
3387 ov.apply_start_bookmark(json!({"any": "bookmark"}))
3389 .await
3390 .unwrap();
3391 assert!(!ov.supports_exactly_once());
3393 assert_eq!(
3394 ov.replay_guarantee(),
3395 faucet_core::ReplayGuarantee::NonDeterministic
3396 );
3397 assert_eq!(ov.capture_resume_position().await.unwrap(), None);
3398 }
3399
3400 #[tokio::test]
3401 async fn state_key_override_forwards_native_stream_pages() {
3402 struct PerPageBookmarkSource;
3408 #[async_trait]
3409 impl Source for PerPageBookmarkSource {
3410 async fn fetch_with_context(
3411 &self,
3412 _ctx: &HashMap<String, Value>,
3413 ) -> Result<Vec<Value>, FaucetError> {
3414 Ok(vec![json!({"id": 1}), json!({"id": 2})])
3415 }
3416 fn stream_pages<'a>(
3417 &'a self,
3418 _ctx: &'a HashMap<String, Value>,
3419 _batch_size: usize,
3420 ) -> std::pin::Pin<
3421 Box<
3422 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
3423 + Send
3424 + 'a,
3425 >,
3426 > {
3427 Box::pin(faucet_core::async_stream::try_stream! {
3428 yield faucet_core::StreamPage {
3429 records: vec![json!({"id": 1})],
3430 bookmark: Some(json!("bm-1")),
3431 };
3432 yield faucet_core::StreamPage {
3433 records: vec![json!({"id": 2})],
3434 bookmark: Some(json!("bm-2")),
3435 };
3436 })
3437 }
3438 fn state_key(&self) -> Option<String> {
3439 Some("native".into())
3440 }
3441 }
3442
3443 use futures::StreamExt;
3444 let ov = StateKeyOverride {
3445 inner: Box::new(PerPageBookmarkSource),
3446 key: "override".into(),
3447 };
3448 let ctx = HashMap::new();
3449 let pages: Vec<_> = ov
3450 .stream_pages(&ctx, 1000)
3451 .collect::<Vec<_>>()
3452 .await
3453 .into_iter()
3454 .collect::<Result<Vec<_>, _>>()
3455 .unwrap();
3456 assert_eq!(pages.len(), 2, "native page boundaries survive the wrap");
3457 assert_eq!(pages[0].bookmark, Some(json!("bm-1")));
3458 assert_eq!(pages[1].bookmark, Some(json!("bm-2")));
3459 }
3460
3461 #[tokio::test]
3462 async fn capturing_sink_forwards_capabilities_and_captures_idempotent_writes() {
3463 struct IdemSink;
3464 #[async_trait]
3465 impl Sink for IdemSink {
3466 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3467 Ok(records.len())
3468 }
3469 fn connector_name(&self) -> &'static str {
3470 "idem"
3471 }
3472 fn supports_idempotent_writes(&self) -> bool {
3473 true
3474 }
3475 fn dedups_by_key(&self) -> bool {
3476 true
3477 }
3478 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
3479 &[
3480 faucet_core::WriteMode::Append,
3481 faucet_core::WriteMode::Upsert,
3482 ]
3483 }
3484 async fn write_batch_idempotent(
3485 &self,
3486 records: &[Value],
3487 _scope: &str,
3488 _token: &str,
3489 ) -> Result<usize, FaucetError> {
3490 Ok(records.len())
3491 }
3492 async fn last_committed_token(
3493 &self,
3494 _scope: &str,
3495 ) -> Result<Option<String>, FaucetError> {
3496 Ok(Some("tok".into()))
3497 }
3498 }
3499
3500 let captured = Arc::new(Mutex::new(Vec::new()));
3501 let sink = CapturingSink::wrap(
3502 Box::new(IdemSink),
3503 Arc::clone(&captured),
3504 Arc::new(Projection::Full),
3505 );
3506 assert!(sink.supports_idempotent_writes());
3509 assert!(sink.dedups_by_key());
3510 assert_eq!(
3511 sink.sink_guarantee(),
3512 faucet_core::SinkGuarantee::AtomicWatermark
3513 );
3514 assert!(
3515 sink.supported_write_modes()
3516 .contains(&faucet_core::WriteMode::Upsert)
3517 );
3518 assert_eq!(
3519 sink.last_committed_token("k").await.unwrap(),
3520 Some("tok".into())
3521 );
3522 assert_eq!(sink.current_schema().await.unwrap(), None);
3523 assert!(!sink.supports_schema_evolution());
3524 let n = sink
3526 .write_batch_idempotent(&[json!({"id": 7})], "k", "t")
3527 .await
3528 .unwrap();
3529 assert_eq!(n, 1);
3530 assert_eq!(*captured.lock().await, vec![json!({"id": 7})]);
3531 }
3532
3533 #[tokio::test]
3534 async fn orphaned_child_surfaces_executor_deadlock() {
3535 use crate::config::ConnectorSpec;
3539 let orphan = ExpandedNode {
3540 id: "orphan".into(),
3541 row_index: 0,
3542 role: NodeRole::Child {
3543 parent_id: "missing-parent".into(),
3544 parent_key: "id".into(),
3545 },
3546 source: ConnectorSpec {
3547 kind: "csv".into(),
3548 config: json!({}),
3549 transforms: None,
3550 inherit_transforms: true,
3551 status: None,
3552 tags: Vec::new(),
3553 },
3554 sink: ConnectorSpec {
3555 kind: "jsonl".into(),
3556 config: json!({}),
3557 transforms: None,
3558 inherit_transforms: true,
3559 status: None,
3560 tags: Vec::new(),
3561 },
3562 transforms: Vec::new(),
3563 state: None,
3564 dlq: None,
3565 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3566 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3567 #[cfg(feature = "quality")]
3568 quality: None,
3569 #[cfg(feature = "contract")]
3570 contract: None,
3571 #[cfg(feature = "masking")]
3572 masking: None,
3573 sink_ref: "default".into(),
3574 schema: None,
3575 depends_on: Vec::new(),
3576 status: crate::config::SourceStatus::Active,
3577 tags: Vec::new(),
3578 deferred_refs: Vec::new(),
3579 source_override: None,
3580 };
3581 let err = run_expanded(vec![orphan], opts("deadlock"))
3582 .await
3583 .expect_err("an orphaned child must surface as an executor deadlock");
3584 match err {
3585 CliError::Internal(msg) => {
3586 assert!(msg.contains("executor deadlock"), "{msg}");
3587 assert!(msg.contains("orphan"), "{msg}");
3588 }
3589 other => panic!("expected Internal deadlock error, got {other:?}"),
3590 }
3591 }
3592
3593 #[test]
3594 fn value_to_string_brief_unquotes_strings_only() {
3595 assert_eq!(value_to_string_brief(&json!("hello")), "hello");
3596 assert_eq!(value_to_string_brief(&json!(42)), "42");
3597 assert_eq!(value_to_string_brief(&json!(true)), "true");
3598 assert_eq!(value_to_string_brief(&json!(null)), "null");
3599 assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
3600 }
3601
3602 #[test]
3603 fn build_state_key_with_and_without_parent() {
3604 assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
3605 assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
3606 }
3607
3608 #[test]
3609 fn resolve_parent_key_walks_objects_arrays_and_misses() {
3610 let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
3611 assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
3612 assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
3613 assert_eq!(resolve_parent_key(&r, "user.age"), None);
3615 assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
3617 assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
3619 }
3620
3621 #[tokio::test]
3622 async fn cooperative_cancel_returns_partial_ok() {
3623 let dir = tempfile::tempdir().unwrap();
3627 let input = dir.path().join("in.csv");
3628 let output = dir.path().join("out.jsonl");
3629 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3630 let cfg = cfg_csv_to_jsonl(&input, &output);
3631 let nodes = expand(&cfg).unwrap();
3632 let token = CancellationToken::new();
3633 token.cancel(); let mut o = opts("cancel");
3635 o.cancel = Some(token);
3636 let summary = run_expanded(nodes, o).await.unwrap();
3637 assert_eq!(summary.invocations.len(), 1);
3640 assert!(
3641 !summary.had_failures(),
3642 "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
3643 );
3644 }
3645
3646 #[tokio::test]
3647 async fn fanout_projects_away_unreferenced_parent_fields() {
3648 let dir = tempfile::tempdir().unwrap();
3652 let parent_csv = dir.path().join("parents.csv");
3653 let child_csv = dir.path().join("child.csv");
3654 std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
3655 std::fs::write(&child_csv, "x\nA\n").unwrap();
3656 let parent_out = dir.path().join("parents.jsonl");
3657 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3658
3659 let yaml = format!(
3660 r#"version: 1
3661pipeline:
3662 source: {{ type: csv, config: {{ path: {parent} }} }}
3663 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3664matrix:
3665 - id: parents
3666 - id: child
3667 parent: parents
3668 source: {{ config: {{ path: {child} }} }}
3669 sink: {{ config: {{ path: "{child_out}" }} }}
3670"#,
3671 parent = parent_csv.display(),
3672 parent_out = parent_out.display(),
3673 child = child_csv.display(),
3674 child_out = child_out_pattern.display(),
3675 );
3676 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3677 let nodes = expand(&cfg).unwrap();
3678 let summary = run_expanded(
3679 nodes,
3680 ExecuteOptions {
3681 pipeline_name: "projtest".into(),
3682 execution: None,
3683 dry_run: false,
3684 limit: None,
3685 state_path_override: None,
3686 shard: None,
3687 auth: Default::default(),
3688 clock: chrono::Utc::now().fixed_offset(),
3689 cancel: None,
3690 resilience: None,
3691 sla: None,
3692 #[cfg(feature = "lineage")]
3693 lineage: None,
3694 #[cfg(feature = "lineage")]
3695 lineage_cfg: None,
3696 #[cfg(feature = "notify")]
3697 notifier: None,
3698 #[cfg(feature = "catalog")]
3699 catalog: None,
3700 },
3701 )
3702 .await
3703 .unwrap();
3704
3705 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3707 assert!(!summary.had_failures(), "{summary:?}");
3708 assert!(dir.path().join("child-1.jsonl").exists());
3710 assert!(dir.path().join("child-2.jsonl").exists());
3711 }
3712}