Skip to main content

faucet_cli/
executor.rs

1//! Run a list of [`ExpandedNode`]s under a bounded-concurrency executor.
2//!
3//! Semantics:
4//!
5//! - Roots run concurrently under `Semaphore(max_concurrent)`.
6//! - Each root captures its written records (via a `CapturingSink` wrapper)
7//!   so descendants can fan out per parent record.
8//! - For each child whose parent has finished successfully, one pipeline
9//!   invocation runs per parent record. `${parent.dotted.path}` tokens in the
10//!   source / sink config and state-key suffix are resolved against that
11//!   record via [`interpolate_record`].
12//! - All invocations share one global semaphore — children and roots compete
13//!   for the same budget.
14//! - A node with `depends_on: [row, …]` starts only after every listed row's
15//!   invocations finish successfully — pure completion-ordering, no record
16//!   hand-off. A failed or skipped dependency skips the node (and, in turn,
17//!   its own subtree and dependents).
18//! - `on_error: continue` (default) skips a failed node's subtree but keeps
19//!   running siblings. `on_error: stop` cancels everything after the first
20//!   failure.
21//! - State-key collisions among children of the same parent surface as a
22//!   `CliError::DuplicateStateKey`.
23
24use 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
43/// Captured fan-out records, keyed by node id. Records are held as `Arc<Value>`
44/// so the per-level snapshot and per-child-unit hand-off are pointer bumps, not
45/// deep clones of the JSON tree (#160).
46type CapturedRecords = Arc<Mutex<HashMap<String, Vec<Arc<Value>>>>>;
47use tokio_util::sync::CancellationToken;
48
49/// Knobs passed to [`run_expanded`].
50pub struct ExecuteOptions {
51    /// Pipeline name — used in log lines and as the first segment of every
52    /// state key.
53    pub pipeline_name: String,
54    /// Override for `execution.max_concurrent`. `None` → use the value in
55    /// `ExecutionSpec` or the default (`num_cpus::get().min(4)`, floored at 1).
56    pub execution: Option<ExecutionSpec>,
57    /// `--dry-run` — every sink is replaced with a no-op counter.
58    pub dry_run: bool,
59    /// `--limit N` — wraps every sink to drop records past the cap.
60    pub limit: Option<usize>,
61    /// `--state-path PATH` — overrides the `file` state-store path.
62    pub state_path_override: Option<PathBuf>,
63    /// Clustered Mode B (#230): narrow this run's single source to one shard
64    /// before streaming, and suffix its state key with the shard id so resume is
65    /// per-shard. `None` (the default) runs the whole source unchanged. Only set
66    /// by the serve shard executor; every other caller leaves it `None`.
67    pub shard: Option<faucet_core::ShardSpec>,
68    /// Shared auth providers built from the top-level `auth:` block. Connectors
69    /// that reference one via `auth: { ref }` resolve against this catalog;
70    /// every row sharing a provider gets the same `Arc` (one token, shared).
71    pub auth: AuthCatalog,
72    /// Wall-clock instant for `${now.*}` interpolation in this run's configs.
73    /// `faucet run` sets process-start (or `--clock`); `faucet schedule` sets
74    /// the tick's scheduled time in the schedule timezone.
75    pub clock: DateTime<FixedOffset>,
76    /// Optional external cancellation token. When set and cancelled, in-flight
77    /// invocations stop at their next page boundary and **flush** their sinks
78    /// (so buffered output like a Parquet footer is durable), rather than being
79    /// hard-dropped (#146 H16). `faucet serve` wires this to run-cancel /
80    /// timeout / shutdown; `faucet run` leaves it `None`.
81    pub cancel: Option<CancellationToken>,
82    /// Optional resilience policy (retry/backoff/circuit-breaker/poison),
83    /// attached to every invocation's `RunStreamOptions` and injected into
84    /// rest/xml/graphql sources. Built once from the top-level `resilience:`
85    /// block; `None` preserves today's behaviour.
86    pub resilience: Option<faucet_core::ResiliencePolicy>,
87    /// Optional freshness/volume SLA (#202), evaluated after every **root**
88    /// invocation against history persisted in the node's state store.
89    /// Violations emit metrics + warnings; they never fail the run. `None`
90    /// disables the pass entirely.
91    pub sla: Option<crate::sla::SlaSpec>,
92    /// Shared OpenLineage emitter, built once from the `lineage:` block. `None`
93    /// disables lineage (and adds zero overhead). Gated on the `lineage` feature.
94    #[cfg(feature = "lineage")]
95    pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
96    /// The resolved `lineage:` config block (facet/event toggles, sampling, job
97    /// name template). Carried alongside the emitter so `run_one_invocation`
98    /// knows which facets/events to assemble. Gated on the `lineage` feature.
99    #[cfg(feature = "lineage")]
100    pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
101    /// Optional notification/incident-routing notifier (#280), built once from
102    /// the top-level `notifications:` block and shared across invocations. Fires
103    /// run success/failure, SLA breach, circuit-open, contract-abort, and
104    /// DLQ-threshold events after every **root** invocation. `None` disables
105    /// notifications entirely (zero overhead). Gated on the `notify` feature.
106    #[cfg(feature = "notify")]
107    pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
108    /// Optional Data Movement Catalog store (#279), recorded into after every
109    /// successful **root** invocation (dataset identity, schema timeline,
110    /// volume/freshness, lineage edge). `faucet serve` passes its run-history
111    /// backend + the serve run id; the CLI runtimes connect one from the
112    /// `catalog:` block. `None` disables recording entirely (zero overhead).
113    /// Recording never fails the run. Gated on the `catalog` feature.
114    #[cfg(feature = "catalog")]
115    pub catalog: Option<crate::catalog::CatalogHandle>,
116}
117
118/// Grace window granted to in-flight invocations to flush cooperatively after
119/// an `on_error: stop` cancellation, before the remaining tasks are
120/// hard-aborted (the backstop for a sink genuinely stuck mid-write). Bounded so
121/// a hung sink can't wedge the whole run.
122const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
123
124/// One pipeline invocation's outcome.
125#[derive(Debug)]
126pub struct InvocationOutcome {
127    pub row_id: String,
128    /// `None` for root invocations; for children, the value at `parent_key` in
129    /// the parent record (rendered to a string).
130    pub parent_record_key: Option<String>,
131    pub records_written: usize,
132    pub error: Option<String>,
133    /// Machine-readable per-invocation stats for `faucet run --output json`
134    /// (#390). `None` for synthetic outcomes (a task panic, or the placeholder
135    /// outcomes the replication / schedule orchestrators build) where no
136    /// pipeline actually ran.
137    pub metrics: Option<InvocationMetrics>,
138}
139
140/// Per-invocation stats surfaced by `faucet run --output json` (#390). Every
141/// field comes from counters the pipeline already maintains — no new
142/// measurement. `records_read` is `None` unless input sampling was active
143/// (a `lineage:` or `catalog:` block installs the source sampler).
144#[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
154/// What [`run_one_invocation`] hands back on success alongside the captured
155/// records — the pipeline-level counters `run_unit` folds into an
156/// [`InvocationMetrics`] (which then adds the connector kinds + wall-clock).
157struct PipelineStats {
158    records_written: usize,
159    records_read: Option<u64>,
160    dlq_count: u64,
161    bookmark: Option<Value>,
162}
163
164/// Aggregate outcome of `run_expanded`.
165#[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
182/// Default number of matrix invocations to run in parallel when neither the
183/// config's `execution.max_concurrent` nor a flag specifies one.
184///
185/// Scales with the core count but is capped at 8. The cap is deliberate: each
186/// invocation is a *full pipeline* with its own connection pools / HTTP
187/// clients, and matrix rows often target the same external system (one API,
188/// one database), so an unbounded fan-out across, say, a 64-core box would
189/// blow through that system's connection or rate limits rather than going
190/// faster. Workloads that genuinely benefit from more parallelism set
191/// `execution.max_concurrent` explicitly to opt out of the cap (#78 LOW).
192fn default_concurrency() -> usize {
193    std::thread::available_parallelism()
194        .map(|n| n.get())
195        .unwrap_or(4)
196        .clamp(1, 8)
197}
198
199/// Execute every node in `nodes`. `nodes` must be in BFS order (roots first
200/// then children) — that's what [`crate::expand::expand`] returns.
201pub 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    // Index nodes by id for parent → children lookups.
216    // parent id → child node ids. Keyed and valued by id (not Vec index) so the
217    // failure cascade can look children up directly instead of indexing into a
218    // HashMap's nondeterministic iteration order (#78/#24).
219    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    // Captured records per node id. Only populated for nodes that have
230    // children (= are referenced by another node's `parent:`). Records are held
231    // as `Arc<Value>` so the per-level snapshot clone and the per-child-unit
232    // hand-off are pointer bumps, not deep clones of the JSON tree (#160).
233    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    // Root cooperative-cancel token: the caller's (serve wires run-cancel /
239    // timeout / shutdown) or a fresh one. Each level derives a child token so
240    // an `on_error: stop` cancels only that level's invocations, while an
241    // external cancel of the root propagates to every level (#146 H16).
242    let cancel = opts.cancel.clone().unwrap_or_default();
243    let opts = Arc::new(opts);
244
245    // We execute level-by-level. Each level is "every node whose parent is
246    // already done." Roots are level 0. For each level, we spawn one task per
247    // (node, parent-record) pair and await them all before moving on.
248    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    // Per-parent projection: what to keep from each captured record so the
254    // fan-out buffer holds only the fields children reference (#160).
255    let projections = build_projections(&nodes_by_id, &children_of);
256
257    // Sort node ids in their original BFS row order so the executor is
258    // deterministic — important for `on_error: stop`, where the first failure
259    // halts the rest of the level.
260    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        // Pick every remaining node whose parent (if any) and every
271        // `depends_on` row are already terminal (completed or skipped), in
272        // deterministic BFS order. Whether a skipped/failed prerequisite
273        // *skips* the node is decided in the unit loop below — readiness only
274        // asks "is there anything left to wait for".
275        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            // No node is ready but some remain — an expand.rs invariant was
297            // violated (e.g. an orphaned parent reference). Surface it instead
298            // of silently dropping the remaining work and reporting success
299            // (#78/#24).
300            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        // Build the work units for this level. Each unit is one invocation —
311        // a root runs once; a child runs once per parent record.
312        let mut units: Vec<Unit> = Vec::new();
313        // Move only the captured records of the parents whose children run this
314        // level out of the shared map. This both narrows the snapshot and frees
315        // each parent's buffer the moment its children consume it: all of a
316        // parent's children become ready in the same level, so its records are
317        // needed exactly once. Units hold their own `Arc<Value>` clones, so
318        // removing the map entry here only drops the map's hold (#160).
319        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 a parent failed (and on_error=continue), the subtree is
336            // skipped. Surface a synthetic "skipped" outcome and move on.
337            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            // Same for a failed/skipped `depends_on` prerequisite: the row
345            // waited for something that never succeeded, so running it would
346            // violate the ordering contract. Mark it skipped so its own
347            // subtree and dependents cascade too.
348            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                    // Detect state-key collisions among siblings sharing one parent.
385                    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        // Unified parallel execution. Tasks run concurrently under the global
418        // semaphore. Under `on_error: stop`, the first failure triggers
419        // `JoinSet::abort_all()` — pending tasks waiting on a permit are
420        // dropped before they do real work, and in-flight tasks are
421        // cancelled at their next `.await` point (potentially leaving
422        // partial sink state — the trade-off users opt into by choosing
423        // `stop`). Under `on_error: continue` every spawned task runs to
424        // completion regardless of sibling failures.
425        // Per-level cancel token: cancelling it (on `on_error: stop`) stops only
426        // this level's invocations cooperatively; it is a child of the root
427        // token, so an external cancel (serve) still propagates here (#146 H16).
428        let level_cancel = cancel.child_token();
429        let mut joinset = tokio::task::JoinSet::new();
430        // Map each spawned task's id back to its row id + parent key so that a
431        // panic (surfaced as a JoinError, which doesn't carry the unit) can be
432        // attributed to the right invocation.
433        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            // Once `on_error: stop` has cancelled the level, give in-flight
453            // invocations a bounded grace to flush cooperatively, then
454            // hard-abort the stragglers (the backstop for a sink stuck
455            // mid-write that can't reach a page boundary to observe the cancel).
456            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            // A failure (an `Err` outcome or a panicked task) marks the level
475            // failed and, under `on_error: stop`, stops the rest. A panicking
476            // connector must NOT take down the whole process (#78/#24).
477            let outcome = match joined {
478                Ok((_id, outcome)) => outcome,
479                Err(e) if e.is_cancelled() => {
480                    // Expected after abort_all() — cancelled before/at an await.
481                    // Not counted as a failure or a success.
482                    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                    // Cooperative first: in-flight pipelines flush at their next
510                    // page boundary so a Parquet footer / S3 upload is completed
511                    // rather than orphaned (#146 H16).
512                    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        // Mark ready nodes done (some may have produced both successes and
526        // failures across their per-parent-record fan-outs — we treat a node
527        // as "failed" overall if any of its invocations failed).
528        for id in ready {
529            remaining.remove(&id);
530            if nodes_with_any_failure.contains(&id) {
531                skipped_subtrees.insert(id.clone());
532                // Cascade to descendants in case we have multi-level chains.
533                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            // Any unfinished work surfaces as "skipped"; we just break here.
546            break;
547        }
548    }
549
550    Ok(RunSummary {
551        invocations: outcomes,
552    })
553}
554
555/// One scheduled invocation — a root runs once, a child runs once per parent
556/// record. Built by the level loop, consumed by [`run_unit`].
557struct 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                    // Move each record into an `Arc` once here; downstream
600                    // per-level / per-unit hand-offs then clone only the pointer.
601                    .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
626/// Produce `{pipeline_name}::{row_id}` or `{pipeline_name}::{row_id}::{key}`.
627pub(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
638/// Reject an invalid state key up front (at unit construction) when the node
639/// will use a state store, so a bad pipeline name or parent-key value surfaces
640/// as a clear [`CliError::InvalidStateKey`] instead of a late mid-run
641/// `FaucetError::State` after connectors are built and the stream has started.
642fn 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
655/// Walk the parent record by `parent_key` (a dotted path) and clone the value.
656fn 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/// What to keep from each of a parent's records when capturing for fan-out.
669/// Projecting to only the fields children reference bounds orchestrator memory
670/// at O(referenced-fields × N) instead of O(full-record × N) (#160).
671#[derive(Debug, Clone)]
672enum Projection {
673    /// Keep the whole record — a child referenced `${parent}` (the entire record)
674    /// or used an empty `parent_key`, so nothing can be safely dropped.
675    Full,
676    /// Keep only these pre-split, non-overlapping dotted paths.
677    Paths(Vec<Vec<String>>),
678}
679
680/// Split a dotted path into segments.
681fn split_path(path: &str) -> Vec<String> {
682    path.split('.').map(|s| s.to_string()).collect()
683}
684
685/// Reduce a set of segment-paths to a minimal non-overlapping set: drop any path
686/// that has a (segment-wise prefix) ancestor in the set — `["user"]` covers
687/// `["user","name"]`. Sorting puts ancestors before their descendants.
688fn 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
703/// Resolve a pre-split dotted path against `record`, dispatching on each value's
704/// type exactly like `resolve_parent_key` / `interpolate::resolve_dotted`.
705fn 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
717/// Insert `leaf` at `segments` into `out`, creating intermediate `Value::Object`
718/// nodes keyed by the literal segment string. Callers pass non-overlapping
719/// `segments` (see `minimal_paths`), so a node that must be an object is never
720/// already a leaf.
721fn 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
740/// Project `record` down to `projection`, building an all-objects reduced tree.
741/// Because the readers (`resolve_parent_key`, `interpolate_record`) dispatch on
742/// the reduced value's type, an array-index segment like `0` is stored — and
743/// later read — as the object key `"0"`, so resolution matches the original.
744fn 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
759/// Compute, per parent id, what to keep from each of its records: the union of
760/// every child's `parent_key` (the state-key path) and every `${parent.path}`
761/// token the children reference (from their pre-collected `deferred_refs`).
762/// Reused by the level loop to project captured records (#160).
763fn 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; // `${parent}` — whole record
784                    } else {
785                        raw.push(split_path(&dref.dotted_path));
786                    }
787                }
788            }
789        }
790        // Defensive: `raw` is empty only when every child had an empty
791        // parent_key, which already set `full`. The `|| raw.is_empty()` keeps us
792        // on `Full` even if that ever changes, so we never project to an empty
793        // tree that would drop the state-key path.
794        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/// Run one pipeline invocation. Returns (captured records, records_written).
805/// Assemble the runtime [`Pipeline`] for one invocation from a node's specs.
806///
807/// This is the `with_*` builder chain — state store, DLQ, cancellation, quality,
808/// contract, masking, schema-drift, adaptive batching, resilience, and delivery
809/// mode — lifted out of [`run_one_invocation`] so the wiring is testable in
810/// isolation and a preview-mode / feature-gate mistake (cf. #321 H1) is harder
811/// to make (#324 C). Behaviour is identical to the previous inline chain; the
812/// only I/O it performs is building the DLQ sink. `source`/`sink` are borrowed
813/// for the returned pipeline's lifetime; `state` is moved in.
814#[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    // Cooperative cancellation: a cancelled token makes the streaming loop stop
838    // at the next page boundary and flush the sink (#146 H16). The pipeline takes
839    // a clone so the caller's lineage terminal-event classification and SLA pass
840    // can still read `cancel.is_cancelled()` (cheap — the token is an `Arc`).
841    pipeline = pipeline.with_cancel(cancel.clone());
842    // Pipeline-level quality checks (v1: no matrix-row override). `expand` already
843    // validated this spec, but compile again here to obtain the runtime
844    // `CompiledQuality`; map any error to a config-level failure.
845    #[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    // Pipeline-level data contract (v1: no matrix-row override). `expand` already
854    // validated the spec; compile again here to obtain the runtime
855    // `CompiledContract`.
856    #[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    // Pipeline-level PII masking (v1: no matrix-row override). Compile scoped to
865    // this node's destination sink — by template name (`sink_ref`) and by
866    // connector kind — so `applies_to` per-destination rules resolve. When no
867    // rule applies to this sink the compiled policy is empty and the pass is
868    // skipped entirely.
869    #[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    // Schema-drift policy (pipeline-level in v1; same for every invocation).
879    if let Some(ref sd) = node.schema {
880        pipeline = pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd));
881    }
882    // Execution-level adaptive batch-size controller (shared by all rows).
883    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    // Resilience policy (retry/backoff/circuit-breaker/poison). Top-level in v1,
893    // so the same policy is attached to every invocation.
894    if let Some(policy) = opts.resilience.clone() {
895        pipeline = pipeline.with_resilience(policy);
896    }
897    // Delivery guarantee (exactly-once resume/skip when the node opted in; the
898    // expand gate already verified source/sink/state support). Preview modes
899    // (`--dry-run`, `--limit`) swap in counting/truncating sinks that cannot
900    // uphold an atomic watermark — and a token committed for a truncated page
901    // would corrupt it — so they always run at-least-once.
902    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    // Observability identity for this invocation — built once, reused by both
920    // the Pipeline builder and the transform instrumentation.
921    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    // Whether this invocation records into the Data Movement Catalog (#279):
930    // roots only, and never for dry-run / --limit / shard runs (their volumes
931    // and datasets are partial or synthetic) — the same scoping as SLA.
932    #[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    // 1) Resolve `${parent.path}` in the per-row source + sink configs.
939    let mut source_cfg = node.source.config.clone();
940    let mut sink_cfg = node.sink.config.clone();
941
942    // Resolve `${now.*}` run-clock tokens for every invocation (root + child),
943    // before the parent-record pass. Leaves all other tokens verbatim.
944    resolve_now_inplace(&mut source_cfg, opts.clock)?;
945    resolve_now_inplace(&mut sink_cfg, opts.clock)?;
946    // `${backfill.*}` tokens are substituted by the `faucet backfill`
947    // orchestrator before nodes reach the executor. One still present here
948    // means a window-scoped config is being driven by `run`/`schedule`/
949    // `serve` — fail loudly instead of handing the literal token to the
950    // connector (#282).
951    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    // 2) Build source + sink. `faucet dlq replay` injects a pre-built source
961    //    (an envelope-unwrapping DLQ reader) via `source_override`; every
962    //    config-driven node builds from the connector registry. The override
963    //    is taken once — replay runs a single invocation.
964    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    // Catalog identity (#279): read the dataset URIs off the *raw* connectors,
978    // before any wrapper is layered on.
979    #[cfg(feature = "catalog")]
980    let source_dataset_uri = source.dataset_uri();
981
982    // Clustered Mode B: narrow the raw source to its assigned shard BEFORE any
983    // wrapping (the TransformingSource / StateKeyOverride wrappers do not forward
984    // apply_shard, so it must reach the concrete connector).
985    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    // ── Lineage: sampling wrappers (only for requested facets) ───────────────
1013    // `in_sample` taps the source's pre-transform records (input schema /
1014    // column lineage); `out_sample` taps the sink's written records (output
1015    // schema / RUNNING-heartbeat throughput counter). Both stay `None` when no
1016    // facet/event needs them, so lineage adds zero per-record overhead.
1017    #[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        // The catalog (#279) needs input/output samples for schema inference
1030        // and record counts regardless of any `lineage:` block; take the max
1031        // of both caps when both are active.
1032        #[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    // Wrap the raw source so it samples PRE-transform input records — this must
1053    // sit between `build_source` and `TransformingSource`.
1054    #[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    // 3) Compile transforms. Resolve `${now.*}` run-clock tokens in each
1064    //    transform's config first — exactly as for source/sink above — so e.g. a
1065    //    `set` transform stamping `${now.date}` writes the real date instead of
1066    //    the literal token string. Without this the token leaks into every record.
1067    let mut transforms = node.transforms.clone();
1068    for t in &mut transforms {
1069        resolve_now_inplace(&mut t.config, opts.clock)?;
1070    }
1071    // With `arrow`, compile the columnar batch forms too so an all-columnar
1072    // chain (e.g. `parquet → sql → parquet`) runs on the Arrow fast path; a
1073    // `Value`-only stage in the chain leaves `batch_fns` with a `None` entry,
1074    // which keeps the whole pipeline on the `Value` path (#375).
1075    #[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    // 4) Build state store. If the source opts into state, wrap it so the
1104    //    executor's per-row state key is used instead of the source's natural
1105    //    one (which is shared across all matrix rows of the same kind).
1106    let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
1107    // Preview modes must not persist bookmarks: the counting/truncating sinks
1108    // return `Ok` without a real write, so a persisted (advanced) bookmark would
1109    // make the next real run skip unwritten records (#321 H1). Wrap the store so
1110    // reads still resume faithfully but writes are dropped.
1111    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    // Keep a handle for the post-run SLA pass (#202) — `state` itself is moved
1118    // into the pipeline below.
1119    let sla_store = state.clone();
1120    // Per-shard bookmark: suffix the state key with the shard id so a reassigned
1121    // shard resumes where its dead owner left off, independent of sibling shards.
1122    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    // Wrap the sink so it samples written records — outermost, immediately
1136    // before the pipeline is constructed (after capture/limit wrappers).
1137    #[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    // 5) Assemble the runtime pipeline. The whole `with_*` builder chain (state,
1147    //    DLQ, cancellation, quality, contract, masking, schema-drift, adaptive
1148    //    batching, resilience, delivery) lives in `build_pipeline` so the wiring
1149    //    is testable in isolation and a preview-mode / feature-gate mistake
1150    //    (cf. #321 H1) is harder to make (#324 C). The identity strings are
1151    //    borrowed — the lineage START/terminal lifecycle below still needs
1152    //    `pipeline_name` / `row_id` / `run_id`.
1153    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    // ── Lineage: START + heartbeat + terminal ────────────────────────────────
1166    #[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                inputs: vec![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_schemas: Vec::new(),
1189                output_schema: None,
1190                column_lineage: None,
1191                source_code: None,
1192            };
1193            em.emit(faucet_lineage::EventType::Start, &ctx).await;
1194            // Heartbeat task — periodic RUNNING events with the live throughput
1195            // count read off the output sampler.
1196            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; // skip the immediate first tick
1204                    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    // Combine run + final flush into one outcome BEFORE emitting the terminal
1227    // lineage event, preserving the original semantics (run error → skip flush;
1228    // run ok but flush error → overall error). The terminal event is classified
1229    // from this combined `result`, then `?`-propagated below — restoring the
1230    // original early-return behaviour while still firing the terminal event on
1231    // both success and error.
1232    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_schemas = vec![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    // ── SLA post-run evaluation (#202) ───────────────────────────────────────
1294    // Roots only: children fan out per parent record, so their volumes are not
1295    // a stable series to baseline (same scoping as `faucet doctor`). Skipped
1296    // for dry-run / --limit (synthetic volumes would poison the baseline), for
1297    // shard executions (a shard's volume is a fraction of the row's, and shard
1298    // counts change run to run), and for cancelled runs (a partial volume is
1299    // not a signal). Monitoring never fails the run — see `evaluate_post_run`.
1300    // Roots only, and never for dry-run / --limit / shard / cancelled runs —
1301    // the same scoping the notification pass below reuses.
1302    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    // ── Notifications (#280) ─────────────────────────────────────────────────
1333    // Fan run success/failure, SLA breach, circuit-open, contract-abort, and
1334    // DLQ-threshold out to the configured channels. Same root/real-run scoping
1335    // as SLA; delivery is fire-and-forget and never fails the run.
1336    #[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    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
1381    // Fold this run's dataset observations + lineage edge into the catalog.
1382    // Successful, complete root runs only (a cancelled run's partial volume is
1383    // not a signal). Recording never fails the run — see `catalog::record`.
1384    #[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        // The samplers are always installed while the catalog is active, so the
1401        // unwrap_or arms are defensive only.
1402        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        // Column lineage for the edge — the same derivation `faucet-lineage`
1412        // emits, so the catalog's edges match the OpenLineage output.
1413        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                // `ColumnLineage` is not `Serialize` (IndexMap); render the
1427                // stable `{"fields": {out: [in, …]}}` shape by hand.
1428                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            // A matrix invocation is always one source → one sink; the vector
1448            // shape exists for topology graphs, where a sink can be fed by several
1449            // (#459).
1450            sources: vec![DatasetObservation {
1451                uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
1452                kind: node.source.kind.clone(),
1453                role: DatasetRole::Source,
1454                schema: source_schema,
1455                records: records_read,
1456            }],
1457            sink: DatasetObservation {
1458                uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
1459                kind: node.sink.kind.clone(),
1460                role: DatasetRole::Sink,
1461                schema: sink_schema,
1462                records: records_out,
1463            },
1464            column_lineage,
1465        };
1466        crate::catalog::record(handle, &update).await;
1467    }
1468
1469    let result = result?;
1470
1471    // Per-invocation stats for `faucet run --output json` (#390). `records_read`
1472    // is only known when the source sampler was installed (a `lineage:` or
1473    // `catalog:` block); otherwise it stays `None` rather than guessing.
1474    #[cfg(feature = "lineage")]
1475    let records_read = in_sample.as_ref().map(|s| s.count());
1476    #[cfg(not(feature = "lineage"))]
1477    let records_read: Option<u64> = None;
1478    let stats = PipelineStats {
1479        records_written: result.records_written,
1480        records_read,
1481        dlq_count: result
1482            .dlq
1483            .as_ref()
1484            .map(|d| d.records_dlq as u64)
1485            .unwrap_or(0),
1486        bookmark: result.bookmark.clone(),
1487    };
1488
1489    let captured = if capture.is_some() {
1490        std::mem::take(&mut *captured.lock().await)
1491    } else {
1492        Vec::new()
1493    };
1494    Ok((captured, stats))
1495}
1496
1497async fn build_state_for_node(
1498    node: &ExpandedNode,
1499    state_path_override: Option<&Path>,
1500) -> CliResult<Option<Arc<dyn StateStore>>> {
1501    match (&node.state, state_path_override) {
1502        (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1503        (None, Some(path)) => Ok(Some(state_from_override(path))),
1504        (Some(spec), Some(path)) => {
1505            if spec.kind == "file" {
1506                Ok(Some(state_from_override(path)))
1507            } else {
1508                tracing::warn!(
1509                    state = %spec.kind,
1510                    "--state-path is only meaningful for the 'file' backend; ignoring override"
1511                );
1512                Ok(Some(build_state_store(spec).await?))
1513            }
1514        }
1515        (None, None) => Ok(None),
1516    }
1517}
1518
1519fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1520    Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1521}
1522
1523/// Translate a [`crate::config::DlqSpec`] from the YAML/JSON config into a
1524/// runtime [`DlqConfig`] ready to attach to a [`Pipeline`].
1525pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1526    // DLQ sinks resolve against an empty catalog — shared `auth: { ref }` on a
1527    // DLQ sink is out of scope (DLQ targets are typically local jsonl/stdout).
1528    let sink = build_sink(
1529        &spec.sink.kind,
1530        spec.sink.config.clone(),
1531        &AuthCatalog::new(),
1532    )
1533    .await?;
1534    Ok(DlqConfig {
1535        sink: Arc::from(sink),
1536        on_batch_error: match spec.on_batch_error {
1537            crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1538            crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1539        },
1540        max_failures_per_page: spec.max_failures_per_page,
1541        max_failures_total: spec.max_failures_total,
1542        include_original_payload: spec.include_original_payload,
1543    })
1544}
1545
1546/// Classify a pipeline error into a notification event (#280). A circuit-breaker
1547/// trip and a contract-abort breach get their own event kinds; everything else
1548/// is a generic `run_failure` carrying a short error-kind label.
1549#[cfg(feature = "notify")]
1550fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
1551    use crate::notify::NotifyEvent;
1552    match err {
1553        FaucetError::CircuitOpen { failures, cooldown } => {
1554            NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
1555        }
1556        FaucetError::ContractViolation { message, .. } => {
1557            NotifyEvent::contract_abort(pipeline, row, message.clone())
1558        }
1559        other => {
1560            NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
1561        }
1562    }
1563}
1564
1565/// Short, stable label for a `FaucetError` variant used as the `error_kind`
1566/// detail on a `run_failure` notification. (`faucet-core`'s own `error_kind`
1567/// helper is `pub(crate)`, so we keep a small CLI-side mapping.)
1568#[cfg(feature = "notify")]
1569fn faucet_error_kind(err: &FaucetError) -> &'static str {
1570    match err {
1571        FaucetError::Config(_) => "config",
1572        FaucetError::Source(_) => "source",
1573        FaucetError::Sink(_) => "sink",
1574        FaucetError::State(_) => "state",
1575        FaucetError::QualityFailure { .. } => "quality",
1576        FaucetError::SchemaDrift { .. } => "schema_drift",
1577        _ => "error",
1578    }
1579}
1580
1581/// In-place `${now.*}` resolution against the run clock. Walks every string
1582/// leaf and rewrites `${now.<token>}`; all other `${...}` tokens are untouched.
1583/// Shared with `faucet test`, which applies the same pre-pass to transform
1584/// configs under the case clock.
1585/// Error on a leftover `${backfill.*}` token: those resolve only inside
1586/// `faucet backfill`, which substitutes them per window unit before the
1587/// executor runs. Reaching here means another runtime picked up a
1588/// window-scoped config.
1589pub(crate) fn reject_unresolved_backfill_tokens(value: &Value, owner: &str) -> CliResult<()> {
1590    fn walk(value: &Value, owner: &str) -> CliResult<()> {
1591        match value {
1592            Value::String(s) if s.contains("${backfill.") => Err(CliError::Config(format!(
1593                "the {owner} config references a `${{backfill.*}}` token, which only                  `faucet backfill` resolves — run this config via `faucet backfill                  --from … --to …`, or remove the token"
1594            ))),
1595            Value::Array(a) => a.iter().try_for_each(|v| walk(v, owner)),
1596            Value::Object(m) => m.values().try_for_each(|v| walk(v, owner)),
1597            _ => Ok(()),
1598        }
1599    }
1600    walk(value, owner)
1601}
1602
1603pub(crate) fn resolve_now_inplace(
1604    value: &mut Value,
1605    clock: DateTime<FixedOffset>,
1606) -> CliResult<()> {
1607    match value {
1608        Value::String(s) => {
1609            *s = crate::interpolate::resolve_now(s, clock)?;
1610            Ok(())
1611        }
1612        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1613        Value::Object(m) => m
1614            .values_mut()
1615            .try_for_each(|v| resolve_now_inplace(v, clock)),
1616        _ => Ok(()),
1617    }
1618}
1619
1620/// In-place runtime interpolation against a parent-record context. Walks every
1621/// string leaf in `value` and replaces `${id.path}` tokens with stringified
1622/// values from `ctx`.
1623fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1624    match value {
1625        Value::String(s) => {
1626            let resolved = interpolate_record(s, ctx)?;
1627            *s = resolved;
1628            Ok(())
1629        }
1630        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1631        Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1632        _ => Ok(()),
1633    }
1634}
1635
1636// ── Adapter sinks/sources ───────────────────────────────────────────────────
1637
1638/// Wraps a [`StateStore`] so reads pass through but writes/deletes are dropped.
1639///
1640/// Attached under `--dry-run` / `--limit`: those modes swap in counting /
1641/// truncating sinks that return `Ok` without a real durable write, and
1642/// `run_stream` persists a page's bookmark after any `Ok`. Persisting an
1643/// advanced bookmark from a preview would make the next *real* run resume past
1644/// records that were never written — a `--dry-run` silently causing data loss
1645/// (audit #321 H1; for postgres-cdc it also lets Postgres recycle WAL for
1646/// undelivered changes). Reads still pass through so the preview faithfully
1647/// resumes from the existing bookmark.
1648pub(crate) struct ReadOnlyStateStore {
1649    pub(crate) inner: Arc<dyn StateStore>,
1650}
1651
1652#[async_trait]
1653impl StateStore for ReadOnlyStateStore {
1654    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1655        self.inner.get(key).await
1656    }
1657    async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1658        Ok(())
1659    }
1660    async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1661        Ok(())
1662    }
1663}
1664
1665/// Wraps a source so its `state_key()` returns the executor-provided value
1666/// instead of the source's natural one. Lets every matrix invocation use a
1667/// distinct state-store entry even when the underlying source kind is shared.
1668struct StateKeyOverride {
1669    inner: Box<dyn Source>,
1670    key: String,
1671}
1672
1673#[async_trait]
1674impl Source for StateKeyOverride {
1675    async fn fetch_with_context(
1676        &self,
1677        ctx: &HashMap<String, Value>,
1678    ) -> Result<Vec<Value>, FaucetError> {
1679        self.inner.fetch_with_context(ctx).await
1680    }
1681    async fn fetch_with_context_incremental(
1682        &self,
1683        ctx: &HashMap<String, Value>,
1684    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1685        self.inner.fetch_with_context_incremental(ctx).await
1686    }
1687    // Forward `stream_pages` so the wrapped connector's *native* page stream
1688    // survives the wrap. Without this the trait's buffering default kicks in
1689    // for every stateful run — losing per-page bookmarks (CDC per-transaction
1690    // durability, exactly-once per-page tokens) and the O(batch_size) memory
1691    // bound.
1692    fn stream_pages<'a>(
1693        &'a self,
1694        ctx: &'a HashMap<String, Value>,
1695        batch_size: usize,
1696    ) -> std::pin::Pin<
1697        Box<
1698            dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
1699                + Send
1700                + 'a,
1701        >,
1702    > {
1703        self.inner.stream_pages(ctx, batch_size)
1704    }
1705    fn connector_name(&self) -> &'static str {
1706        self.inner.connector_name()
1707    }
1708    fn dataset_uri(&self) -> String {
1709        self.inner.dataset_uri()
1710    }
1711    fn state_key(&self) -> Option<String> {
1712        Some(self.key.clone())
1713    }
1714    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1715        self.inner.apply_start_bookmark(bookmark).await
1716    }
1717    fn supports_exactly_once(&self) -> bool {
1718        self.inner.supports_exactly_once()
1719    }
1720    fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
1721        self.inner.replay_guarantee()
1722    }
1723    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1724        self.inner.capture_resume_position().await
1725    }
1726}
1727
1728/// Forwards each record to an inner sink while also capturing a **projected**
1729/// copy into a shared buffer for descendant rows to consume. Projecting to only
1730/// the fields children reference bounds orchestrator memory (#160).
1731struct CapturingSink {
1732    inner: Box<dyn Sink>,
1733    captured: Arc<Mutex<Vec<Value>>>,
1734    projection: Arc<Projection>,
1735}
1736
1737impl CapturingSink {
1738    fn wrap(
1739        inner: Box<dyn Sink>,
1740        captured: Arc<Mutex<Vec<Value>>>,
1741        projection: Arc<Projection>,
1742    ) -> Self {
1743        Self {
1744            inner,
1745            captured,
1746            projection,
1747        }
1748    }
1749}
1750
1751#[async_trait]
1752impl Sink for CapturingSink {
1753    fn connector_name(&self) -> &'static str {
1754        self.inner.connector_name()
1755    }
1756    fn dataset_uri(&self) -> String {
1757        self.inner.dataset_uri()
1758    }
1759    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1760        let written = self.inner.write_batch(records).await?;
1761        // Capture only what actually landed (LimitedSink may have dropped some),
1762        // projected to the fields children reference (#160).
1763        let n = written.min(records.len());
1764        let mut buf = self.captured.lock().await;
1765        buf.extend(
1766            records
1767                .iter()
1768                .take(n)
1769                .map(|r| project_record(r, &self.projection)),
1770        );
1771        Ok(written)
1772    }
1773    async fn flush(&self) -> Result<(), FaucetError> {
1774        self.inner.flush().await
1775    }
1776    // Capability + exactly-once passthroughs, so a parent row that fans out to
1777    // children (which is what this wrapper serves) keeps the inner sink's
1778    // delivery semantics instead of being masked down to the trait defaults.
1779    fn supports_idempotent_writes(&self) -> bool {
1780        self.inner.supports_idempotent_writes()
1781    }
1782    fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
1783        self.inner.sink_guarantee()
1784    }
1785    fn dedups_by_key(&self) -> bool {
1786        self.inner.dedups_by_key()
1787    }
1788    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
1789        self.inner.supported_write_modes()
1790    }
1791    async fn write_batch_idempotent(
1792        &self,
1793        records: &[Value],
1794        scope: &str,
1795        token: &str,
1796    ) -> Result<usize, FaucetError> {
1797        let written = self
1798            .inner
1799            .write_batch_idempotent(records, scope, token)
1800            .await?;
1801        let n = written.min(records.len());
1802        let mut buf = self.captured.lock().await;
1803        buf.extend(
1804            records
1805                .iter()
1806                .take(n)
1807                .map(|r| project_record(r, &self.projection)),
1808        );
1809        Ok(written)
1810    }
1811    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1812        self.inner.last_committed_token(scope).await
1813    }
1814    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1815        self.inner.current_schema().await
1816    }
1817    fn supports_schema_evolution(&self) -> bool {
1818        self.inner.supports_schema_evolution()
1819    }
1820    async fn evolve_schema(
1821        &self,
1822        evolution: &faucet_core::SchemaEvolution,
1823    ) -> Result<(), FaucetError> {
1824        self.inner.evolve_schema(evolution).await
1825    }
1826}
1827
1828/// Cap on records written. Each `write_batch` call truncates `records` to the
1829/// remaining budget before delegating.
1830pub(crate) struct LimitedSink {
1831    inner: Box<dyn Sink>,
1832    remaining: AtomicUsize,
1833}
1834
1835impl LimitedSink {
1836    pub(crate) fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1837        Self {
1838            inner,
1839            remaining: AtomicUsize::new(cap),
1840        }
1841    }
1842}
1843
1844#[async_trait]
1845impl Sink for LimitedSink {
1846    fn connector_name(&self) -> &'static str {
1847        self.inner.connector_name()
1848    }
1849    fn dataset_uri(&self) -> String {
1850        self.inner.dataset_uri()
1851    }
1852    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1853        let remaining = self.remaining.load(Ordering::Relaxed);
1854        if remaining == 0 {
1855            return Ok(0);
1856        }
1857        let take = remaining.min(records.len());
1858        let slice = &records[..take];
1859        let written = self.inner.write_batch(slice).await?;
1860        self.remaining
1861            .fetch_sub(written.min(remaining), Ordering::Relaxed);
1862        Ok(written)
1863    }
1864    async fn flush(&self) -> Result<(), FaucetError> {
1865        self.inner.flush().await
1866    }
1867}
1868
1869/// No-op sink used in `--dry-run`. Counts records seen so the rest of the
1870/// pipeline (transforms, source) still runs.
1871pub(crate) struct CountingSink {
1872    seen: AtomicUsize,
1873}
1874
1875impl CountingSink {
1876    pub(crate) fn new() -> Self {
1877        Self {
1878            seen: AtomicUsize::new(0),
1879        }
1880    }
1881}
1882
1883#[async_trait]
1884impl Sink for CountingSink {
1885    fn connector_name(&self) -> &'static str {
1886        "dry-run"
1887    }
1888    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1889        self.seen.fetch_add(records.len(), Ordering::Relaxed);
1890        Ok(records.len())
1891    }
1892}
1893
1894/// Render a JSON value compactly for use as a state-key suffix or log line.
1895/// Strings pass through unquoted; numbers/bools/null/composites use to_string.
1896fn value_to_string_brief(v: &Value) -> String {
1897    match v {
1898        Value::String(s) => s.clone(),
1899        other => other.to_string(),
1900    }
1901}
1902
1903#[cfg(test)]
1904mod tests {
1905    use super::*;
1906    use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1907    use crate::expand::expand;
1908    use serde_json::json;
1909
1910    fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
1911        PipelineConfig {
1912            version: 1,
1913            name: Some("test".into()),
1914            vars: None,
1915            params: Default::default(),
1916            auth: None,
1917            pipeline: PipelineSpec {
1918                source: Some(ConnectorSpec {
1919                    kind: "csv".into(),
1920                    config: json!({"path": input.to_str().unwrap()}),
1921                    transforms: None,
1922                    inherit_transforms: true,
1923                    status: None,
1924                    tags: Vec::new(),
1925                }),
1926                sink: Some(ConnectorSpec {
1927                    kind: "jsonl".into(),
1928                    config: json!({"path": output.to_str().unwrap()}),
1929                    transforms: None,
1930                    inherit_transforms: true,
1931                    status: None,
1932                    tags: Vec::new(),
1933                }),
1934                sources: Default::default(),
1935                sinks: Default::default(),
1936                transforms: Vec::new(),
1937                state: None,
1938                dlq: None,
1939                #[cfg(feature = "quality")]
1940                quality: None,
1941                #[cfg(feature = "contract")]
1942                contract: None,
1943                #[cfg(feature = "masking")]
1944                masking: None,
1945                schema: None,
1946                nodes: std::collections::HashMap::new(),
1947                edges: Vec::new(),
1948            },
1949            matrix: Vec::new(),
1950            execution: None,
1951            selection: None,
1952            observability: None,
1953            delivery: faucet_core::DeliveryMode::default(),
1954            resilience: None,
1955            sla: None,
1956            shard: None,
1957            replication: None,
1958            backfill: None,
1959            #[cfg(feature = "schedule")]
1960            schedule: None,
1961            #[cfg(feature = "lineage")]
1962            lineage: None,
1963            #[cfg(feature = "catalog")]
1964            catalog: None,
1965            #[cfg(feature = "notify")]
1966            notifications: Vec::new(),
1967        }
1968    }
1969
1970    #[tokio::test]
1971    async fn empty_matrix_runs_pipeline_once() {
1972        let dir = tempfile::tempdir().unwrap();
1973        let input = dir.path().join("in.csv");
1974        let output = dir.path().join("out.jsonl");
1975        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
1976        let cfg = cfg_csv_to_jsonl(&input, &output);
1977        let nodes = expand(&cfg).unwrap();
1978        let summary = run_expanded(
1979            nodes,
1980            ExecuteOptions {
1981                pipeline_name: "t".into(),
1982                execution: None,
1983                dry_run: false,
1984                limit: None,
1985                state_path_override: None,
1986                shard: None,
1987                auth: Default::default(),
1988                clock: chrono::Utc::now().fixed_offset(),
1989                cancel: None,
1990                resilience: None,
1991                sla: None,
1992                #[cfg(feature = "lineage")]
1993                lineage: None,
1994                #[cfg(feature = "lineage")]
1995                lineage_cfg: None,
1996                #[cfg(feature = "notify")]
1997                notifier: None,
1998                #[cfg(feature = "catalog")]
1999                catalog: None,
2000            },
2001        )
2002        .await
2003        .unwrap();
2004        assert_eq!(summary.invocations.len(), 1);
2005        assert_eq!(summary.invocations[0].records_written, 2);
2006        assert!(!summary.had_failures());
2007        let body = std::fs::read_to_string(&output).unwrap();
2008        assert_eq!(body.lines().count(), 2);
2009    }
2010
2011    /// Minimal options with a catalog handle attached.
2012    #[cfg(feature = "catalog")]
2013    fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
2014        let mut o = opts(name);
2015        o.catalog = Some(handle);
2016        o
2017    }
2018
2019    #[cfg(feature = "catalog")]
2020    #[tokio::test]
2021    async fn catalog_records_schema_timeline_across_two_runs() {
2022        // Acceptance (#279): running the same pipeline twice with a schema
2023        // change in between produces exactly two schema-timeline entries for
2024        // the dataset, the second carrying a computed diff.
2025        use crate::catalog::CatalogHandle;
2026        use crate::serve::history::RunHistory as _;
2027        use crate::serve::history::catalog::{self, CatalogListFilter};
2028        use crate::serve::history::memory::MemoryHistory;
2029
2030        let dir = tempfile::tempdir().unwrap();
2031        let input = dir.path().join("in.csv");
2032        let output = dir.path().join("out.jsonl");
2033        let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
2034        let handle = CatalogHandle {
2035            store: store.clone(),
2036            run_id: None,
2037            sample_records: 10,
2038        };
2039
2040        std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
2041        let cfg = cfg_csv_to_jsonl(&input, &output);
2042        let nodes = expand(&cfg).unwrap();
2043        let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
2044            .await
2045            .unwrap();
2046        assert!(!summary.had_failures());
2047
2048        // Second run: same pipeline, schema gains an `email` column.
2049        std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
2050        let nodes = expand(&cfg).unwrap();
2051        let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
2052            .await
2053            .unwrap();
2054        assert!(!summary.had_failures());
2055
2056        // Two datasets (source + sink), each with a 2-entry deduped timeline.
2057        let page = store
2058            .catalog_list_datasets(&CatalogListFilter {
2059                limit: 10,
2060                ..Default::default()
2061            })
2062            .await
2063            .unwrap();
2064        assert_eq!(page.datasets.len(), 2, "source + sink datasets");
2065        for ds in &page.datasets {
2066            let detail = store
2067                .catalog_get_dataset(&ds.id)
2068                .await
2069                .unwrap()
2070                .expect("dataset detail");
2071            assert_eq!(detail.dataset.runs, 2);
2072            assert_eq!(
2073                detail.schema_timeline.len(),
2074                2,
2075                "exactly two timeline entries for {}",
2076                ds.uri
2077            );
2078            assert!(detail.schema_timeline[0].diff.is_none());
2079            let diff = detail.schema_timeline[1]
2080                .diff
2081                .as_ref()
2082                .expect("second version carries a diff");
2083            assert!(
2084                diff["added"]
2085                    .as_array()
2086                    .unwrap()
2087                    .iter()
2088                    .any(|c| c["column"] == "email"),
2089                "diff must show the added email column: {diff}"
2090            );
2091            assert_eq!(detail.stats.len(), 2, "one volume point per run");
2092        }
2093        // One lineage edge, csv → jsonl, traversed twice.
2094        let edges = store.catalog_lineage(None, 5).await.unwrap();
2095        assert_eq!(edges.len(), 1);
2096        assert_eq!(edges[0].runs, 2);
2097        assert_eq!(edges[0].last_records, 2);
2098        assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
2099    }
2100
2101    /// A catalog store whose writes always fail — drives the never-fail-the-run
2102    /// contract.
2103    #[cfg(feature = "catalog")]
2104    struct FailingCatalogStore;
2105
2106    #[cfg(feature = "catalog")]
2107    #[async_trait]
2108    impl crate::serve::history::RunHistory for FailingCatalogStore {
2109        async fn claim_idempotency(
2110            &self,
2111            _: &str,
2112            _: &str,
2113            _: &str,
2114            _: std::time::Duration,
2115        ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
2116            Err(crate::serve::history::HistoryError::Backend("down".into()))
2117        }
2118        async fn upsert(
2119            &self,
2120            _: &crate::serve::history::RunRecord,
2121        ) -> Result<(), crate::serve::history::HistoryError> {
2122            Err(crate::serve::history::HistoryError::Backend("down".into()))
2123        }
2124        async fn get(
2125            &self,
2126            _: &str,
2127        ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
2128        {
2129            Err(crate::serve::history::HistoryError::Backend("down".into()))
2130        }
2131        async fn list(
2132            &self,
2133            _: &crate::serve::history::ListFilter,
2134        ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
2135            Err(crate::serve::history::HistoryError::Backend("down".into()))
2136        }
2137        async fn delete(
2138            &self,
2139            _: &str,
2140        ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
2141        {
2142            Err(crate::serve::history::HistoryError::Backend("down".into()))
2143        }
2144        async fn purge_expired(
2145            &self,
2146            _: std::time::Duration,
2147        ) -> Result<usize, crate::serve::history::HistoryError> {
2148            Err(crate::serve::history::HistoryError::Backend("down".into()))
2149        }
2150        async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
2151            Err(crate::serve::history::HistoryError::Backend("down".into()))
2152        }
2153        async fn catalog_record(
2154            &self,
2155            _: &crate::serve::history::catalog::CatalogUpdate,
2156        ) -> Result<(), crate::serve::history::HistoryError> {
2157            Err(crate::serve::history::HistoryError::Backend(
2158                "catalog write refused".into(),
2159            ))
2160        }
2161        fn degraded(&self) -> bool {
2162            false
2163        }
2164    }
2165
2166    #[cfg(feature = "catalog")]
2167    #[tokio::test]
2168    async fn catalog_write_failure_never_fails_the_run() {
2169        // Acceptance (#279): a forced catalog-backend error degrades (logged)
2170        // while the pipeline still succeeds and writes its output.
2171        use crate::catalog::CatalogHandle;
2172        let dir = tempfile::tempdir().unwrap();
2173        let input = dir.path().join("in.csv");
2174        let output = dir.path().join("out.jsonl");
2175        std::fs::write(&input, "name\nalice\n").unwrap();
2176        let cfg = cfg_csv_to_jsonl(&input, &output);
2177        let nodes = expand(&cfg).unwrap();
2178        let handle = CatalogHandle {
2179            store: Arc::new(FailingCatalogStore),
2180            run_id: None,
2181            sample_records: 10,
2182        };
2183        let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
2184            .await
2185            .unwrap();
2186        assert!(
2187            !summary.had_failures(),
2188            "catalog failure must not fail the run"
2189        );
2190        assert_eq!(summary.invocations[0].records_written, 1);
2191        assert_eq!(
2192            std::fs::read_to_string(&output).unwrap().lines().count(),
2193            1,
2194            "sink output written despite the catalog error"
2195        );
2196    }
2197
2198    #[tokio::test]
2199    async fn matrix_two_independent_roots_both_run() {
2200        // Two roots: one writes alice, the other writes bob — to two separate files.
2201        let dir = tempfile::tempdir().unwrap();
2202        let csv_a = dir.path().join("a.csv");
2203        let csv_b = dir.path().join("b.csv");
2204        let out_a = dir.path().join("a.jsonl");
2205        let out_b = dir.path().join("b.jsonl");
2206        std::fs::write(&csv_a, "name\nalice\n").unwrap();
2207        std::fs::write(&csv_b, "name\nbob\n").unwrap();
2208
2209        let yaml = format!(
2210            r#"version: 1
2211pipeline:
2212  source: {{ type: csv, config: {{ path: {a} }} }}
2213  sink:   {{ type: jsonl, config: {{ path: {out_a} }} }}
2214matrix:
2215  - id: rowA
2216  - id: rowB
2217    source: {{ config: {{ path: {b} }} }}
2218    sink:   {{ config: {{ path: {out_b} }} }}
2219"#,
2220            a = csv_a.display(),
2221            b = csv_b.display(),
2222            out_a = out_a.display(),
2223            out_b = out_b.display(),
2224        );
2225        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2226        let nodes = expand(&cfg).unwrap();
2227        let summary = run_expanded(
2228            nodes,
2229            ExecuteOptions {
2230                pipeline_name: "matrix".into(),
2231                execution: None,
2232                dry_run: false,
2233                limit: None,
2234                state_path_override: None,
2235                shard: None,
2236                auth: Default::default(),
2237                clock: chrono::Utc::now().fixed_offset(),
2238                cancel: None,
2239                resilience: None,
2240                sla: None,
2241                #[cfg(feature = "lineage")]
2242                lineage: None,
2243                #[cfg(feature = "lineage")]
2244                lineage_cfg: None,
2245                #[cfg(feature = "notify")]
2246                notifier: None,
2247                #[cfg(feature = "catalog")]
2248                catalog: None,
2249            },
2250        )
2251        .await
2252        .unwrap();
2253        assert_eq!(summary.invocations.len(), 2);
2254        assert!(out_a.exists());
2255        assert!(out_b.exists());
2256    }
2257
2258    #[tokio::test]
2259    async fn dag_child_fans_out_per_parent_record() {
2260        // Parent: CSV with two records (id=1, id=2).
2261        // Child: writes one JSONL file per parent id, using ${parent.id} in the path.
2262        let dir = tempfile::tempdir().unwrap();
2263        let parent_csv = dir.path().join("parents.csv");
2264        let child_csv = dir.path().join("child.csv");
2265        std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
2266        std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
2267        let parent_out = dir.path().join("parents.jsonl");
2268        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2269
2270        let yaml = format!(
2271            r#"version: 1
2272pipeline:
2273  source: {{ type: csv, config: {{ path: {parent} }} }}
2274  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
2275matrix:
2276  - id: parents
2277  - id: child
2278    parent: parents
2279    source: {{ config: {{ path: {child} }} }}
2280    sink:   {{ config: {{ path: "{child_out}" }} }}
2281"#,
2282            parent = parent_csv.display(),
2283            parent_out = parent_out.display(),
2284            child = child_csv.display(),
2285            child_out = child_out_pattern.display(),
2286        );
2287        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2288        let nodes = expand(&cfg).unwrap();
2289        let summary = run_expanded(
2290            nodes,
2291            ExecuteOptions {
2292                pipeline_name: "dagtest".into(),
2293                execution: None,
2294                dry_run: false,
2295                limit: None,
2296                state_path_override: None,
2297                shard: None,
2298                auth: Default::default(),
2299                clock: chrono::Utc::now().fixed_offset(),
2300                cancel: None,
2301                resilience: None,
2302                sla: None,
2303                #[cfg(feature = "lineage")]
2304                lineage: None,
2305                #[cfg(feature = "lineage")]
2306                lineage_cfg: None,
2307                #[cfg(feature = "notify")]
2308                notifier: None,
2309                #[cfg(feature = "catalog")]
2310                catalog: None,
2311            },
2312        )
2313        .await
2314        .unwrap();
2315
2316        // 1 parent invocation + 2 child invocations.
2317        assert_eq!(summary.invocations.len(), 3);
2318        assert!(!summary.had_failures(), "{:?}", summary);
2319        assert!(dir.path().join("child-1.jsonl").exists());
2320        assert!(dir.path().join("child-2.jsonl").exists());
2321    }
2322
2323    #[tokio::test]
2324    async fn depends_on_root_runs_after_dependency() {
2325        // `stage` writes a CSV that `load` reads — `load` can only succeed if
2326        // it genuinely starts after `stage` finishes (pure ordering, no
2327        // record hand-off).
2328        let dir = tempfile::tempdir().unwrap();
2329        let input = dir.path().join("in.csv");
2330        let mid = dir.path().join("mid.csv");
2331        let out = dir.path().join("out.jsonl");
2332        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2333
2334        let yaml = format!(
2335            r#"version: 1
2336pipeline:
2337  source: {{ type: csv, config: {{ path: {input} }} }}
2338  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2339matrix:
2340  - id: stage
2341    sink: {{ type: csv, config: {{ path: {mid} }} }}
2342  - id: load
2343    depends_on: [stage]
2344    source: {{ config: {{ path: {mid} }} }}
2345"#,
2346            input = input.display(),
2347            mid = mid.display(),
2348            out = out.display(),
2349        );
2350        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2351        let nodes = expand(&cfg).unwrap();
2352        let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
2353        assert_eq!(summary.invocations.len(), 2, "{summary:?}");
2354        assert!(!summary.had_failures(), "{summary:?}");
2355        let load = summary
2356            .invocations
2357            .iter()
2358            .find(|i| i.row_id == "load")
2359            .unwrap();
2360        assert_eq!(load.records_written, 2);
2361        let written = std::fs::read_to_string(&out).unwrap();
2362        assert_eq!(written.lines().count(), 2);
2363    }
2364
2365    #[tokio::test]
2366    async fn diamond_dependency_waits_for_all_prerequisites() {
2367        // c waits on both a and b (a diamond join): readiness must require
2368        // *every* dependency to be terminal, not just the first.
2369        let dir = tempfile::tempdir().unwrap();
2370        let input = dir.path().join("in.csv");
2371        let mid_a = dir.path().join("mid_a.csv");
2372        let mid_b = dir.path().join("mid_b.csv");
2373        let out = dir.path().join("out.jsonl");
2374        std::fs::write(&input, "name\nalice\n").unwrap();
2375
2376        let yaml = format!(
2377            r#"version: 1
2378pipeline:
2379  source: {{ type: csv, config: {{ path: {input} }} }}
2380  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2381matrix:
2382  - id: a
2383    sink: {{ type: csv, config: {{ path: {mid_a} }} }}
2384  - id: b
2385    sink: {{ type: csv, config: {{ path: {mid_b} }} }}
2386  - id: c
2387    depends_on: [a, b]
2388    source: {{ config: {{ path: {mid_a} }} }}
2389"#,
2390            input = input.display(),
2391            mid_a = mid_a.display(),
2392            mid_b = mid_b.display(),
2393            out = out.display(),
2394        );
2395        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2396        let nodes = expand(&cfg).unwrap();
2397        let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
2398        assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2399        assert!(!summary.had_failures(), "{summary:?}");
2400        assert!(mid_b.exists(), "b must have run before c became ready");
2401        assert!(out.exists());
2402    }
2403
2404    #[tokio::test]
2405    async fn failed_dependency_skips_dependent() {
2406        // `stage` fails (missing input file); `load` depends on it and must be
2407        // skipped — no invocation outcome, no output file.
2408        let dir = tempfile::tempdir().unwrap();
2409        let good_input = dir.path().join("good.csv");
2410        let out = dir.path().join("out.jsonl");
2411        std::fs::write(&good_input, "name\nalice\n").unwrap();
2412
2413        let yaml = format!(
2414            r#"version: 1
2415pipeline:
2416  source: {{ type: csv, config: {{ path: {good} }} }}
2417  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2418matrix:
2419  - id: stage
2420    source: {{ config: {{ path: {missing} }} }}
2421  - id: load
2422    depends_on: [stage]
2423"#,
2424            good = good_input.display(),
2425            missing = dir.path().join("nonexistent.csv").display(),
2426            out = out.display(),
2427        );
2428        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2429        let nodes = expand(&cfg).unwrap();
2430        let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
2431        assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2432        assert_eq!(summary.invocations[0].row_id, "stage");
2433        assert!(summary.invocations[0].error.is_some());
2434        assert!(
2435            !out.exists(),
2436            "dependent row must not run after its dependency failed"
2437        );
2438    }
2439
2440    #[tokio::test]
2441    async fn dependency_on_skipped_row_cascades() {
2442        // p fails → its child c is skipped → q (which depends on c) must be
2443        // skipped too, even though c itself never *failed*.
2444        let dir = tempfile::tempdir().unwrap();
2445        let good_input = dir.path().join("good.csv");
2446        let out = dir.path().join("q.jsonl");
2447        std::fs::write(&good_input, "id\n1\n").unwrap();
2448
2449        let yaml = format!(
2450            r#"version: 1
2451pipeline:
2452  source: {{ type: csv, config: {{ path: {good} }} }}
2453  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2454matrix:
2455  - id: p
2456    source: {{ config: {{ path: {missing} }} }}
2457  - id: c
2458    parent: p
2459  - id: q
2460    depends_on: [c]
2461"#,
2462            good = good_input.display(),
2463            missing = dir.path().join("nonexistent.csv").display(),
2464            out = out.display(),
2465        );
2466        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2467        let nodes = expand(&cfg).unwrap();
2468        let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
2469        assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2470        assert_eq!(summary.invocations[0].row_id, "p");
2471        assert!(summary.invocations[0].error.is_some());
2472        assert!(
2473            !out.exists(),
2474            "q must be skipped when its dependency was skipped"
2475        );
2476    }
2477
2478    #[tokio::test]
2479    async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
2480        // First root writes to an invalid sink path and fails. The second
2481        // ("good") root would succeed. Under `on_error: stop` the executor
2482        // calls `abort_all()` on the first failure, which cancels pending /
2483        // in-flight tasks at their next await point — but that is
2484        // best-effort: with `max_concurrent: 1` the two roots race for the
2485        // single permit, so "good" may already have completed before "bad"
2486        // fails. We therefore assert the guarantees that hold under *any*
2487        // scheduling rather than an exact invocation count (which was racy,
2488        // see issue #78 finding #24). The deterministic "stop actually
2489        // cancels in-flight work" path is covered by
2490        // `on_error_stop_under_parallelism_aborts_other_in_flight`.
2491        let dir = tempfile::tempdir().unwrap();
2492        let good_csv = dir.path().join("good.csv");
2493        std::fs::write(&good_csv, "x\n1\n").unwrap();
2494        let good_out = dir.path().join("good.jsonl");
2495        let bad_sink_dir = dir.path().to_path_buf();
2496
2497        let yaml = format!(
2498            r#"version: 1
2499pipeline:
2500  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2501  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
2502matrix:
2503  - id: bad
2504    sink: {{ config: {{ path: {bad_dir} }} }}
2505  - id: good
2506execution:
2507  max_concurrent: 1
2508  on_error: stop
2509"#,
2510            good_csv = good_csv.display(),
2511            good_out = good_out.display(),
2512            bad_dir = bad_sink_dir.display(),
2513        );
2514        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2515        let nodes = expand(&cfg).unwrap();
2516        let summary = run_expanded(
2517            nodes,
2518            ExecuteOptions {
2519                pipeline_name: "stoptest".into(),
2520                execution: cfg.execution.clone(),
2521                dry_run: false,
2522                limit: None,
2523                state_path_override: None,
2524                shard: None,
2525                auth: Default::default(),
2526                clock: chrono::Utc::now().fixed_offset(),
2527                cancel: None,
2528                resilience: None,
2529                sla: None,
2530                #[cfg(feature = "lineage")]
2531                lineage: None,
2532                #[cfg(feature = "lineage")]
2533                lineage_cfg: None,
2534                #[cfg(feature = "notify")]
2535                notifier: None,
2536                #[cfg(feature = "catalog")]
2537                catalog: None,
2538            },
2539        )
2540        .await
2541        .unwrap();
2542
2543        // Invariants that hold regardless of which root won the permit race:
2544        assert!(summary.had_failures(), "the failing root must be reported");
2545
2546        // "bad" ran exactly once and is recorded as a failure.
2547        let bad: Vec<_> = summary
2548            .invocations
2549            .iter()
2550            .filter(|o| o.row_id == "bad")
2551            .collect();
2552        assert_eq!(bad.len(), 1, "bad must run exactly once");
2553        assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
2554
2555        // No duplicate / extra invocations beyond the two work units.
2556        assert!(
2557            summary.invocations.len() <= 2,
2558            "at most the two roots may run, got {:?}",
2559            summary.invocations
2560        );
2561
2562        // "good" may: (a) win the permit first and run fully (writes its row,
2563        // file exists); (b) lose the race, acquire the permit after "bad" fails,
2564        // observe the cooperative stop-cancel at its first page boundary, and
2565        // return a 0-record success (no file); or (c) never appear if it was
2566        // still pending when the level finished. So the only invariant is: a
2567        // "good" that actually WROTE records must have produced its file.
2568        let good_wrote = summary
2569            .invocations
2570            .iter()
2571            .find(|o| o.row_id == "good" && o.error.is_none())
2572            .map(|o| o.records_written)
2573            .unwrap_or(0);
2574        if good_wrote > 0 {
2575            assert!(
2576                good_out.exists(),
2577                "a good that wrote records must have produced its output file"
2578            );
2579        }
2580    }
2581
2582    #[tokio::test]
2583    async fn invalid_pipeline_name_with_state_errors_up_front() {
2584        // A pipeline name that can't form a valid state key must fail up front
2585        // (at unit construction) when state is configured — not deep mid-run
2586        // as a `FaucetError::State`.
2587        let dir = tempfile::tempdir().unwrap();
2588        let input = dir.path().join("in.csv");
2589        let output = dir.path().join("out.jsonl");
2590        std::fs::write(&input, "name\nalice\n").unwrap();
2591        let yaml = format!(
2592            r#"version: 1
2593pipeline:
2594  source: {{ type: csv, config: {{ path: {input} }} }}
2595  sink:   {{ type: jsonl, config: {{ path: {output} }} }}
2596  state:  {{ type: memory }}
2597"#,
2598            input = input.display(),
2599            output = output.display(),
2600        );
2601        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2602        let nodes = expand(&cfg).unwrap();
2603        let err = run_expanded(
2604            nodes,
2605            ExecuteOptions {
2606                pipeline_name: "bad name".into(), // space is illegal in a state key
2607                execution: None,
2608                dry_run: false,
2609                limit: None,
2610                state_path_override: None,
2611                shard: None,
2612                auth: Default::default(),
2613                clock: chrono::Utc::now().fixed_offset(),
2614                cancel: None,
2615                resilience: None,
2616                sla: None,
2617                #[cfg(feature = "lineage")]
2618                lineage: None,
2619                #[cfg(feature = "lineage")]
2620                lineage_cfg: None,
2621                #[cfg(feature = "notify")]
2622                notifier: None,
2623                #[cfg(feature = "catalog")]
2624                catalog: None,
2625            },
2626        )
2627        .await
2628        .expect_err("an invalid pipeline name must be rejected up front when state is configured");
2629        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2630    }
2631
2632    #[tokio::test]
2633    async fn invalid_parent_key_value_with_state_errors_up_front() {
2634        // A parent-record value that yields an illegal state-key suffix must
2635        // fail up front at the child's unit construction, not mid-run.
2636        let dir = tempfile::tempdir().unwrap();
2637        let parent_csv = dir.path().join("parents.csv");
2638        let child_csv = dir.path().join("child.csv");
2639        // The parent `id` value contains a space — illegal in a state key.
2640        std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
2641        std::fs::write(&child_csv, "x\nA\n").unwrap();
2642        let parent_out = dir.path().join("parents.jsonl");
2643        let child_out = dir.path().join("child.jsonl");
2644        let yaml = format!(
2645            r#"version: 1
2646pipeline:
2647  source: {{ type: csv, config: {{ path: {parent} }} }}
2648  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
2649  state:  {{ type: memory }}
2650matrix:
2651  - id: parents
2652  - id: child
2653    parent: parents
2654    source: {{ config: {{ path: {child} }} }}
2655    sink:   {{ config: {{ path: {child_out} }} }}
2656"#,
2657            parent = parent_csv.display(),
2658            parent_out = parent_out.display(),
2659            child = child_csv.display(),
2660            child_out = child_out.display(),
2661        );
2662        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2663        let nodes = expand(&cfg).unwrap();
2664        let err = run_expanded(
2665            nodes,
2666            ExecuteOptions {
2667                pipeline_name: "ok".into(),
2668                execution: None,
2669                dry_run: false,
2670                limit: None,
2671                state_path_override: None,
2672                shard: None,
2673                auth: Default::default(),
2674                clock: chrono::Utc::now().fixed_offset(),
2675                cancel: None,
2676                resilience: None,
2677                sla: None,
2678                #[cfg(feature = "lineage")]
2679                lineage: None,
2680                #[cfg(feature = "lineage")]
2681                lineage_cfg: None,
2682                #[cfg(feature = "notify")]
2683                notifier: None,
2684                #[cfg(feature = "catalog")]
2685                catalog: None,
2686            },
2687        )
2688        .await
2689        .expect_err(
2690            "an illegal parent-key value must be rejected up front when state is configured",
2691        );
2692        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2693    }
2694
2695    #[tokio::test]
2696    async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
2697        // Three roots running with `max_concurrent: 3`. The bad row points
2698        // its sink at a directory (open fails fast). The other two point at
2699        // sinks that block forever on the writer end of a pipe — stuck *inside*
2700        // the sink write, they never reach a page boundary to observe the
2701        // cooperative stop-cancel, so the only way they can complete is the
2702        // hard-abort backstop that fires after the flush grace (#146 H16). The
2703        // test would hang if `on_error: stop` never aborted them, so a passing
2704        // run is itself the assertion.
2705        let dir = tempfile::tempdir().unwrap();
2706        let bad_sink_dir = dir.path().to_path_buf();
2707        // A real csv source with one row — small enough that the pipeline
2708        // proceeds straight to the sink phase.
2709        let good_csv = dir.path().join("good.csv");
2710        std::fs::write(&good_csv, "x\n1\n").unwrap();
2711        // The two "would never finish" sinks point at the same path as the
2712        // bad sink (an existing directory). Their sink-open also errors
2713        // out — but we still verify the *abort* path by counting how many
2714        // tasks make it past spawn before stop fires. The strict invariant
2715        // we assert: the bad row's failure is the first one observed.
2716        let yaml = format!(
2717            r#"version: 1
2718pipeline:
2719  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2720  sink:   {{ type: jsonl, config: {{ path: {bad_dir} }} }}
2721matrix:
2722  - id: bad
2723  - id: good_a
2724  - id: good_b
2725execution:
2726  max_concurrent: 3
2727  on_error: stop
2728"#,
2729            good_csv = good_csv.display(),
2730            bad_dir = bad_sink_dir.display(),
2731        );
2732        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2733        let nodes = expand(&cfg).unwrap();
2734        let summary = run_expanded(
2735            nodes,
2736            ExecuteOptions {
2737                pipeline_name: "stop_parallel".into(),
2738                execution: cfg.execution.clone(),
2739                dry_run: false,
2740                limit: None,
2741                state_path_override: None,
2742                shard: None,
2743                auth: Default::default(),
2744                clock: chrono::Utc::now().fixed_offset(),
2745                cancel: None,
2746                resilience: None,
2747                sla: None,
2748                #[cfg(feature = "lineage")]
2749                lineage: None,
2750                #[cfg(feature = "lineage")]
2751                lineage_cfg: None,
2752                #[cfg(feature = "notify")]
2753                notifier: None,
2754                #[cfg(feature = "catalog")]
2755                catalog: None,
2756            },
2757        )
2758        .await
2759        .unwrap();
2760
2761        // First-observed failure halts the run. The first outcome in the
2762        // summary is guaranteed to be a failure (other tasks either fail
2763        // too or get cancelled — both cases never push a *success* outcome
2764        // first because every sink in this matrix is configured to fail).
2765        assert!(
2766            summary.had_failures(),
2767            "summary should record at least one failure: {summary:?}"
2768        );
2769        assert!(
2770            summary.invocations[0].error.is_some(),
2771            "first outcome must be the failure that triggered stop: {summary:?}"
2772        );
2773        // No invocation should report `records_written > 0` — every sink is
2774        // bad. (Catches a regression where abort_all somehow let a task
2775        // bypass its broken sink.)
2776        for inv in &summary.invocations {
2777            assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
2778        }
2779    }
2780
2781    #[tokio::test]
2782    async fn on_error_continue_skips_failed_subtree_only() {
2783        // Two roots: one fails. The good one's invocation still completes.
2784        let dir = tempfile::tempdir().unwrap();
2785        let good_csv = dir.path().join("good.csv");
2786        std::fs::write(&good_csv, "x\n1\n").unwrap();
2787        let good_out = dir.path().join("good.jsonl");
2788
2789        let yaml = format!(
2790            r#"version: 1
2791pipeline:
2792  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2793  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
2794matrix:
2795  - id: bad
2796    sink: {{ config: {{ path: {bad_dir} }} }}
2797  - id: good
2798"#,
2799            good_csv = good_csv.display(),
2800            good_out = good_out.display(),
2801            bad_dir = dir.path().display(),
2802        );
2803        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2804        let nodes = expand(&cfg).unwrap();
2805        let summary = run_expanded(
2806            nodes,
2807            ExecuteOptions {
2808                pipeline_name: "continuetest".into(),
2809                execution: None,
2810                dry_run: false,
2811                limit: None,
2812                state_path_override: None,
2813                shard: None,
2814                auth: Default::default(),
2815                clock: chrono::Utc::now().fixed_offset(),
2816                cancel: None,
2817                resilience: None,
2818                sla: None,
2819                #[cfg(feature = "lineage")]
2820                lineage: None,
2821                #[cfg(feature = "lineage")]
2822                lineage_cfg: None,
2823                #[cfg(feature = "notify")]
2824                notifier: None,
2825                #[cfg(feature = "catalog")]
2826                catalog: None,
2827            },
2828        )
2829        .await
2830        .unwrap();
2831        assert_eq!(summary.invocations.len(), 2);
2832        assert_eq!(summary.failure_count(), 1);
2833        let good_outcome = summary
2834            .invocations
2835            .iter()
2836            .find(|i| i.row_id == "good")
2837            .unwrap();
2838        assert!(good_outcome.error.is_none());
2839    }
2840
2841    // ── projection helpers (#160) ─────────────────────────────────────────────
2842
2843    #[test]
2844    fn split_path_splits_on_dots() {
2845        assert_eq!(split_path("id"), vec!["id".to_string()]);
2846        assert_eq!(
2847            split_path("user.name"),
2848            vec!["user".to_string(), "name".to_string()]
2849        );
2850    }
2851
2852    #[test]
2853    fn minimal_paths_drops_descendants_of_kept_ancestors() {
2854        let paths = vec![
2855            vec!["user".into(), "name".into()],
2856            vec!["user".into()],
2857            vec!["id".into()],
2858            vec!["id".into()],
2859        ];
2860        let min = minimal_paths(paths);
2861        assert!(min.contains(&vec!["user".to_string()]));
2862        assert!(min.contains(&vec!["id".to_string()]));
2863        assert!(
2864            !min.contains(&vec!["user".to_string(), "name".to_string()]),
2865            "user.name must be dropped — covered by user"
2866        );
2867        assert_eq!(min.len(), 2);
2868    }
2869
2870    #[test]
2871    fn project_full_clones_whole_record() {
2872        let r = json!({"a": 1, "b": {"c": 2}});
2873        assert_eq!(project_record(&r, &Projection::Full), r);
2874    }
2875
2876    #[test]
2877    fn project_keeps_only_referenced_paths() {
2878        let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
2879        let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
2880        let got = project_record(&r, &p);
2881        assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
2882        assert!(got.get("blob").is_none());
2883        assert!(got["user"].get("age").is_none());
2884    }
2885
2886    #[test]
2887    fn project_array_index_path_resolves_same_as_original() {
2888        let r = json!({"tags": ["x", "y", "z"]});
2889        let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
2890        let got = project_record(&r, &p);
2891        assert_eq!(got, json!({"tags": {"0": "x"}}));
2892        assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
2893        assert_eq!(
2894            resolve_parent_key(&got, "tags.0"),
2895            resolve_parent_key(&r, "tags.0"),
2896            "reduced tree must resolve the same value as the original"
2897        );
2898    }
2899
2900    #[test]
2901    fn project_numeric_object_key_resolves_same_as_original() {
2902        // A numeric segment can address an OBJECT key in the original (not an
2903        // array index). The reduced all-objects tree stores it under the same
2904        // key, so resolution still matches — the parity property the design
2905        // relies on, distinct from the array-index case above.
2906        let r = json!({"data": {"0": "x", "1": "y"}});
2907        let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
2908        let got = project_record(&r, &p);
2909        assert_eq!(got, json!({"data": {"0": "x"}}));
2910        assert_eq!(
2911            resolve_parent_key(&got, "data.0"),
2912            resolve_parent_key(&r, "data.0"),
2913            "numeric object-key path must resolve identically on the reduced tree"
2914        );
2915    }
2916
2917    #[test]
2918    fn project_missing_path_is_omitted() {
2919        let r = json!({"id": 1});
2920        let p = Projection::Paths(vec![vec!["nope".into()]]);
2921        assert_eq!(project_record(&r, &p), json!({}));
2922    }
2923
2924    #[test]
2925    fn build_projections_unions_parent_key_and_refs() {
2926        use crate::config::ConnectorSpec;
2927        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2928
2929        fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
2930            ExpandedNode {
2931                id: id.into(),
2932                row_index: 0,
2933                role: NodeRole::Child {
2934                    parent_id: parent.into(),
2935                    parent_key: parent_key.into(),
2936                },
2937                source: ConnectorSpec {
2938                    kind: "csv".into(),
2939                    config: json!({}),
2940                    transforms: None,
2941                    inherit_transforms: true,
2942                    status: None,
2943                    tags: Vec::new(),
2944                },
2945                sink: ConnectorSpec {
2946                    kind: "jsonl".into(),
2947                    config: json!({}),
2948                    transforms: None,
2949                    inherit_transforms: true,
2950                    status: None,
2951                    tags: Vec::new(),
2952                },
2953                transforms: Vec::new(),
2954                state: None,
2955                dlq: None,
2956                delivery: faucet_core::DeliveryMode::AtLeastOnce,
2957                delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
2958                #[cfg(feature = "quality")]
2959                quality: None,
2960                #[cfg(feature = "contract")]
2961                contract: None,
2962                #[cfg(feature = "masking")]
2963                masking: None,
2964                sink_ref: "default".into(),
2965                schema: None,
2966                depends_on: Vec::new(),
2967                status: crate::config::SourceStatus::Active,
2968                tags: Vec::new(),
2969                deferred_refs: refs
2970                    .iter()
2971                    .map(|(rid, p)| DeferredRef {
2972                        referenced_id: (*rid).into(),
2973                        dotted_path: (*p).into(),
2974                        token: format!("${{{rid}.{p}}}"),
2975                    })
2976                    .collect(),
2977                source_override: None,
2978            }
2979        }
2980
2981        let c1 = child("c1", "p", "id", &[("p", "user.name")]);
2982        let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
2983        let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
2984        let children_of =
2985            HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
2986
2987        let projs = build_projections(&nodes_by_id, &children_of);
2988        let p = projs.get("p").expect("projection for p");
2989        match &**p {
2990            Projection::Paths(paths) => {
2991                assert!(paths.contains(&vec!["id".to_string()]));
2992                assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
2993                assert!(paths.contains(&vec!["email".to_string()]));
2994                assert!(
2995                    !paths.iter().any(|p| p == &vec!["x".to_string()]),
2996                    "a ref to a different parent must not be captured under p"
2997                );
2998            }
2999            Projection::Full => panic!("expected Paths, got Full"),
3000        }
3001    }
3002
3003    #[test]
3004    fn build_projections_whole_record_ref_is_full() {
3005        use crate::config::ConnectorSpec;
3006        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3007        let c = ExpandedNode {
3008            id: "c".into(),
3009            row_index: 0,
3010            role: NodeRole::Child {
3011                parent_id: "p".into(),
3012                parent_key: "id".into(),
3013            },
3014            source: ConnectorSpec {
3015                kind: "csv".into(),
3016                config: json!({}),
3017                transforms: None,
3018                inherit_transforms: true,
3019                status: None,
3020                tags: Vec::new(),
3021            },
3022            sink: ConnectorSpec {
3023                kind: "jsonl".into(),
3024                config: json!({}),
3025                transforms: None,
3026                inherit_transforms: true,
3027                status: None,
3028                tags: Vec::new(),
3029            },
3030            transforms: Vec::new(),
3031            state: None,
3032            dlq: None,
3033            delivery: faucet_core::DeliveryMode::AtLeastOnce,
3034            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3035            #[cfg(feature = "quality")]
3036            quality: None,
3037            #[cfg(feature = "contract")]
3038            contract: None,
3039            #[cfg(feature = "masking")]
3040            masking: None,
3041            sink_ref: "default".into(),
3042            schema: None,
3043            depends_on: Vec::new(),
3044            status: crate::config::SourceStatus::Active,
3045            tags: Vec::new(),
3046            deferred_refs: vec![DeferredRef {
3047                referenced_id: "p".into(),
3048                dotted_path: "".into(),
3049                token: "${p}".into(),
3050            }],
3051            source_override: None,
3052        };
3053        let nodes_by_id = HashMap::from([("c".to_string(), c)]);
3054        let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
3055        let projs = build_projections(&nodes_by_id, &children_of);
3056        assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
3057    }
3058
3059    /// Helper: minimal `ExecuteOptions` with all optional knobs cleared.
3060    fn opts(name: &str) -> ExecuteOptions {
3061        ExecuteOptions {
3062            pipeline_name: name.into(),
3063            execution: None,
3064            dry_run: false,
3065            limit: None,
3066            state_path_override: None,
3067            shard: None,
3068            auth: Default::default(),
3069            clock: chrono::Utc::now().fixed_offset(),
3070            cancel: None,
3071            resilience: None,
3072            sla: None,
3073            #[cfg(feature = "lineage")]
3074            lineage: None,
3075            #[cfg(feature = "lineage")]
3076            lineage_cfg: None,
3077            #[cfg(feature = "notify")]
3078            notifier: None,
3079            #[cfg(feature = "catalog")]
3080            catalog: None,
3081        }
3082    }
3083
3084    #[tokio::test]
3085    async fn dry_run_counts_records_without_writing_sink_file() {
3086        // `--dry-run` swaps the real sink for a CountingSink — records flow but
3087        // no output file is produced.
3088        let dir = tempfile::tempdir().unwrap();
3089        let input = dir.path().join("in.csv");
3090        let output = dir.path().join("out.jsonl");
3091        std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
3092        let cfg = cfg_csv_to_jsonl(&input, &output);
3093        let nodes = expand(&cfg).unwrap();
3094        let mut o = opts("dry");
3095        o.dry_run = true;
3096        let summary = run_expanded(nodes, o).await.unwrap();
3097        assert_eq!(summary.invocations.len(), 1);
3098        assert_eq!(summary.invocations[0].records_written, 3);
3099        assert!(!summary.had_failures());
3100        assert!(
3101            !output.exists(),
3102            "dry-run must not create the real sink file"
3103        );
3104    }
3105
3106    #[tokio::test]
3107    async fn read_only_state_store_drops_writes_keeps_reads() {
3108        // #321 H1: reads pass through; put/delete are dropped so a preview never
3109        // mutates the durable bookmark.
3110        let inner = Arc::new(faucet_core::MemoryStateStore::new()) as Arc<dyn StateStore>;
3111        inner.put("k", &json!("v0")).await.unwrap();
3112        let ro = ReadOnlyStateStore {
3113            inner: inner.clone(),
3114        };
3115        assert_eq!(ro.get("k").await.unwrap(), Some(json!("v0")));
3116        // A write must be a no-op — the inner store keeps its original value.
3117        ro.put("k", &json!("advanced")).await.unwrap();
3118        assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3119        // A delete must be a no-op too.
3120        ro.delete("k").await.unwrap();
3121        assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3122    }
3123
3124    #[tokio::test]
3125    async fn dry_run_with_state_does_not_persist_bookmark() {
3126        // #321 H1: a `--dry-run` with a durable state store must not advance the
3127        // persisted bookmark. Pre-seed a bookmark file, run dry, and confirm it is
3128        // byte-for-byte unchanged (the ReadOnlyStateStore wrapper drops writes).
3129        let dir = tempfile::tempdir().unwrap();
3130        let input = dir.path().join("in.csv");
3131        let output = dir.path().join("out.jsonl");
3132        let state_dir = dir.path().join("state");
3133        std::fs::create_dir_all(&state_dir).unwrap();
3134        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3135        let cfg = cfg_csv_to_jsonl(&input, &output);
3136        let nodes = expand(&cfg).unwrap();
3137        let mut o = opts("drystate");
3138        o.dry_run = true;
3139        o.state_path_override = Some(state_dir.clone());
3140        let summary = run_expanded(nodes, o).await.unwrap();
3141        assert!(!summary.had_failures());
3142        assert!(!output.exists(), "dry-run must not write the sink file");
3143        // The state store is wrapped read-only under dry-run, so no bookmark
3144        // file is ever persisted — the state dir stays empty.
3145        let persisted: Vec<_> = std::fs::read_dir(&state_dir)
3146            .unwrap()
3147            .filter_map(Result::ok)
3148            .collect();
3149        assert!(
3150            persisted.is_empty(),
3151            "dry-run must not persist any bookmark file, found: {persisted:?}"
3152        );
3153    }
3154
3155    #[tokio::test]
3156    async fn limit_caps_records_written_across_the_run() {
3157        // `--limit N` wraps the sink so only the first N records land.
3158        let dir = tempfile::tempdir().unwrap();
3159        let input = dir.path().join("in.csv");
3160        let output = dir.path().join("out.jsonl");
3161        std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
3162        let cfg = cfg_csv_to_jsonl(&input, &output);
3163        let nodes = expand(&cfg).unwrap();
3164        let mut o = opts("lim");
3165        o.limit = Some(2);
3166        let summary = run_expanded(nodes, o).await.unwrap();
3167        assert_eq!(summary.invocations[0].records_written, 2);
3168        let body = std::fs::read_to_string(&output).unwrap();
3169        assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
3170    }
3171
3172    #[tokio::test]
3173    async fn duplicate_state_key_among_siblings_is_rejected() {
3174        // Two parent records whose `parent_key` value collides (both id="dup")
3175        // produce two child units with the SAME state key. With state
3176        // configured, that collision must surface as DuplicateStateKey.
3177        let dir = tempfile::tempdir().unwrap();
3178        let parent_csv = dir.path().join("parents.csv");
3179        let child_csv = dir.path().join("child.csv");
3180        // Both rows share id="dup" — the per-child state-key suffix collides.
3181        std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
3182        std::fs::write(&child_csv, "x\nA\n").unwrap();
3183        let parent_out = dir.path().join("parents.jsonl");
3184        let child_out = dir.path().join("child.jsonl");
3185        let yaml = format!(
3186            r#"version: 1
3187pipeline:
3188  source: {{ type: csv, config: {{ path: {parent} }} }}
3189  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
3190  state:  {{ type: memory }}
3191matrix:
3192  - id: parents
3193  - id: child
3194    parent: parents
3195    source: {{ config: {{ path: {child} }} }}
3196    sink:   {{ config: {{ path: {child_out} }} }}
3197"#,
3198            parent = parent_csv.display(),
3199            parent_out = parent_out.display(),
3200            child = child_csv.display(),
3201            child_out = child_out.display(),
3202        );
3203        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3204        let nodes = expand(&cfg).unwrap();
3205        let err = run_expanded(nodes, opts("dupkey"))
3206            .await
3207            .expect_err("colliding sibling state keys must be rejected");
3208        match err {
3209            CliError::DuplicateStateKey { id, state_key } => {
3210                assert_eq!(id, "child");
3211                assert_eq!(state_key, "dupkey::child::dup");
3212            }
3213            other => panic!("expected DuplicateStateKey, got {other:?}"),
3214        }
3215    }
3216
3217    #[tokio::test]
3218    async fn state_path_override_writes_bookmark_file() {
3219        // `--state-path` with a node that has no `state:` block wires a
3220        // FileStateStore at the override path; running it should create the
3221        // bookmark file (REST-less csv source still opts into state via the
3222        // override).
3223        let dir = tempfile::tempdir().unwrap();
3224        let input = dir.path().join("in.csv");
3225        let output = dir.path().join("out.jsonl");
3226        let state_dir = dir.path().join("state");
3227        std::fs::write(&input, "name\nalice\n").unwrap();
3228        let cfg = cfg_csv_to_jsonl(&input, &output);
3229        let nodes = expand(&cfg).unwrap();
3230        let mut o = opts("statepath");
3231        o.state_path_override = Some(state_dir.clone());
3232        let summary = run_expanded(nodes, o).await.unwrap();
3233        assert!(!summary.had_failures());
3234        // The csv source has no natural state key, so the StateKeyOverride wrap
3235        // is skipped — but build_state_for_node still constructs the store.
3236        // The run completing without error exercises the (None, Some(path)) arm.
3237        assert_eq!(summary.invocations[0].records_written, 1);
3238    }
3239
3240    #[tokio::test]
3241    async fn build_dlq_config_maps_spec_fields() {
3242        use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
3243        let dir = tempfile::tempdir().unwrap();
3244        let dlq_out = dir.path().join("dlq.jsonl");
3245        let spec = DlqSpec {
3246            sink: ConnectorSpec {
3247                kind: "jsonl".into(),
3248                config: json!({ "path": dlq_out.to_str().unwrap() }),
3249                transforms: None,
3250                inherit_transforms: true,
3251                status: None,
3252                tags: Vec::new(),
3253            },
3254            on_batch_error: OnBatchErrorSpec::DlqAll,
3255            max_failures_per_page: Some(7),
3256            max_failures_total: Some(42),
3257            include_original_payload: false,
3258        };
3259        let cfg = build_dlq_config(&spec).await.unwrap();
3260        assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
3261        assert_eq!(cfg.max_failures_per_page, Some(7));
3262        assert_eq!(cfg.max_failures_total, Some(42));
3263        assert!(!cfg.include_original_payload);
3264    }
3265
3266    #[tokio::test]
3267    async fn build_state_for_node_arms() {
3268        let dir = tempfile::tempdir().unwrap();
3269
3270        // (None, None) → no store.
3271        let node = stub_node(None);
3272        assert!(build_state_for_node(&node, None).await.unwrap().is_none());
3273
3274        // (None, Some(path)) → FileStateStore from override.
3275        let p = dir.path().join("s1");
3276        assert!(
3277            build_state_for_node(&node, Some(&p))
3278                .await
3279                .unwrap()
3280                .is_some()
3281        );
3282
3283        // (Some(memory spec), None) → built from spec.
3284        let node_mem = stub_node(Some(crate::config::StateStoreSpec {
3285            kind: "memory".into(),
3286            config: json!({}),
3287        }));
3288        assert!(
3289            build_state_for_node(&node_mem, None)
3290                .await
3291                .unwrap()
3292                .is_some()
3293        );
3294
3295        // (Some(file spec), Some(path)) → file backend uses the override path.
3296        let node_file = stub_node(Some(crate::config::StateStoreSpec {
3297            kind: "file".into(),
3298            config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
3299        }));
3300        let p2 = dir.path().join("override2");
3301        assert!(
3302            build_state_for_node(&node_file, Some(&p2))
3303                .await
3304                .unwrap()
3305                .is_some()
3306        );
3307
3308        // (Some(memory spec), Some(path)) → non-file backend ignores override,
3309        // still builds from spec.
3310        let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
3311            kind: "memory".into(),
3312            config: json!({}),
3313        }));
3314        let p3 = dir.path().join("override3");
3315        assert!(
3316            build_state_for_node(&node_mem2, Some(&p3))
3317                .await
3318                .unwrap()
3319                .is_some()
3320        );
3321    }
3322
3323    /// Build a minimal root `ExpandedNode` carrying only an (optional) state spec.
3324    fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
3325        use crate::config::ConnectorSpec;
3326        ExpandedNode {
3327            id: "n".into(),
3328            row_index: 0,
3329            role: NodeRole::Root,
3330            source: ConnectorSpec {
3331                kind: "csv".into(),
3332                config: json!({}),
3333                transforms: None,
3334                inherit_transforms: true,
3335                status: None,
3336                tags: Vec::new(),
3337            },
3338            sink: ConnectorSpec {
3339                kind: "jsonl".into(),
3340                config: json!({}),
3341                transforms: None,
3342                inherit_transforms: true,
3343                status: None,
3344                tags: Vec::new(),
3345            },
3346            transforms: Vec::new(),
3347            state,
3348            dlq: None,
3349            delivery: faucet_core::DeliveryMode::AtLeastOnce,
3350            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3351            #[cfg(feature = "quality")]
3352            quality: None,
3353            #[cfg(feature = "contract")]
3354            contract: None,
3355            #[cfg(feature = "masking")]
3356            masking: None,
3357            sink_ref: "default".into(),
3358            schema: None,
3359            depends_on: Vec::new(),
3360            status: crate::config::SourceStatus::Active,
3361            tags: Vec::new(),
3362            deferred_refs: Vec::new(),
3363            source_override: None,
3364        }
3365    }
3366
3367    #[tokio::test]
3368    async fn state_key_override_delegates_and_overrides_key() {
3369        // StateKeyOverride forwards fetch/bookmark to inner and reports its own key.
3370        let dir = tempfile::tempdir().unwrap();
3371        let input = dir.path().join("in.csv");
3372        std::fs::write(&input, "name\nz\n").unwrap();
3373        let inner = build_source(
3374            "csv",
3375            json!({"path": input.to_str().unwrap()}),
3376            &AuthCatalog::new(),
3377            None,
3378        )
3379        .await
3380        .unwrap();
3381        // The wrapped name must match the inner source's, whatever it reports.
3382        let inner_name = inner.connector_name();
3383        let ov = StateKeyOverride {
3384            inner,
3385            key: "my::custom::key".into(),
3386        };
3387        assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
3388        assert_eq!(ov.connector_name(), inner_name);
3389        let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
3390        assert_eq!(rows.len(), 1);
3391        // apply_start_bookmark delegates without error (csv ignores it).
3392        ov.apply_start_bookmark(json!({"any": "bookmark"}))
3393            .await
3394            .unwrap();
3395        // Capability passthroughs (csv defaults).
3396        assert!(!ov.supports_exactly_once());
3397        assert_eq!(
3398            ov.replay_guarantee(),
3399            faucet_core::ReplayGuarantee::NonDeterministic
3400        );
3401        assert_eq!(ov.capture_resume_position().await.unwrap(), None);
3402    }
3403
3404    #[tokio::test]
3405    async fn state_key_override_forwards_native_stream_pages() {
3406        // The wrap must preserve the inner source's NATIVE page stream —
3407        // per-page bookmarks included. Without the `stream_pages` forward, the
3408        // trait's buffering default kicks in and collapses everything into
3409        // final-page-bookmark-only pages (losing CDC per-transaction
3410        // durability and exactly-once per-page tokens).
3411        struct PerPageBookmarkSource;
3412        #[async_trait]
3413        impl Source for PerPageBookmarkSource {
3414            async fn fetch_with_context(
3415                &self,
3416                _ctx: &HashMap<String, Value>,
3417            ) -> Result<Vec<Value>, FaucetError> {
3418                Ok(vec![json!({"id": 1}), json!({"id": 2})])
3419            }
3420            fn stream_pages<'a>(
3421                &'a self,
3422                _ctx: &'a HashMap<String, Value>,
3423                _batch_size: usize,
3424            ) -> std::pin::Pin<
3425                Box<
3426                    dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
3427                        + Send
3428                        + 'a,
3429                >,
3430            > {
3431                Box::pin(faucet_core::async_stream::try_stream! {
3432                    yield faucet_core::StreamPage {
3433                        records: vec![json!({"id": 1})],
3434                        bookmark: Some(json!("bm-1")),
3435                    };
3436                    yield faucet_core::StreamPage {
3437                        records: vec![json!({"id": 2})],
3438                        bookmark: Some(json!("bm-2")),
3439                    };
3440                })
3441            }
3442            fn state_key(&self) -> Option<String> {
3443                Some("native".into())
3444            }
3445        }
3446
3447        use futures::StreamExt;
3448        let ov = StateKeyOverride {
3449            inner: Box::new(PerPageBookmarkSource),
3450            key: "override".into(),
3451        };
3452        let ctx = HashMap::new();
3453        let pages: Vec<_> = ov
3454            .stream_pages(&ctx, 1000)
3455            .collect::<Vec<_>>()
3456            .await
3457            .into_iter()
3458            .collect::<Result<Vec<_>, _>>()
3459            .unwrap();
3460        assert_eq!(pages.len(), 2, "native page boundaries survive the wrap");
3461        assert_eq!(pages[0].bookmark, Some(json!("bm-1")));
3462        assert_eq!(pages[1].bookmark, Some(json!("bm-2")));
3463    }
3464
3465    #[tokio::test]
3466    async fn capturing_sink_forwards_capabilities_and_captures_idempotent_writes() {
3467        struct IdemSink;
3468        #[async_trait]
3469        impl Sink for IdemSink {
3470            async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3471                Ok(records.len())
3472            }
3473            fn connector_name(&self) -> &'static str {
3474                "idem"
3475            }
3476            fn supports_idempotent_writes(&self) -> bool {
3477                true
3478            }
3479            fn dedups_by_key(&self) -> bool {
3480                true
3481            }
3482            fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
3483                &[
3484                    faucet_core::WriteMode::Append,
3485                    faucet_core::WriteMode::Upsert,
3486                ]
3487            }
3488            async fn write_batch_idempotent(
3489                &self,
3490                records: &[Value],
3491                _scope: &str,
3492                _token: &str,
3493            ) -> Result<usize, FaucetError> {
3494                Ok(records.len())
3495            }
3496            async fn last_committed_token(
3497                &self,
3498                _scope: &str,
3499            ) -> Result<Option<String>, FaucetError> {
3500                Ok(Some("tok".into()))
3501            }
3502        }
3503
3504        let captured = Arc::new(Mutex::new(Vec::new()));
3505        let sink = CapturingSink::wrap(
3506            Box::new(IdemSink),
3507            Arc::clone(&captured),
3508            Arc::new(Projection::Full),
3509        );
3510        // Capability passthroughs: a parent row feeding children keeps the
3511        // inner sink's delivery semantics.
3512        assert!(sink.supports_idempotent_writes());
3513        assert!(sink.dedups_by_key());
3514        assert_eq!(
3515            sink.sink_guarantee(),
3516            faucet_core::SinkGuarantee::AtomicWatermark
3517        );
3518        assert!(
3519            sink.supported_write_modes()
3520                .contains(&faucet_core::WriteMode::Upsert)
3521        );
3522        assert_eq!(
3523            sink.last_committed_token("k").await.unwrap(),
3524            Some("tok".into())
3525        );
3526        assert_eq!(sink.current_schema().await.unwrap(), None);
3527        assert!(!sink.supports_schema_evolution());
3528        // Idempotent writes are captured for child fan-out like plain writes.
3529        let n = sink
3530            .write_batch_idempotent(&[json!({"id": 7})], "k", "t")
3531            .await
3532            .unwrap();
3533        assert_eq!(n, 1);
3534        assert_eq!(*captured.lock().await, vec![json!({"id": 7})]);
3535    }
3536
3537    #[tokio::test]
3538    async fn orphaned_child_surfaces_executor_deadlock() {
3539        // A child node whose parent id is never present among the nodes can
3540        // never become ready. `expand` would reject this, but a hand-built node
3541        // list exercises the executor's own deadlock guard (lines 227-233).
3542        use crate::config::ConnectorSpec;
3543        let orphan = ExpandedNode {
3544            id: "orphan".into(),
3545            row_index: 0,
3546            role: NodeRole::Child {
3547                parent_id: "missing-parent".into(),
3548                parent_key: "id".into(),
3549            },
3550            source: ConnectorSpec {
3551                kind: "csv".into(),
3552                config: json!({}),
3553                transforms: None,
3554                inherit_transforms: true,
3555                status: None,
3556                tags: Vec::new(),
3557            },
3558            sink: ConnectorSpec {
3559                kind: "jsonl".into(),
3560                config: json!({}),
3561                transforms: None,
3562                inherit_transforms: true,
3563                status: None,
3564                tags: Vec::new(),
3565            },
3566            transforms: Vec::new(),
3567            state: None,
3568            dlq: None,
3569            delivery: faucet_core::DeliveryMode::AtLeastOnce,
3570            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3571            #[cfg(feature = "quality")]
3572            quality: None,
3573            #[cfg(feature = "contract")]
3574            contract: None,
3575            #[cfg(feature = "masking")]
3576            masking: None,
3577            sink_ref: "default".into(),
3578            schema: None,
3579            depends_on: Vec::new(),
3580            status: crate::config::SourceStatus::Active,
3581            tags: Vec::new(),
3582            deferred_refs: Vec::new(),
3583            source_override: None,
3584        };
3585        let err = run_expanded(vec![orphan], opts("deadlock"))
3586            .await
3587            .expect_err("an orphaned child must surface as an executor deadlock");
3588        match err {
3589            CliError::Internal(msg) => {
3590                assert!(msg.contains("executor deadlock"), "{msg}");
3591                assert!(msg.contains("orphan"), "{msg}");
3592            }
3593            other => panic!("expected Internal deadlock error, got {other:?}"),
3594        }
3595    }
3596
3597    #[test]
3598    fn value_to_string_brief_unquotes_strings_only() {
3599        assert_eq!(value_to_string_brief(&json!("hello")), "hello");
3600        assert_eq!(value_to_string_brief(&json!(42)), "42");
3601        assert_eq!(value_to_string_brief(&json!(true)), "true");
3602        assert_eq!(value_to_string_brief(&json!(null)), "null");
3603        assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
3604    }
3605
3606    #[test]
3607    fn build_state_key_with_and_without_parent() {
3608        assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
3609        assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
3610    }
3611
3612    #[test]
3613    fn resolve_parent_key_walks_objects_arrays_and_misses() {
3614        let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
3615        assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
3616        assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
3617        // Missing key → None.
3618        assert_eq!(resolve_parent_key(&r, "user.age"), None);
3619        // Descending into a scalar → None.
3620        assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
3621        // Non-numeric array index → None.
3622        assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
3623    }
3624
3625    #[tokio::test]
3626    async fn cooperative_cancel_returns_partial_ok() {
3627        // A pre-cancelled token makes the run stop at the first page boundary
3628        // and flush — returning Ok with a partial (possibly empty) result
3629        // rather than erroring. Covers the cancel-threading path.
3630        let dir = tempfile::tempdir().unwrap();
3631        let input = dir.path().join("in.csv");
3632        let output = dir.path().join("out.jsonl");
3633        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3634        let cfg = cfg_csv_to_jsonl(&input, &output);
3635        let nodes = expand(&cfg).unwrap();
3636        let token = CancellationToken::new();
3637        token.cancel(); // already cancelled before the run starts
3638        let mut o = opts("cancel");
3639        o.cancel = Some(token);
3640        let summary = run_expanded(nodes, o).await.unwrap();
3641        // The single root invocation completes (Ok) — it is not reported as a
3642        // failure even though it was cancelled.
3643        assert_eq!(summary.invocations.len(), 1);
3644        assert!(
3645            !summary.had_failures(),
3646            "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
3647        );
3648    }
3649
3650    #[tokio::test]
3651    async fn fanout_projects_away_unreferenced_parent_fields() {
3652        // Parent CSV has id + a big unreferenced "payload" column. The child only
3653        // references ${parents.id} (in its output path), so projection keeps "id"
3654        // and the parent_key but drops "payload" — and fan-out still works.
3655        let dir = tempfile::tempdir().unwrap();
3656        let parent_csv = dir.path().join("parents.csv");
3657        let child_csv = dir.path().join("child.csv");
3658        std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
3659        std::fs::write(&child_csv, "x\nA\n").unwrap();
3660        let parent_out = dir.path().join("parents.jsonl");
3661        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3662
3663        let yaml = format!(
3664            r#"version: 1
3665pipeline:
3666  source: {{ type: csv, config: {{ path: {parent} }} }}
3667  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
3668matrix:
3669  - id: parents
3670  - id: child
3671    parent: parents
3672    source: {{ config: {{ path: {child} }} }}
3673    sink:   {{ config: {{ path: "{child_out}" }} }}
3674"#,
3675            parent = parent_csv.display(),
3676            parent_out = parent_out.display(),
3677            child = child_csv.display(),
3678            child_out = child_out_pattern.display(),
3679        );
3680        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3681        let nodes = expand(&cfg).unwrap();
3682        let summary = run_expanded(
3683            nodes,
3684            ExecuteOptions {
3685                pipeline_name: "projtest".into(),
3686                execution: None,
3687                dry_run: false,
3688                limit: None,
3689                state_path_override: None,
3690                shard: None,
3691                auth: Default::default(),
3692                clock: chrono::Utc::now().fixed_offset(),
3693                cancel: None,
3694                resilience: None,
3695                sla: None,
3696                #[cfg(feature = "lineage")]
3697                lineage: None,
3698                #[cfg(feature = "lineage")]
3699                lineage_cfg: None,
3700                #[cfg(feature = "notify")]
3701                notifier: None,
3702                #[cfg(feature = "catalog")]
3703                catalog: None,
3704            },
3705        )
3706        .await
3707        .unwrap();
3708
3709        // 1 parent + 2 child invocations (one per parent record) — cardinality kept.
3710        assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3711        assert!(!summary.had_failures(), "{summary:?}");
3712        // ${parents.id} resolved correctly for each child despite "payload" being projected away.
3713        assert!(dir.path().join("child-1.jsonl").exists());
3714        assert!(dir.path().join("child-2.jsonl").exists());
3715    }
3716}