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 crate::transforms::compile_transforms;
32use async_trait::async_trait;
33use chrono::{DateTime, FixedOffset};
34use faucet_core::observability::Labels;
35use faucet_core::{DlqConfig, FaucetError, OnBatchError, Pipeline, Sink, Source, StateStore};
36use serde_json::Value;
37use std::collections::{HashMap, HashSet};
38use std::path::{Path, PathBuf};
39use std::sync::Arc;
40use std::sync::atomic::{AtomicUsize, Ordering};
41use std::time::Duration;
42use tokio::sync::{Mutex, Semaphore};
43
44/// Captured fan-out records, keyed by node id. Records are held as `Arc<Value>`
45/// so the per-level snapshot and per-child-unit hand-off are pointer bumps, not
46/// deep clones of the JSON tree (#160).
47type CapturedRecords = Arc<Mutex<HashMap<String, Vec<Arc<Value>>>>>;
48use tokio_util::sync::CancellationToken;
49
50/// Knobs passed to [`run_expanded`].
51pub struct ExecuteOptions {
52    /// Pipeline name — used in log lines and as the first segment of every
53    /// state key.
54    pub pipeline_name: String,
55    /// Override for `execution.max_concurrent`. `None` → use the value in
56    /// `ExecutionSpec` or the default (`num_cpus::get().min(4)`, floored at 1).
57    pub execution: Option<ExecutionSpec>,
58    /// `--dry-run` — every sink is replaced with a no-op counter.
59    pub dry_run: bool,
60    /// `--limit N` — wraps every sink to drop records past the cap.
61    pub limit: Option<usize>,
62    /// `--state-path PATH` — overrides the `file` state-store path.
63    pub state_path_override: Option<PathBuf>,
64    /// Clustered Mode B (#230): narrow this run's single source to one shard
65    /// before streaming, and suffix its state key with the shard id so resume is
66    /// per-shard. `None` (the default) runs the whole source unchanged. Only set
67    /// by the serve shard executor; every other caller leaves it `None`.
68    pub shard: Option<faucet_core::ShardSpec>,
69    /// Shared auth providers built from the top-level `auth:` block. Connectors
70    /// that reference one via `auth: { ref }` resolve against this catalog;
71    /// every row sharing a provider gets the same `Arc` (one token, shared).
72    pub auth: AuthCatalog,
73    /// Wall-clock instant for `${now.*}` interpolation in this run's configs.
74    /// `faucet run` sets process-start (or `--clock`); `faucet schedule` sets
75    /// the tick's scheduled time in the schedule timezone.
76    pub clock: DateTime<FixedOffset>,
77    /// Optional external cancellation token. When set and cancelled, in-flight
78    /// invocations stop at their next page boundary and **flush** their sinks
79    /// (so buffered output like a Parquet footer is durable), rather than being
80    /// hard-dropped (#146 H16). `faucet serve` wires this to run-cancel /
81    /// timeout / shutdown; `faucet run` leaves it `None`.
82    pub cancel: Option<CancellationToken>,
83    /// Optional resilience policy (retry/backoff/circuit-breaker/poison),
84    /// attached to every invocation's `RunStreamOptions` and injected into
85    /// rest/xml/graphql sources. Built once from the top-level `resilience:`
86    /// block; `None` preserves today's behaviour.
87    pub resilience: Option<faucet_core::ResiliencePolicy>,
88    /// Optional freshness/volume SLA (#202), evaluated after every **root**
89    /// invocation against history persisted in the node's state store.
90    /// Violations emit metrics + warnings; they never fail the run. `None`
91    /// disables the pass entirely.
92    pub sla: Option<crate::sla::SlaSpec>,
93    /// Shared OpenLineage emitter, built once from the `lineage:` block. `None`
94    /// disables lineage (and adds zero overhead). Gated on the `lineage` feature.
95    #[cfg(feature = "lineage")]
96    pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
97    /// The resolved `lineage:` config block (facet/event toggles, sampling, job
98    /// name template). Carried alongside the emitter so `run_one_invocation`
99    /// knows which facets/events to assemble. Gated on the `lineage` feature.
100    #[cfg(feature = "lineage")]
101    pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
102    /// Optional notification/incident-routing notifier (#280), built once from
103    /// the top-level `notifications:` block and shared across invocations. Fires
104    /// run success/failure, SLA breach, circuit-open, contract-abort, and
105    /// DLQ-threshold events after every **root** invocation. `None` disables
106    /// notifications entirely (zero overhead). Gated on the `notify` feature.
107    #[cfg(feature = "notify")]
108    pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
109    /// Optional Data Movement Catalog store (#279), recorded into after every
110    /// successful **root** invocation (dataset identity, schema timeline,
111    /// volume/freshness, lineage edge). `faucet serve` passes its run-history
112    /// backend + the serve run id; the CLI runtimes connect one from the
113    /// `catalog:` block. `None` disables recording entirely (zero overhead).
114    /// Recording never fails the run. Gated on the `catalog` feature.
115    #[cfg(feature = "catalog")]
116    pub catalog: Option<crate::catalog::CatalogHandle>,
117}
118
119/// Grace window granted to in-flight invocations to flush cooperatively after
120/// an `on_error: stop` cancellation, before the remaining tasks are
121/// hard-aborted (the backstop for a sink genuinely stuck mid-write). Bounded so
122/// a hung sink can't wedge the whole run.
123const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
124
125/// One pipeline invocation's outcome.
126#[derive(Debug)]
127pub struct InvocationOutcome {
128    pub row_id: String,
129    /// `None` for root invocations; for children, the value at `parent_key` in
130    /// the parent record (rendered to a string).
131    pub parent_record_key: Option<String>,
132    pub records_written: usize,
133    pub error: Option<String>,
134}
135
136/// Aggregate outcome of `run_expanded`.
137#[derive(Debug)]
138pub struct RunSummary {
139    pub invocations: Vec<InvocationOutcome>,
140}
141
142impl RunSummary {
143    pub fn failure_count(&self) -> usize {
144        self.invocations
145            .iter()
146            .filter(|i| i.error.is_some())
147            .count()
148    }
149    pub fn had_failures(&self) -> bool {
150        self.failure_count() > 0
151    }
152}
153
154/// Default number of matrix invocations to run in parallel when neither the
155/// config's `execution.max_concurrent` nor a flag specifies one.
156///
157/// Scales with the core count but is capped at 8. The cap is deliberate: each
158/// invocation is a *full pipeline* with its own connection pools / HTTP
159/// clients, and matrix rows often target the same external system (one API,
160/// one database), so an unbounded fan-out across, say, a 64-core box would
161/// blow through that system's connection or rate limits rather than going
162/// faster. Workloads that genuinely benefit from more parallelism set
163/// `execution.max_concurrent` explicitly to opt out of the cap (#78 LOW).
164fn default_concurrency() -> usize {
165    std::thread::available_parallelism()
166        .map(|n| n.get())
167        .unwrap_or(4)
168        .clamp(1, 8)
169}
170
171/// Execute every node in `nodes`. `nodes` must be in BFS order (roots first
172/// then children) — that's what [`crate::expand::expand`] returns.
173pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
174    let on_error = opts
175        .execution
176        .as_ref()
177        .map(|e| e.on_error)
178        .unwrap_or_default();
179    let max_concurrent = opts
180        .execution
181        .as_ref()
182        .and_then(|e| e.max_concurrent)
183        .unwrap_or_else(default_concurrency)
184        .max(1);
185    let semaphore = Arc::new(Semaphore::new(max_concurrent));
186
187    // Index nodes by id for parent → children lookups.
188    // parent id → child node ids. Keyed and valued by id (not Vec index) so the
189    // failure cascade can look children up directly instead of indexing into a
190    // HashMap's nondeterministic iteration order (#78/#24).
191    let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
192    for n in nodes.iter() {
193        if let NodeRole::Child { parent_id, .. } = &n.role {
194            children_of
195                .entry(parent_id.clone())
196                .or_default()
197                .push(n.id.clone());
198        }
199    }
200
201    // Captured records per node id. Only populated for nodes that have
202    // children (= are referenced by another node's `parent:`). Records are held
203    // as `Arc<Value>` so the per-level snapshot clone and the per-child-unit
204    // hand-off are pointer bumps, not deep clones of the JSON tree (#160).
205    let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
206
207    let mut outcomes: Vec<InvocationOutcome> = Vec::new();
208    let mut skipped_subtrees: HashSet<String> = HashSet::new();
209
210    // Root cooperative-cancel token: the caller's (serve wires run-cancel /
211    // timeout / shutdown) or a fresh one. Each level derives a child token so
212    // an `on_error: stop` cancels only that level's invocations, while an
213    // external cancel of the root propagates to every level (#146 H16).
214    let cancel = opts.cancel.clone().unwrap_or_default();
215    let opts = Arc::new(opts);
216
217    // We execute level-by-level. Each level is "every node whose parent is
218    // already done." Roots are level 0. For each level, we spawn one task per
219    // (node, parent-record) pair and await them all before moving on.
220    let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
221    let mut completed: HashSet<String> = HashSet::new();
222    let nodes_by_id: HashMap<String, ExpandedNode> =
223        nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
224
225    // Per-parent projection: what to keep from each captured record so the
226    // fan-out buffer holds only the fields children reference (#160).
227    let projections = build_projections(&nodes_by_id, &children_of);
228
229    // Sort node ids in their original BFS row order so the executor is
230    // deterministic — important for `on_error: stop`, where the first failure
231    // halts the rest of the level.
232    let bfs_order: Vec<String> = {
233        let mut ids: Vec<(usize, String)> = nodes_by_id
234            .values()
235            .map(|n| (n.row_index, n.id.clone()))
236            .collect();
237        ids.sort_by_key(|(i, _)| *i);
238        ids.into_iter().map(|(_, id)| id).collect()
239    };
240
241    while !remaining.is_empty() {
242        // Pick every remaining node whose parent (if any) and every
243        // `depends_on` row are already terminal (completed or skipped), in
244        // deterministic BFS order. Whether a skipped/failed prerequisite
245        // *skips* the node is decided in the unit loop below — readiness only
246        // asks "is there anything left to wait for".
247        let ready: Vec<String> = bfs_order
248            .iter()
249            .filter(|id| remaining.contains(*id))
250            .filter(|id| {
251                let node = &nodes_by_id[*id];
252                let parent_done = match &node.role {
253                    NodeRole::Root => true,
254                    NodeRole::Child { parent_id, .. } => {
255                        completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
256                    }
257                };
258                parent_done
259                    && node
260                        .depends_on
261                        .iter()
262                        .all(|d| completed.contains(d) || skipped_subtrees.contains(d))
263            })
264            .cloned()
265            .collect();
266
267        if ready.is_empty() {
268            // No node is ready but some remain — an expand.rs invariant was
269            // violated (e.g. an orphaned parent reference). Surface it instead
270            // of silently dropping the remaining work and reporting success
271            // (#78/#24).
272            let mut stuck: Vec<String> = remaining.iter().cloned().collect();
273            stuck.sort();
274            return Err(CliError::Internal(format!(
275                "executor deadlock: {} node(s) never became ready (no completed/skipped \
276                 parent or dependency): {}",
277                stuck.len(),
278                stuck.join(", ")
279            )));
280        }
281
282        // Build the work units for this level. Each unit is one invocation —
283        // a root runs once; a child runs once per parent record.
284        let mut units: Vec<Unit> = Vec::new();
285        // Move only the captured records of the parents whose children run this
286        // level out of the shared map. This both narrows the snapshot and frees
287        // each parent's buffer the moment its children consume it: all of a
288        // parent's children become ready in the same level, so its records are
289        // needed exactly once. Units hold their own `Arc<Value>` clones, so
290        // removing the map entry here only drops the map's hold (#160).
291        let level_records: HashMap<String, Vec<Arc<Value>>> = {
292            let consumed_parents: HashSet<&str> = ready
293                .iter()
294                .filter_map(|id| match &nodes_by_id[id].role {
295                    NodeRole::Child { parent_id, .. } => Some(parent_id.as_str()),
296                    NodeRole::Root => None,
297                })
298                .collect();
299            let mut cap = captured.lock().await;
300            consumed_parents
301                .iter()
302                .filter_map(|p| cap.remove(*p).map(|v| (p.to_string(), v)))
303                .collect()
304        };
305        for id in &ready {
306            let node = &nodes_by_id[id];
307            // If a parent failed (and on_error=continue), the subtree is
308            // skipped. Surface a synthetic "skipped" outcome and move on.
309            if let NodeRole::Child { parent_id, .. } = &node.role
310                && skipped_subtrees.contains(parent_id)
311            {
312                skipped_subtrees.insert(id.clone());
313                tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
314                continue;
315            }
316            // Same for a failed/skipped `depends_on` prerequisite: the row
317            // waited for something that never succeeded, so running it would
318            // violate the ordering contract. Mark it skipped so its own
319            // subtree and dependents cascade too.
320            if let Some(dep) = node
321                .depends_on
322                .iter()
323                .find(|d| skipped_subtrees.contains(d.as_str()))
324            {
325                skipped_subtrees.insert(id.clone());
326                tracing::warn!(
327                    row = %id, dependency = %dep,
328                    "skipping row: a depends_on row failed or was skipped"
329                );
330                continue;
331            }
332            match &node.role {
333                NodeRole::Root => {
334                    let uses_state = node.state.is_some() || opts.state_path_override.is_some();
335                    let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
336                    validate_unit_state_key(&node.id, uses_state, &state_key)?;
337                    units.push(Unit {
338                        node: node.clone(),
339                        parent_record: None,
340                        state_key,
341                        parent_record_key: None,
342                    });
343                }
344                NodeRole::Child {
345                    parent_id,
346                    parent_key,
347                } => {
348                    let parent_records = level_records.get(parent_id).cloned().unwrap_or_default();
349                    if parent_records.is_empty() {
350                        tracing::info!(
351                            row = %id, parent = %parent_id,
352                            "parent produced no records — child skipped"
353                        );
354                        continue;
355                    }
356                    // Detect state-key collisions among siblings sharing one parent.
357                    let uses_state = node.state.is_some() || opts.state_path_override.is_some();
358                    let mut seen_keys: HashSet<String> = HashSet::new();
359                    for record in &parent_records {
360                        let pk_value = resolve_parent_key(record, parent_key);
361                        let pk_string = pk_value
362                            .as_ref()
363                            .map(value_to_string_brief)
364                            .unwrap_or_else(|| "(missing)".to_string());
365                        let state_key =
366                            build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
367                        validate_unit_state_key(&node.id, uses_state, &state_key)?;
368                        if !seen_keys.insert(state_key.clone()) {
369                            return Err(CliError::DuplicateStateKey {
370                                id: node.id.clone(),
371                                state_key,
372                            });
373                        }
374                        units.push(Unit {
375                            node: node.clone(),
376                            parent_record: Some(record.clone()),
377                            state_key,
378                            parent_record_key: Some(pk_string),
379                        });
380                    }
381                }
382            }
383        }
384        drop(level_records);
385
386        let mut had_level_failure = false;
387        let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
388
389        // Unified parallel execution. Tasks run concurrently under the global
390        // semaphore. Under `on_error: stop`, the first failure triggers
391        // `JoinSet::abort_all()` — pending tasks waiting on a permit are
392        // dropped before they do real work, and in-flight tasks are
393        // cancelled at their next `.await` point (potentially leaving
394        // partial sink state — the trade-off users opt into by choosing
395        // `stop`). Under `on_error: continue` every spawned task runs to
396        // completion regardless of sibling failures.
397        // Per-level cancel token: cancelling it (on `on_error: stop`) stops only
398        // this level's invocations cooperatively; it is a child of the root
399        // token, so an external cancel (serve) still propagates here (#146 H16).
400        let level_cancel = cancel.child_token();
401        let mut joinset = tokio::task::JoinSet::new();
402        // Map each spawned task's id back to its row id + parent key so that a
403        // panic (surfaced as a JoinError, which doesn't carry the unit) can be
404        // attributed to the right invocation.
405        let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
406        for unit in units {
407            let sem = Arc::clone(&semaphore);
408            let opts2 = Arc::clone(&opts);
409            let captured = Arc::clone(&captured);
410            let capture = projections.get(&unit.node.id).cloned();
411            let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
412            let unit_cancel = level_cancel.clone();
413            let handle = joinset.spawn(async move {
414                let _permit = sem.acquire().await.expect("semaphore not closed");
415                run_unit(&unit, capture, &captured, &opts2, unit_cancel).await
416            });
417            task_meta.insert(handle.id(), meta);
418        }
419
420        let mut stop_triggered = false;
421        let mut aborted = false;
422        let mut stop_deadline: Option<tokio::time::Instant> = None;
423        loop {
424            // Once `on_error: stop` has cancelled the level, give in-flight
425            // invocations a bounded grace to flush cooperatively, then
426            // hard-abort the stragglers (the backstop for a sink stuck
427            // mid-write that can't reach a page boundary to observe the cancel).
428            let joined = match stop_deadline {
429                Some(deadline) if !aborted => {
430                    match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
431                        Ok(j) => j,
432                        Err(_) => {
433                            tracing::warn!(
434                                "on_error: stop — flush grace elapsed; aborting remaining \
435                                 in-flight invocations"
436                            );
437                            joinset.abort_all();
438                            aborted = true;
439                            continue;
440                        }
441                    }
442                }
443                _ => joinset.join_next_with_id().await,
444            };
445            let Some(joined) = joined else { break };
446            // A failure (an `Err` outcome or a panicked task) marks the level
447            // failed and, under `on_error: stop`, stops the rest. A panicking
448            // connector must NOT take down the whole process (#78/#24).
449            let outcome = match joined {
450                Ok((_id, outcome)) => outcome,
451                Err(e) if e.is_cancelled() => {
452                    // Expected after abort_all() — cancelled before/at an await.
453                    // Not counted as a failure or a success.
454                    continue;
455                }
456                Err(e) => {
457                    let (row_id, parent_record_key) = task_meta
458                        .get(&e.id())
459                        .cloned()
460                        .unwrap_or_else(|| ("<unknown>".to_string(), None));
461                    InvocationOutcome {
462                        row_id,
463                        parent_record_key,
464                        records_written: 0,
465                        error: Some(format!("pipeline invocation task panicked: {e}")),
466                    }
467                }
468            };
469
470            if let Some(err) = &outcome.error {
471                tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
472                had_level_failure = true;
473                nodes_with_any_failure.insert(outcome.row_id.clone());
474                if matches!(on_error, OnError::Stop) && !stop_triggered {
475                    stop_triggered = true;
476                    tracing::error!(
477                        "on_error: stop — cancelling in-flight invocations (cooperative \
478                         flush), then aborting any that don't stop within the grace window"
479                    );
480                    // Cooperative first: in-flight pipelines flush at their next
481                    // page boundary so a Parquet footer / S3 upload is completed
482                    // rather than orphaned (#146 H16).
483                    level_cancel.cancel();
484                    stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
485                }
486            } else {
487                tracing::info!(
488                    row = %outcome.row_id,
489                    records_written = outcome.records_written,
490                    "pipeline invocation completed"
491                );
492            }
493            outcomes.push(outcome);
494        }
495
496        // Mark ready nodes done (some may have produced both successes and
497        // failures across their per-parent-record fan-outs — we treat a node
498        // as "failed" overall if any of its invocations failed).
499        for id in ready {
500            remaining.remove(&id);
501            if nodes_with_any_failure.contains(&id) {
502                skipped_subtrees.insert(id.clone());
503                // Cascade to descendants in case we have multi-level chains.
504                if let Some(children) = children_of.get(&id) {
505                    for cid in children {
506                        skipped_subtrees.insert(cid.clone());
507                    }
508                }
509            } else {
510                completed.insert(id);
511            }
512        }
513
514        if had_level_failure && matches!(on_error, OnError::Stop) {
515            tracing::error!("on_error: stop — aborting after first failure");
516            // Any unfinished work surfaces as "skipped"; we just break here.
517            break;
518        }
519    }
520
521    Ok(RunSummary {
522        invocations: outcomes,
523    })
524}
525
526/// One scheduled invocation — a root runs once, a child runs once per parent
527/// record. Built by the level loop, consumed by [`run_unit`].
528struct Unit {
529    node: ExpandedNode,
530    parent_record: Option<Arc<Value>>,
531    state_key: String,
532    parent_record_key: Option<String>,
533}
534
535async fn run_unit(
536    unit: &Unit,
537    capture: Option<Arc<Projection>>,
538    captured: &CapturedRecords,
539    opts: &ExecuteOptions,
540    cancel: CancellationToken,
541) -> InvocationOutcome {
542    let needs_capture = capture.is_some();
543    let result = run_one_invocation(
544        &unit.node,
545        unit.parent_record.as_deref(),
546        &unit.state_key,
547        capture,
548        opts,
549        cancel,
550    )
551    .await;
552    let row_id = unit.node.id.clone();
553    let parent_record_key = unit.parent_record_key.clone();
554    match result {
555        Ok((records, written)) => {
556            if needs_capture {
557                captured
558                    .lock()
559                    .await
560                    .entry(row_id.clone())
561                    .or_default()
562                    // Move each record into an `Arc` once here; downstream
563                    // per-level / per-unit hand-offs then clone only the pointer.
564                    .extend(records.into_iter().map(Arc::new));
565            }
566            InvocationOutcome {
567                row_id,
568                parent_record_key,
569                records_written: written,
570                error: None,
571            }
572        }
573        Err(e) => InvocationOutcome {
574            row_id,
575            parent_record_key,
576            records_written: 0,
577            error: Some(e.to_string()),
578        },
579    }
580}
581
582/// Produce `{pipeline_name}::{row_id}` or `{pipeline_name}::{row_id}::{key}`.
583pub(crate) fn build_state_key(
584    pipeline_name: &str,
585    row_id: &str,
586    parent_key: Option<&str>,
587) -> String {
588    match parent_key {
589        None => format!("{pipeline_name}::{row_id}"),
590        Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
591    }
592}
593
594/// Reject an invalid state key up front (at unit construction) when the node
595/// will use a state store, so a bad pipeline name or parent-key value surfaces
596/// as a clear [`CliError::InvalidStateKey`] instead of a late mid-run
597/// `FaucetError::State` after connectors are built and the stream has started.
598fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
599    if uses_state {
600        faucet_core::state::validate_state_key(state_key).map_err(|e| {
601            CliError::InvalidStateKey {
602                id: node_id.to_owned(),
603                state_key: state_key.to_owned(),
604                reason: e.to_string(),
605            }
606        })?;
607    }
608    Ok(())
609}
610
611/// Walk the parent record by `parent_key` (a dotted path) and clone the value.
612fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
613    let mut cur = record;
614    for segment in parent_key.split('.') {
615        cur = match cur {
616            Value::Object(m) => m.get(segment)?,
617            Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
618            _ => return None,
619        };
620    }
621    Some(cur.clone())
622}
623
624/// What to keep from each of a parent's records when capturing for fan-out.
625/// Projecting to only the fields children reference bounds orchestrator memory
626/// at O(referenced-fields × N) instead of O(full-record × N) (#160).
627#[derive(Debug, Clone)]
628enum Projection {
629    /// Keep the whole record — a child referenced `${parent}` (the entire record)
630    /// or used an empty `parent_key`, so nothing can be safely dropped.
631    Full,
632    /// Keep only these pre-split, non-overlapping dotted paths.
633    Paths(Vec<Vec<String>>),
634}
635
636/// Split a dotted path into segments.
637fn split_path(path: &str) -> Vec<String> {
638    path.split('.').map(|s| s.to_string()).collect()
639}
640
641/// Reduce a set of segment-paths to a minimal non-overlapping set: drop any path
642/// that has a (segment-wise prefix) ancestor in the set — `["user"]` covers
643/// `["user","name"]`. Sorting puts ancestors before their descendants.
644fn minimal_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
645    paths.sort();
646    paths.dedup();
647    let mut kept: Vec<Vec<String>> = Vec::new();
648    for p in paths {
649        let covered = kept
650            .iter()
651            .any(|anc| p.len() >= anc.len() && p[..anc.len()] == anc[..]);
652        if !covered {
653            kept.push(p);
654        }
655    }
656    kept
657}
658
659/// Resolve a pre-split dotted path against `record`, dispatching on each value's
660/// type exactly like `resolve_parent_key` / `interpolate::resolve_dotted`.
661fn walk_value(record: &Value, segments: &[String]) -> Option<Value> {
662    let mut cur = record;
663    for seg in segments {
664        cur = match cur {
665            Value::Object(m) => m.get(seg)?,
666            Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
667            _ => return None,
668        };
669    }
670    Some(cur.clone())
671}
672
673/// Insert `leaf` at `segments` into `out`, creating intermediate `Value::Object`
674/// nodes keyed by the literal segment string. Callers pass non-overlapping
675/// `segments` (see `minimal_paths`), so a node that must be an object is never
676/// already a leaf.
677fn graft_object(out: &mut Value, segments: &[String], leaf: Value) {
678    if segments.is_empty() {
679        return;
680    }
681    let mut cur = out;
682    for seg in &segments[..segments.len() - 1] {
683        let map = match cur {
684            Value::Object(m) => m,
685            _ => return,
686        };
687        cur = map
688            .entry(seg.clone())
689            .or_insert_with(|| Value::Object(serde_json::Map::new()));
690    }
691    if let Value::Object(m) = cur {
692        m.insert(segments[segments.len() - 1].clone(), leaf);
693    }
694}
695
696/// Project `record` down to `projection`, building an all-objects reduced tree.
697/// Because the readers (`resolve_parent_key`, `interpolate_record`) dispatch on
698/// the reduced value's type, an array-index segment like `0` is stored — and
699/// later read — as the object key `"0"`, so resolution matches the original.
700fn project_record(record: &Value, projection: &Projection) -> Value {
701    match projection {
702        Projection::Full => record.clone(),
703        Projection::Paths(paths) => {
704            let mut out = Value::Object(serde_json::Map::new());
705            for segs in paths {
706                if let Some(v) = walk_value(record, segs) {
707                    graft_object(&mut out, segs, v);
708                }
709            }
710            out
711        }
712    }
713}
714
715/// Compute, per parent id, what to keep from each of its records: the union of
716/// every child's `parent_key` (the state-key path) and every `${parent.path}`
717/// token the children reference (from their pre-collected `deferred_refs`).
718/// Reused by the level loop to project captured records (#160).
719fn build_projections(
720    nodes_by_id: &HashMap<String, ExpandedNode>,
721    children_of: &HashMap<String, Vec<String>>,
722) -> HashMap<String, Arc<Projection>> {
723    let mut out = HashMap::new();
724    for (parent_id, child_ids) in children_of {
725        let mut raw: Vec<Vec<String>> = Vec::new();
726        let mut full = false;
727        for cid in child_ids {
728            let child = &nodes_by_id[cid];
729            if let NodeRole::Child { parent_key, .. } = &child.role {
730                if parent_key.is_empty() {
731                    full = true;
732                } else {
733                    raw.push(split_path(parent_key));
734                }
735            }
736            for dref in &child.deferred_refs {
737                if dref.referenced_id == *parent_id {
738                    if dref.dotted_path.is_empty() {
739                        full = true; // `${parent}` — whole record
740                    } else {
741                        raw.push(split_path(&dref.dotted_path));
742                    }
743                }
744            }
745        }
746        // Defensive: `raw` is empty only when every child had an empty
747        // parent_key, which already set `full`. The `|| raw.is_empty()` keeps us
748        // on `Full` even if that ever changes, so we never project to an empty
749        // tree that would drop the state-key path.
750        let projection = if full || raw.is_empty() {
751            Projection::Full
752        } else {
753            Projection::Paths(minimal_paths(raw))
754        };
755        out.insert(parent_id.clone(), Arc::new(projection));
756    }
757    out
758}
759
760/// Run one pipeline invocation. Returns (captured records, records_written).
761async fn run_one_invocation(
762    node: &ExpandedNode,
763    parent_record: Option<&Value>,
764    state_key: &str,
765    capture: Option<Arc<Projection>>,
766    opts: &ExecuteOptions,
767    cancel: CancellationToken,
768) -> CliResult<(Vec<Value>, usize)> {
769    // Observability identity for this invocation — built once, reused by both
770    // the Pipeline builder and the transform instrumentation.
771    let run_id = uuid::Uuid::now_v7().to_string();
772    let pipeline_name = opts.pipeline_name.clone();
773    let row_id = node.id.clone();
774    #[cfg(feature = "lineage")]
775    let lineage = opts.lineage.clone();
776    #[cfg(feature = "lineage")]
777    let lineage_cfg = opts.lineage_cfg.clone();
778    let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
779    // Whether this invocation records into the Data Movement Catalog (#279):
780    // roots only, and never for dry-run / --limit / shard runs (their volumes
781    // and datasets are partial or synthetic) — the same scoping as SLA.
782    #[cfg(feature = "catalog")]
783    let catalog_active = opts.catalog.is_some()
784        && matches!(node.role, NodeRole::Root)
785        && !opts.dry_run
786        && opts.limit.is_none()
787        && opts.shard.is_none();
788    // 1) Resolve `${parent.path}` in the per-row source + sink configs.
789    let mut source_cfg = node.source.config.clone();
790    let mut sink_cfg = node.sink.config.clone();
791
792    // Resolve `${now.*}` run-clock tokens for every invocation (root + child),
793    // before the parent-record pass. Leaves all other tokens verbatim.
794    resolve_now_inplace(&mut source_cfg, opts.clock)?;
795    resolve_now_inplace(&mut sink_cfg, opts.clock)?;
796
797    if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
798        let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
799        resolve_inplace(&mut source_cfg, &ctx)?;
800        resolve_inplace(&mut sink_cfg, &ctx)?;
801    }
802
803    // 2) Build source + sink. `faucet dlq replay` injects a pre-built source
804    //    (an envelope-unwrapping DLQ reader) via `source_override`; every
805    //    config-driven node builds from the connector registry. The override
806    //    is taken once — replay runs a single invocation.
807    let source = match node.source_override.as_ref().and_then(|o| o.take()) {
808        Some(prebuilt) => prebuilt,
809        None => {
810            build_source(
811                &node.source.kind,
812                source_cfg,
813                &opts.auth,
814                opts.resilience.as_ref().map(|r| &r.retry),
815            )
816            .await?
817        }
818    };
819
820    // Catalog identity (#279): read the dataset URIs off the *raw* connectors,
821    // before any wrapper is layered on.
822    #[cfg(feature = "catalog")]
823    let source_dataset_uri = source.dataset_uri();
824
825    // Clustered Mode B: narrow the raw source to its assigned shard BEFORE any
826    // wrapping (the TransformingSource / StateKeyOverride wrappers do not forward
827    // apply_shard, so it must reach the concrete connector).
828    if let Some(shard) = &opts.shard {
829        source
830            .apply_shard(shard)
831            .await
832            .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
833    }
834    let raw_sink: Box<dyn Sink> = if opts.dry_run {
835        Box::new(CountingSink::new())
836    } else {
837        build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
838    };
839    #[cfg(feature = "catalog")]
840    let sink_dataset_uri = raw_sink.dataset_uri();
841    let raw_sink: Box<dyn Sink> = match opts.limit {
842        Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
843        None => raw_sink,
844    };
845    let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
846    let sink: Box<dyn Sink> = match &capture {
847        Some(projection) => Box::new(CapturingSink::wrap(
848            raw_sink,
849            Arc::clone(&captured),
850            Arc::clone(projection),
851        )),
852        None => raw_sink,
853    };
854
855    // ── Lineage: sampling wrappers (only for requested facets) ───────────────
856    // `in_sample` taps the source's pre-transform records (input schema /
857    // column lineage); `out_sample` taps the sink's written records (output
858    // schema / RUNNING-heartbeat throughput counter). Both stay `None` when no
859    // facet/event needs them, so lineage adds zero per-record overhead.
860    #[cfg(feature = "lineage")]
861    let (in_sample, out_sample) = {
862        use std::sync::Arc as StdArc;
863        let mut want = false;
864        let mut cap = 0usize;
865        if let (Some(_), Some(lc)) = (&lineage, &lineage_cfg) {
866            let want_schema = lc.include_schema_facet || lc.include_column_lineage;
867            if want_schema {
868                cap = cap.max(lc.sample_records);
869            }
870            want = want_schema || lc.emit_on.running;
871        }
872        // The catalog (#279) needs input/output samples for schema inference
873        // and record counts regardless of any `lineage:` block; take the max
874        // of both caps when both are active.
875        #[cfg(feature = "catalog")]
876        if catalog_active {
877            want = true;
878            cap = cap.max(
879                opts.catalog
880                    .as_ref()
881                    .map(|h| h.sample_records)
882                    .unwrap_or(crate::catalog::DEFAULT_SAMPLE_RECORDS),
883            );
884        }
885        if want {
886            (
887                Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
888                Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
889            )
890        } else {
891            (None, None)
892        }
893    };
894
895    // Wrap the raw source so it samples PRE-transform input records — this must
896    // sit between `build_source` and `TransformingSource`.
897    #[cfg(feature = "lineage")]
898    let source: Box<dyn Source> = match &in_sample {
899        Some(state) => Box::new(faucet_lineage::SamplingSource::new(
900            source,
901            std::sync::Arc::clone(state),
902        )),
903        None => source,
904    };
905
906    // 3) Compile transforms. Resolve `${now.*}` run-clock tokens in each
907    //    transform's config first — exactly as for source/sink above — so e.g. a
908    //    `set` transform stamping `${now.date}` writes the real date instead of
909    //    the literal token string. Without this the token leaks into every record.
910    let stages = if node.transforms.is_empty() {
911        compile_transforms(&node.transforms)?
912    } else {
913        let mut transforms = node.transforms.clone();
914        for t in &mut transforms {
915            resolve_now_inplace(&mut t.config, opts.clock)?;
916        }
917        compile_transforms(&transforms)?
918    };
919    let source: Box<dyn Source> = if stages.is_empty() {
920        source
921    } else {
922        Box::new(faucet_core::TransformingSource::new(
923            source,
924            stages,
925            obs_labels.clone(),
926        )?)
927    };
928
929    // 4) Build state store. If the source opts into state, wrap it so the
930    //    executor's per-row state key is used instead of the source's natural
931    //    one (which is shared across all matrix rows of the same kind).
932    let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
933    // Keep a handle for the post-run SLA pass (#202) — `state` itself is moved
934    // into the pipeline below.
935    let sla_store = state.clone();
936    // Per-shard bookmark: suffix the state key with the shard id so a reassigned
937    // shard resumes where its dead owner left off, independent of sibling shards.
938    let effective_state_key = match &opts.shard {
939        Some(shard) => format!("{state_key}::{}", shard.id),
940        None => state_key.to_owned(),
941    };
942    let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
943        Box::new(StateKeyOverride {
944            inner: source,
945            key: effective_state_key,
946        })
947    } else {
948        source
949    };
950
951    // Wrap the sink so it samples written records — outermost, immediately
952    // before the pipeline is constructed (after capture/limit wrappers).
953    #[cfg(feature = "lineage")]
954    let sink: Box<dyn Sink> = match &out_sample {
955        Some(state) => Box::new(faucet_lineage::SamplingSink::new(
956            sink,
957            std::sync::Arc::clone(state),
958        )),
959        None => sink,
960    };
961
962    // 5) Run.
963    // When lineage is enabled the START/terminal lifecycle below still needs
964    // `pipeline_name` / `row_id` / `run_id`, so hand the builder clones; the
965    // non-lineage build moves them straight in (byte-identical to before).
966    #[cfg(feature = "lineage")]
967    let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
968        .with_name(pipeline_name.clone())
969        .with_row(row_id.clone())
970        .with_run_id(run_id.clone());
971    #[cfg(not(feature = "lineage"))]
972    let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
973        .with_name(pipeline_name)
974        .with_row(row_id)
975        .with_run_id(run_id);
976    let pipeline = match state {
977        Some(store) => pipeline.with_state_store(store),
978        None => pipeline,
979    };
980    let pipeline = if let Some(ref dlq_spec) = node.dlq {
981        let dlq_cfg = build_dlq_config(dlq_spec).await?;
982        pipeline.with_dlq(dlq_cfg)
983    } else {
984        pipeline
985    };
986    // Cooperative cancellation: a cancelled token makes the streaming loop stop
987    // at the next page boundary and flush the sink (#146 H16). The pipeline
988    // takes a clone so the lineage terminal-event classification and the SLA
989    // pass below can still read `cancel.is_cancelled()` (cheap — the token is
990    // an `Arc`).
991    let pipeline = pipeline.with_cancel(cancel.clone());
992    // Pipeline-level quality checks (v1: no matrix-row override). `expand`
993    // already validated this spec, but compile again here to obtain the
994    // runtime `CompiledQuality`; map any error to a config-level failure.
995    #[cfg(feature = "quality")]
996    let pipeline = if let Some(ref quality_spec) = node.quality {
997        let compiled = Arc::new(
998            faucet_core::CompiledQuality::compile(quality_spec)
999                .map_err(|e| CliError::Config(format!("quality: {e}")))?,
1000        );
1001        pipeline.with_quality(compiled)
1002    } else {
1003        pipeline
1004    };
1005    // Pipeline-level data contract (v1: no matrix-row override). `expand`
1006    // already validated the spec; compile again here to obtain the runtime
1007    // `CompiledContract`.
1008    #[cfg(feature = "contract")]
1009    let pipeline = if let Some(ref contract_spec) = node.contract {
1010        let compiled = Arc::new(
1011            faucet_core::CompiledContract::compile(contract_spec)
1012                .map_err(|e| CliError::Config(format!("contract: {e}")))?,
1013        );
1014        pipeline.with_contract(compiled)
1015    } else {
1016        pipeline
1017    };
1018    // Pipeline-level PII masking (v1: no matrix-row override). Compile scoped
1019    // to this node's destination sink — by template name (`sink_ref`) and by
1020    // connector kind — so `applies_to` per-destination rules resolve. When no
1021    // rule applies to this sink the compiled policy is empty and the pass is
1022    // skipped entirely.
1023    #[cfg(feature = "masking")]
1024    let pipeline = if let Some(ref masking_spec) = node.masking {
1025        let sink_ids = [node.sink_ref.as_str(), node.sink.kind.as_str()];
1026        let compiled = faucet_core::CompiledMasking::compile_for_sink(masking_spec, &sink_ids)
1027            .map_err(|e| CliError::Config(format!("masking: {e}")))?;
1028        if compiled.is_empty() {
1029            pipeline
1030        } else {
1031            pipeline.with_masking(Arc::new(compiled))
1032        }
1033    } else {
1034        pipeline
1035    };
1036    // Schema-drift policy (pipeline-level in v1; same for every invocation).
1037    let pipeline = if let Some(ref sd) = node.schema {
1038        pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd))
1039    } else {
1040        pipeline
1041    };
1042    // Execution-level adaptive batch-size controller (shared by all rows).
1043    let pipeline = if let Some(ab) = opts
1044        .execution
1045        .as_ref()
1046        .and_then(|e| e.adaptive_batch_size.clone())
1047    {
1048        ab.validate()
1049            .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
1050        pipeline.with_adaptive(ab)
1051    } else {
1052        pipeline
1053    };
1054    // Resilience policy (retry/backoff/circuit-breaker/poison). Top-level in v1,
1055    // so the same policy is attached to every invocation.
1056    let pipeline = if let Some(policy) = opts.resilience.clone() {
1057        pipeline.with_resilience(policy)
1058    } else {
1059        pipeline
1060    };
1061    // Delivery guarantee (exactly-once resume/skip when the node opted in; the
1062    // expand gate already verified source/sink/state support). Preview modes
1063    // (`--dry-run`, `--limit`) swap in counting/truncating sinks that cannot
1064    // uphold an atomic watermark — and a token committed for a truncated page
1065    // would corrupt it — so they always run at-least-once.
1066    let effective_delivery = if opts.dry_run || opts.limit.is_some() {
1067        faucet_core::idempotency::DeliveryMode::AtLeastOnce
1068    } else {
1069        node.delivery
1070    };
1071    let pipeline = pipeline.with_delivery(effective_delivery);
1072    // ── Lineage: START + heartbeat + terminal ────────────────────────────────
1073    #[cfg(feature = "lineage")]
1074    let lineage_ctx = match (&lineage, &lineage_cfg) {
1075        (Some(em), Some(lc)) => {
1076            let job_name =
1077                crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
1078            let mut ctx = faucet_lineage::RunLifecycle {
1079                job_namespace: lc.namespace.clone(),
1080                job_name,
1081                run_id: run_id.clone(),
1082                parent: lc.parent_job.clone(),
1083                input: faucet_lineage::DatasetRef {
1084                    namespace: lc.namespace.clone(),
1085                    name: source.dataset_uri(),
1086                },
1087                output: faucet_lineage::DatasetRef {
1088                    namespace: lc.namespace.clone(),
1089                    name: sink.dataset_uri(),
1090                },
1091                started_at: chrono::Utc::now(),
1092                finished_at: None,
1093                records: 0,
1094                error: None,
1095                input_schema: None,
1096                output_schema: None,
1097                column_lineage: None,
1098                source_code: None,
1099            };
1100            em.emit(faucet_lineage::EventType::Start, &ctx).await;
1101            // Heartbeat task — periodic RUNNING events with the live throughput
1102            // count read off the output sampler.
1103            let hb_handle = if lc.emit_on.running {
1104                let em2 = std::sync::Arc::clone(em);
1105                let interval = lc.heartbeat_interval;
1106                let mut beat_ctx = ctx.clone();
1107                let counter = out_sample.clone();
1108                Some(tokio::spawn(async move {
1109                    let mut tick = tokio::time::interval(interval);
1110                    tick.tick().await; // skip the immediate first tick
1111                    loop {
1112                        tick.tick().await;
1113                        if let Some(c) = &counter {
1114                            beat_ctx.records = c.count();
1115                        }
1116                        em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
1117                            .await;
1118                    }
1119                }))
1120            } else {
1121                None
1122            };
1123            ctx.source_code = if lc.include_source_code_facet {
1124                Some(serde_json::to_string(&node.source.config).unwrap_or_default())
1125            } else {
1126                None
1127            };
1128            Some((std::sync::Arc::clone(em), ctx, hb_handle))
1129        }
1130        _ => None,
1131    };
1132
1133    // Combine run + final flush into one outcome BEFORE emitting the terminal
1134    // lineage event, preserving the original semantics (run error → skip flush;
1135    // run ok but flush error → overall error). The terminal event is classified
1136    // from this combined `result`, then `?`-propagated below — restoring the
1137    // original early-return behaviour while still firing the terminal event on
1138    // both success and error.
1139    let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1140        Ok(r) => sink.flush().await.map(|_| r),
1141        Err(e) => Err(e),
1142    };
1143
1144    #[cfg(feature = "lineage")]
1145    if let Some((em, mut ctx, hb)) = lineage_ctx {
1146        if let Some(h) = hb {
1147            h.abort();
1148        }
1149        ctx.finished_at = Some(chrono::Utc::now());
1150        if let Some(state) = &out_sample {
1151            ctx.records = state.count();
1152            if lineage_cfg
1153                .as_ref()
1154                .map(|l| l.include_schema_facet)
1155                .unwrap_or(false)
1156            {
1157                ctx.output_schema = Some(state.inferred_schema());
1158            }
1159        }
1160        if let Some(state) = &in_sample
1161            && lineage_cfg
1162                .as_ref()
1163                .map(|l| l.include_schema_facet || l.include_column_lineage)
1164                .unwrap_or(false)
1165        {
1166            let in_schema = state.inferred_schema();
1167            if lineage_cfg
1168                .as_ref()
1169                .map(|l| l.include_column_lineage)
1170                .unwrap_or(false)
1171            {
1172                let input_fields: Vec<String> =
1173                    in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1174                #[cfg(feature = "masking")]
1175                let has_masking = node.masking.is_some();
1176                #[cfg(not(feature = "masking"))]
1177                let has_masking = false;
1178                let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1179                ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1180            }
1181            if lineage_cfg
1182                .as_ref()
1183                .map(|l| l.include_schema_facet)
1184                .unwrap_or(false)
1185            {
1186                ctx.input_schema = Some(in_schema);
1187            }
1188        }
1189        let ev = match &result {
1190            Err(e) => {
1191                ctx.error = Some(e.to_string());
1192                faucet_lineage::EventType::Fail
1193            }
1194            Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1195            Ok(_) => faucet_lineage::EventType::Complete,
1196        };
1197        em.emit(ev, &ctx).await;
1198    }
1199
1200    // ── SLA post-run evaluation (#202) ───────────────────────────────────────
1201    // Roots only: children fan out per parent record, so their volumes are not
1202    // a stable series to baseline (same scoping as `faucet doctor`). Skipped
1203    // for dry-run / --limit (synthetic volumes would poison the baseline), for
1204    // shard executions (a shard's volume is a fraction of the row's, and shard
1205    // counts change run to run), and for cancelled runs (a partial volume is
1206    // not a signal). Monitoring never fails the run — see `evaluate_post_run`.
1207    // Roots only, and never for dry-run / --limit / shard / cancelled runs —
1208    // the same scoping the notification pass below reuses.
1209    let is_notifiable_root = matches!(node.role, NodeRole::Root)
1210        && !opts.dry_run
1211        && opts.limit.is_none()
1212        && opts.shard.is_none()
1213        && !cancel.is_cancelled();
1214
1215    #[cfg_attr(not(feature = "notify"), allow(unused_variables))]
1216    let sla_violations = if let Some(spec) = &opts.sla
1217        && is_notifiable_root
1218    {
1219        let outcome = match &result {
1220            Ok(r) => crate::sla::RunOutcome::Success {
1221                rows: r.records_written as u64,
1222            },
1223            Err(_) => crate::sla::RunOutcome::Failure,
1224        };
1225        crate::sla::evaluate_post_run(
1226            spec,
1227            sla_store.as_ref(),
1228            state_key,
1229            &obs_labels.pipeline,
1230            &obs_labels.row,
1231            outcome,
1232            chrono::Utc::now().timestamp(),
1233        )
1234        .await
1235    } else {
1236        Vec::new()
1237    };
1238
1239    // ── Notifications (#280) ─────────────────────────────────────────────────
1240    // Fan run success/failure, SLA breach, circuit-open, contract-abort, and
1241    // DLQ-threshold out to the configured channels. Same root/real-run scoping
1242    // as SLA; delivery is fire-and-forget and never fails the run.
1243    #[cfg(feature = "notify")]
1244    if let Some(notifier) = &opts.notifier
1245        && is_notifiable_root
1246    {
1247        use crate::notify::NotifyEvent;
1248        let pipeline = obs_labels.pipeline.to_string();
1249        let row = obs_labels.row.to_string();
1250        match &result {
1251            Ok(r) => {
1252                notifier
1253                    .emit(NotifyEvent::run_success(
1254                        pipeline.clone(),
1255                        row.clone(),
1256                        r.records_written as u64,
1257                    ))
1258                    .await;
1259                if let Some(dlq) = &r.dlq
1260                    && dlq.records_dlq > 0
1261                {
1262                    notifier
1263                        .emit(NotifyEvent::dlq_threshold(
1264                            pipeline.clone(),
1265                            row.clone(),
1266                            dlq.records_dlq as u64,
1267                        ))
1268                        .await;
1269                }
1270            }
1271            Err(e) => {
1272                notifier.emit(error_event(&pipeline, &row, e)).await;
1273            }
1274        }
1275        for v in &sla_violations {
1276            notifier
1277                .emit(NotifyEvent::sla_breach(
1278                    pipeline.clone(),
1279                    row.clone(),
1280                    v.kind(),
1281                    v.to_string(),
1282                ))
1283                .await;
1284        }
1285    }
1286
1287    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
1288    // Fold this run's dataset observations + lineage edge into the catalog.
1289    // Successful, complete root runs only (a cancelled run's partial volume is
1290    // not a signal). Recording never fails the run — see `catalog::record`.
1291    #[cfg(feature = "catalog")]
1292    if let Some(handle) = &opts.catalog
1293        && catalog_active
1294        && !cancel.is_cancelled()
1295        && let Ok(pipeline_result) = &result
1296    {
1297        use crate::catalog::model::{canonicalize_uri, schema_from_samples};
1298        use crate::serve::history::catalog::{CatalogUpdate, DatasetObservation, DatasetRole};
1299
1300        let records_written = pipeline_result.records_written as u64;
1301        let source_schema = in_sample
1302            .as_ref()
1303            .and_then(|s| schema_from_samples(&s.samples()));
1304        let sink_schema = out_sample
1305            .as_ref()
1306            .and_then(|s| schema_from_samples(&s.samples()));
1307        // The samplers are always installed while the catalog is active, so the
1308        // unwrap_or arms are defensive only.
1309        let records_read = in_sample
1310            .as_ref()
1311            .map(|s| s.count())
1312            .unwrap_or(records_written);
1313        let records_out = out_sample
1314            .as_ref()
1315            .map(|s| s.count())
1316            .unwrap_or(records_written);
1317
1318        // Column lineage for the edge — the same derivation `faucet-lineage`
1319        // emits, so the catalog's edges match the OpenLineage output.
1320        let column_lineage = in_sample.as_ref().and_then(|s| {
1321            let input_fields: Vec<String> = s
1322                .inferred_schema()
1323                .fields
1324                .iter()
1325                .map(|(n, _)| n.clone())
1326                .collect();
1327            #[cfg(feature = "masking")]
1328            let has_masking = node.masking.is_some();
1329            #[cfg(not(feature = "masking"))]
1330            let has_masking = false;
1331            let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1332            faucet_lineage::derive_column_lineage(&input_fields, &ops).map(|cl| {
1333                // `ColumnLineage` is not `Serialize` (IndexMap); render the
1334                // stable `{"fields": {out: [in, …]}}` shape by hand.
1335                let fields: serde_json::Map<String, Value> = cl
1336                    .edges
1337                    .iter()
1338                    .map(|(out, ins)| {
1339                        (
1340                            out.clone(),
1341                            Value::Array(ins.iter().map(|s| Value::String(s.clone())).collect()),
1342                        )
1343                    })
1344                    .collect();
1345                serde_json::json!({ "fields": fields })
1346            })
1347        });
1348
1349        let update = CatalogUpdate {
1350            run_id: handle.run_id.clone().unwrap_or_else(|| run_id.clone()),
1351            pipeline: obs_labels.pipeline.to_string(),
1352            row: obs_labels.row.to_string(),
1353            recorded_at: chrono::Utc::now(),
1354            source: DatasetObservation {
1355                uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
1356                kind: node.source.kind.clone(),
1357                role: DatasetRole::Source,
1358                schema: source_schema,
1359                records: records_read,
1360            },
1361            sink: DatasetObservation {
1362                uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
1363                kind: node.sink.kind.clone(),
1364                role: DatasetRole::Sink,
1365                schema: sink_schema,
1366                records: records_out,
1367            },
1368            column_lineage,
1369        };
1370        crate::catalog::record(handle, &update).await;
1371    }
1372
1373    let result = result?;
1374
1375    let captured = if capture.is_some() {
1376        std::mem::take(&mut *captured.lock().await)
1377    } else {
1378        Vec::new()
1379    };
1380    Ok((captured, result.records_written))
1381}
1382
1383async fn build_state_for_node(
1384    node: &ExpandedNode,
1385    state_path_override: Option<&Path>,
1386) -> CliResult<Option<Arc<dyn StateStore>>> {
1387    match (&node.state, state_path_override) {
1388        (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1389        (None, Some(path)) => Ok(Some(state_from_override(path))),
1390        (Some(spec), Some(path)) => {
1391            if spec.kind == "file" {
1392                Ok(Some(state_from_override(path)))
1393            } else {
1394                tracing::warn!(
1395                    state = %spec.kind,
1396                    "--state-path is only meaningful for the 'file' backend; ignoring override"
1397                );
1398                Ok(Some(build_state_store(spec).await?))
1399            }
1400        }
1401        (None, None) => Ok(None),
1402    }
1403}
1404
1405fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1406    Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1407}
1408
1409/// Translate a [`crate::config::DlqSpec`] from the YAML/JSON config into a
1410/// runtime [`DlqConfig`] ready to attach to a [`Pipeline`].
1411pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1412    // DLQ sinks resolve against an empty catalog — shared `auth: { ref }` on a
1413    // DLQ sink is out of scope (DLQ targets are typically local jsonl/stdout).
1414    let sink = build_sink(
1415        &spec.sink.kind,
1416        spec.sink.config.clone(),
1417        &AuthCatalog::new(),
1418    )
1419    .await?;
1420    Ok(DlqConfig {
1421        sink: Arc::from(sink),
1422        on_batch_error: match spec.on_batch_error {
1423            crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1424            crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1425        },
1426        max_failures_per_page: spec.max_failures_per_page,
1427        max_failures_total: spec.max_failures_total,
1428        include_original_payload: spec.include_original_payload,
1429    })
1430}
1431
1432/// Classify a pipeline error into a notification event (#280). A circuit-breaker
1433/// trip and a contract-abort breach get their own event kinds; everything else
1434/// is a generic `run_failure` carrying a short error-kind label.
1435#[cfg(feature = "notify")]
1436fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
1437    use crate::notify::NotifyEvent;
1438    match err {
1439        FaucetError::CircuitOpen { failures, cooldown } => {
1440            NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
1441        }
1442        FaucetError::ContractViolation { message, .. } => {
1443            NotifyEvent::contract_abort(pipeline, row, message.clone())
1444        }
1445        other => {
1446            NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
1447        }
1448    }
1449}
1450
1451/// Short, stable label for a `FaucetError` variant used as the `error_kind`
1452/// detail on a `run_failure` notification. (`faucet-core`'s own `error_kind`
1453/// helper is `pub(crate)`, so we keep a small CLI-side mapping.)
1454#[cfg(feature = "notify")]
1455fn faucet_error_kind(err: &FaucetError) -> &'static str {
1456    match err {
1457        FaucetError::Config(_) => "config",
1458        FaucetError::Source(_) => "source",
1459        FaucetError::Sink(_) => "sink",
1460        FaucetError::State(_) => "state",
1461        FaucetError::QualityFailure { .. } => "quality",
1462        FaucetError::SchemaDrift { .. } => "schema_drift",
1463        _ => "error",
1464    }
1465}
1466
1467/// In-place `${now.*}` resolution against the run clock. Walks every string
1468/// leaf and rewrites `${now.<token>}`; all other `${...}` tokens are untouched.
1469/// Shared with `faucet test`, which applies the same pre-pass to transform
1470/// configs under the case clock.
1471pub(crate) fn resolve_now_inplace(
1472    value: &mut Value,
1473    clock: DateTime<FixedOffset>,
1474) -> CliResult<()> {
1475    match value {
1476        Value::String(s) => {
1477            *s = crate::interpolate::resolve_now(s, clock)?;
1478            Ok(())
1479        }
1480        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1481        Value::Object(m) => m
1482            .values_mut()
1483            .try_for_each(|v| resolve_now_inplace(v, clock)),
1484        _ => Ok(()),
1485    }
1486}
1487
1488/// In-place runtime interpolation against a parent-record context. Walks every
1489/// string leaf in `value` and replaces `${id.path}` tokens with stringified
1490/// values from `ctx`.
1491fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1492    match value {
1493        Value::String(s) => {
1494            let resolved = interpolate_record(s, ctx)?;
1495            *s = resolved;
1496            Ok(())
1497        }
1498        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1499        Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1500        _ => Ok(()),
1501    }
1502}
1503
1504// ── Adapter sinks/sources ───────────────────────────────────────────────────
1505
1506/// Wraps a source so its `state_key()` returns the executor-provided value
1507/// instead of the source's natural one. Lets every matrix invocation use a
1508/// distinct state-store entry even when the underlying source kind is shared.
1509struct StateKeyOverride {
1510    inner: Box<dyn Source>,
1511    key: String,
1512}
1513
1514#[async_trait]
1515impl Source for StateKeyOverride {
1516    async fn fetch_with_context(
1517        &self,
1518        ctx: &HashMap<String, Value>,
1519    ) -> Result<Vec<Value>, FaucetError> {
1520        self.inner.fetch_with_context(ctx).await
1521    }
1522    async fn fetch_with_context_incremental(
1523        &self,
1524        ctx: &HashMap<String, Value>,
1525    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1526        self.inner.fetch_with_context_incremental(ctx).await
1527    }
1528    // Forward `stream_pages` so the wrapped connector's *native* page stream
1529    // survives the wrap. Without this the trait's buffering default kicks in
1530    // for every stateful run — losing per-page bookmarks (CDC per-transaction
1531    // durability, exactly-once per-page tokens) and the O(batch_size) memory
1532    // bound.
1533    fn stream_pages<'a>(
1534        &'a self,
1535        ctx: &'a HashMap<String, Value>,
1536        batch_size: usize,
1537    ) -> std::pin::Pin<
1538        Box<
1539            dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
1540                + Send
1541                + 'a,
1542        >,
1543    > {
1544        self.inner.stream_pages(ctx, batch_size)
1545    }
1546    fn connector_name(&self) -> &'static str {
1547        self.inner.connector_name()
1548    }
1549    fn dataset_uri(&self) -> String {
1550        self.inner.dataset_uri()
1551    }
1552    fn state_key(&self) -> Option<String> {
1553        Some(self.key.clone())
1554    }
1555    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1556        self.inner.apply_start_bookmark(bookmark).await
1557    }
1558    fn supports_exactly_once(&self) -> bool {
1559        self.inner.supports_exactly_once()
1560    }
1561    fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
1562        self.inner.replay_guarantee()
1563    }
1564    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1565        self.inner.capture_resume_position().await
1566    }
1567}
1568
1569/// Forwards each record to an inner sink while also capturing a **projected**
1570/// copy into a shared buffer for descendant rows to consume. Projecting to only
1571/// the fields children reference bounds orchestrator memory (#160).
1572struct CapturingSink {
1573    inner: Box<dyn Sink>,
1574    captured: Arc<Mutex<Vec<Value>>>,
1575    projection: Arc<Projection>,
1576}
1577
1578impl CapturingSink {
1579    fn wrap(
1580        inner: Box<dyn Sink>,
1581        captured: Arc<Mutex<Vec<Value>>>,
1582        projection: Arc<Projection>,
1583    ) -> Self {
1584        Self {
1585            inner,
1586            captured,
1587            projection,
1588        }
1589    }
1590}
1591
1592#[async_trait]
1593impl Sink for CapturingSink {
1594    fn connector_name(&self) -> &'static str {
1595        self.inner.connector_name()
1596    }
1597    fn dataset_uri(&self) -> String {
1598        self.inner.dataset_uri()
1599    }
1600    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1601        let written = self.inner.write_batch(records).await?;
1602        // Capture only what actually landed (LimitedSink may have dropped some),
1603        // projected to the fields children reference (#160).
1604        let n = written.min(records.len());
1605        let mut buf = self.captured.lock().await;
1606        buf.extend(
1607            records
1608                .iter()
1609                .take(n)
1610                .map(|r| project_record(r, &self.projection)),
1611        );
1612        Ok(written)
1613    }
1614    async fn flush(&self) -> Result<(), FaucetError> {
1615        self.inner.flush().await
1616    }
1617    // Capability + exactly-once passthroughs, so a parent row that fans out to
1618    // children (which is what this wrapper serves) keeps the inner sink's
1619    // delivery semantics instead of being masked down to the trait defaults.
1620    fn supports_idempotent_writes(&self) -> bool {
1621        self.inner.supports_idempotent_writes()
1622    }
1623    fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
1624        self.inner.sink_guarantee()
1625    }
1626    fn dedups_by_key(&self) -> bool {
1627        self.inner.dedups_by_key()
1628    }
1629    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
1630        self.inner.supported_write_modes()
1631    }
1632    async fn write_batch_idempotent(
1633        &self,
1634        records: &[Value],
1635        scope: &str,
1636        token: &str,
1637    ) -> Result<usize, FaucetError> {
1638        let written = self
1639            .inner
1640            .write_batch_idempotent(records, scope, token)
1641            .await?;
1642        let n = written.min(records.len());
1643        let mut buf = self.captured.lock().await;
1644        buf.extend(
1645            records
1646                .iter()
1647                .take(n)
1648                .map(|r| project_record(r, &self.projection)),
1649        );
1650        Ok(written)
1651    }
1652    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1653        self.inner.last_committed_token(scope).await
1654    }
1655    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1656        self.inner.current_schema().await
1657    }
1658    fn supports_schema_evolution(&self) -> bool {
1659        self.inner.supports_schema_evolution()
1660    }
1661    async fn evolve_schema(
1662        &self,
1663        evolution: &faucet_core::SchemaEvolution,
1664    ) -> Result<(), FaucetError> {
1665        self.inner.evolve_schema(evolution).await
1666    }
1667}
1668
1669/// Cap on records written. Each `write_batch` call truncates `records` to the
1670/// remaining budget before delegating.
1671struct LimitedSink {
1672    inner: Box<dyn Sink>,
1673    remaining: AtomicUsize,
1674}
1675
1676impl LimitedSink {
1677    fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1678        Self {
1679            inner,
1680            remaining: AtomicUsize::new(cap),
1681        }
1682    }
1683}
1684
1685#[async_trait]
1686impl Sink for LimitedSink {
1687    fn connector_name(&self) -> &'static str {
1688        self.inner.connector_name()
1689    }
1690    fn dataset_uri(&self) -> String {
1691        self.inner.dataset_uri()
1692    }
1693    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1694        let remaining = self.remaining.load(Ordering::Relaxed);
1695        if remaining == 0 {
1696            return Ok(0);
1697        }
1698        let take = remaining.min(records.len());
1699        let slice = &records[..take];
1700        let written = self.inner.write_batch(slice).await?;
1701        self.remaining
1702            .fetch_sub(written.min(remaining), Ordering::Relaxed);
1703        Ok(written)
1704    }
1705    async fn flush(&self) -> Result<(), FaucetError> {
1706        self.inner.flush().await
1707    }
1708}
1709
1710/// No-op sink used in `--dry-run`. Counts records seen so the rest of the
1711/// pipeline (transforms, source) still runs.
1712struct CountingSink {
1713    seen: AtomicUsize,
1714}
1715
1716impl CountingSink {
1717    fn new() -> Self {
1718        Self {
1719            seen: AtomicUsize::new(0),
1720        }
1721    }
1722}
1723
1724#[async_trait]
1725impl Sink for CountingSink {
1726    fn connector_name(&self) -> &'static str {
1727        "dry-run"
1728    }
1729    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1730        self.seen.fetch_add(records.len(), Ordering::Relaxed);
1731        Ok(records.len())
1732    }
1733}
1734
1735/// Render a JSON value compactly for use as a state-key suffix or log line.
1736/// Strings pass through unquoted; numbers/bools/null/composites use to_string.
1737fn value_to_string_brief(v: &Value) -> String {
1738    match v {
1739        Value::String(s) => s.clone(),
1740        other => other.to_string(),
1741    }
1742}
1743
1744#[cfg(test)]
1745mod tests {
1746    use super::*;
1747    use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1748    use crate::expand::expand;
1749    use serde_json::json;
1750
1751    fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
1752        PipelineConfig {
1753            version: 1,
1754            name: Some("test".into()),
1755            vars: None,
1756            auth: None,
1757            pipeline: PipelineSpec {
1758                source: Some(ConnectorSpec {
1759                    kind: "csv".into(),
1760                    config: json!({"path": input.to_str().unwrap()}),
1761                    transforms: None,
1762                    inherit_transforms: true,
1763                }),
1764                sink: Some(ConnectorSpec {
1765                    kind: "jsonl".into(),
1766                    config: json!({"path": output.to_str().unwrap()}),
1767                    transforms: None,
1768                    inherit_transforms: true,
1769                }),
1770                sources: Default::default(),
1771                sinks: Default::default(),
1772                transforms: Vec::new(),
1773                state: None,
1774                dlq: None,
1775                #[cfg(feature = "quality")]
1776                quality: None,
1777                #[cfg(feature = "contract")]
1778                contract: None,
1779                #[cfg(feature = "masking")]
1780                masking: None,
1781                schema: None,
1782            },
1783            matrix: Vec::new(),
1784            execution: None,
1785            observability: None,
1786            delivery: faucet_core::DeliveryMode::default(),
1787            resilience: None,
1788            sla: None,
1789            shard: None,
1790            replication: None,
1791            #[cfg(feature = "schedule")]
1792            schedule: None,
1793            #[cfg(feature = "lineage")]
1794            lineage: None,
1795            #[cfg(feature = "catalog")]
1796            catalog: None,
1797            #[cfg(feature = "notify")]
1798            notifications: Vec::new(),
1799        }
1800    }
1801
1802    #[tokio::test]
1803    async fn empty_matrix_runs_pipeline_once() {
1804        let dir = tempfile::tempdir().unwrap();
1805        let input = dir.path().join("in.csv");
1806        let output = dir.path().join("out.jsonl");
1807        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
1808        let cfg = cfg_csv_to_jsonl(&input, &output);
1809        let nodes = expand(&cfg).unwrap();
1810        let summary = run_expanded(
1811            nodes,
1812            ExecuteOptions {
1813                pipeline_name: "t".into(),
1814                execution: None,
1815                dry_run: false,
1816                limit: None,
1817                state_path_override: None,
1818                shard: None,
1819                auth: Default::default(),
1820                clock: chrono::Utc::now().fixed_offset(),
1821                cancel: None,
1822                resilience: None,
1823                sla: None,
1824                #[cfg(feature = "lineage")]
1825                lineage: None,
1826                #[cfg(feature = "lineage")]
1827                lineage_cfg: None,
1828                #[cfg(feature = "notify")]
1829                notifier: None,
1830                #[cfg(feature = "catalog")]
1831                catalog: None,
1832            },
1833        )
1834        .await
1835        .unwrap();
1836        assert_eq!(summary.invocations.len(), 1);
1837        assert_eq!(summary.invocations[0].records_written, 2);
1838        assert!(!summary.had_failures());
1839        let body = std::fs::read_to_string(&output).unwrap();
1840        assert_eq!(body.lines().count(), 2);
1841    }
1842
1843    /// Minimal options with a catalog handle attached.
1844    #[cfg(feature = "catalog")]
1845    fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
1846        let mut o = opts(name);
1847        o.catalog = Some(handle);
1848        o
1849    }
1850
1851    #[cfg(feature = "catalog")]
1852    #[tokio::test]
1853    async fn catalog_records_schema_timeline_across_two_runs() {
1854        // Acceptance (#279): running the same pipeline twice with a schema
1855        // change in between produces exactly two schema-timeline entries for
1856        // the dataset, the second carrying a computed diff.
1857        use crate::catalog::CatalogHandle;
1858        use crate::serve::history::RunHistory as _;
1859        use crate::serve::history::catalog::{self, CatalogListFilter};
1860        use crate::serve::history::memory::MemoryHistory;
1861
1862        let dir = tempfile::tempdir().unwrap();
1863        let input = dir.path().join("in.csv");
1864        let output = dir.path().join("out.jsonl");
1865        let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
1866        let handle = CatalogHandle {
1867            store: store.clone(),
1868            run_id: None,
1869            sample_records: 10,
1870        };
1871
1872        std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
1873        let cfg = cfg_csv_to_jsonl(&input, &output);
1874        let nodes = expand(&cfg).unwrap();
1875        let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
1876            .await
1877            .unwrap();
1878        assert!(!summary.had_failures());
1879
1880        // Second run: same pipeline, schema gains an `email` column.
1881        std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
1882        let nodes = expand(&cfg).unwrap();
1883        let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
1884            .await
1885            .unwrap();
1886        assert!(!summary.had_failures());
1887
1888        // Two datasets (source + sink), each with a 2-entry deduped timeline.
1889        let page = store
1890            .catalog_list_datasets(&CatalogListFilter {
1891                limit: 10,
1892                ..Default::default()
1893            })
1894            .await
1895            .unwrap();
1896        assert_eq!(page.datasets.len(), 2, "source + sink datasets");
1897        for ds in &page.datasets {
1898            let detail = store
1899                .catalog_get_dataset(&ds.id)
1900                .await
1901                .unwrap()
1902                .expect("dataset detail");
1903            assert_eq!(detail.dataset.runs, 2);
1904            assert_eq!(
1905                detail.schema_timeline.len(),
1906                2,
1907                "exactly two timeline entries for {}",
1908                ds.uri
1909            );
1910            assert!(detail.schema_timeline[0].diff.is_none());
1911            let diff = detail.schema_timeline[1]
1912                .diff
1913                .as_ref()
1914                .expect("second version carries a diff");
1915            assert!(
1916                diff["added"]
1917                    .as_array()
1918                    .unwrap()
1919                    .iter()
1920                    .any(|c| c["column"] == "email"),
1921                "diff must show the added email column: {diff}"
1922            );
1923            assert_eq!(detail.stats.len(), 2, "one volume point per run");
1924        }
1925        // One lineage edge, csv → jsonl, traversed twice.
1926        let edges = store.catalog_lineage(None, 5).await.unwrap();
1927        assert_eq!(edges.len(), 1);
1928        assert_eq!(edges[0].runs, 2);
1929        assert_eq!(edges[0].last_records, 2);
1930        assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
1931    }
1932
1933    /// A catalog store whose writes always fail — drives the never-fail-the-run
1934    /// contract.
1935    #[cfg(feature = "catalog")]
1936    struct FailingCatalogStore;
1937
1938    #[cfg(feature = "catalog")]
1939    #[async_trait]
1940    impl crate::serve::history::RunHistory for FailingCatalogStore {
1941        async fn claim_idempotency(
1942            &self,
1943            _: &str,
1944            _: &str,
1945            _: &str,
1946            _: std::time::Duration,
1947        ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
1948            Err(crate::serve::history::HistoryError::Backend("down".into()))
1949        }
1950        async fn upsert(
1951            &self,
1952            _: &crate::serve::history::RunRecord,
1953        ) -> Result<(), crate::serve::history::HistoryError> {
1954            Err(crate::serve::history::HistoryError::Backend("down".into()))
1955        }
1956        async fn get(
1957            &self,
1958            _: &str,
1959        ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
1960        {
1961            Err(crate::serve::history::HistoryError::Backend("down".into()))
1962        }
1963        async fn list(
1964            &self,
1965            _: &crate::serve::history::ListFilter,
1966        ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
1967            Err(crate::serve::history::HistoryError::Backend("down".into()))
1968        }
1969        async fn delete(
1970            &self,
1971            _: &str,
1972        ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
1973        {
1974            Err(crate::serve::history::HistoryError::Backend("down".into()))
1975        }
1976        async fn purge_expired(
1977            &self,
1978            _: std::time::Duration,
1979        ) -> Result<usize, crate::serve::history::HistoryError> {
1980            Err(crate::serve::history::HistoryError::Backend("down".into()))
1981        }
1982        async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
1983            Err(crate::serve::history::HistoryError::Backend("down".into()))
1984        }
1985        async fn catalog_record(
1986            &self,
1987            _: &crate::serve::history::catalog::CatalogUpdate,
1988        ) -> Result<(), crate::serve::history::HistoryError> {
1989            Err(crate::serve::history::HistoryError::Backend(
1990                "catalog write refused".into(),
1991            ))
1992        }
1993        fn degraded(&self) -> bool {
1994            false
1995        }
1996    }
1997
1998    #[cfg(feature = "catalog")]
1999    #[tokio::test]
2000    async fn catalog_write_failure_never_fails_the_run() {
2001        // Acceptance (#279): a forced catalog-backend error degrades (logged)
2002        // while the pipeline still succeeds and writes its output.
2003        use crate::catalog::CatalogHandle;
2004        let dir = tempfile::tempdir().unwrap();
2005        let input = dir.path().join("in.csv");
2006        let output = dir.path().join("out.jsonl");
2007        std::fs::write(&input, "name\nalice\n").unwrap();
2008        let cfg = cfg_csv_to_jsonl(&input, &output);
2009        let nodes = expand(&cfg).unwrap();
2010        let handle = CatalogHandle {
2011            store: Arc::new(FailingCatalogStore),
2012            run_id: None,
2013            sample_records: 10,
2014        };
2015        let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
2016            .await
2017            .unwrap();
2018        assert!(
2019            !summary.had_failures(),
2020            "catalog failure must not fail the run"
2021        );
2022        assert_eq!(summary.invocations[0].records_written, 1);
2023        assert_eq!(
2024            std::fs::read_to_string(&output).unwrap().lines().count(),
2025            1,
2026            "sink output written despite the catalog error"
2027        );
2028    }
2029
2030    #[tokio::test]
2031    async fn matrix_two_independent_roots_both_run() {
2032        // Two roots: one writes alice, the other writes bob — to two separate files.
2033        let dir = tempfile::tempdir().unwrap();
2034        let csv_a = dir.path().join("a.csv");
2035        let csv_b = dir.path().join("b.csv");
2036        let out_a = dir.path().join("a.jsonl");
2037        let out_b = dir.path().join("b.jsonl");
2038        std::fs::write(&csv_a, "name\nalice\n").unwrap();
2039        std::fs::write(&csv_b, "name\nbob\n").unwrap();
2040
2041        let yaml = format!(
2042            r#"version: 1
2043pipeline:
2044  source: {{ type: csv, config: {{ path: {a} }} }}
2045  sink:   {{ type: jsonl, config: {{ path: {out_a} }} }}
2046matrix:
2047  - id: rowA
2048  - id: rowB
2049    source: {{ config: {{ path: {b} }} }}
2050    sink:   {{ config: {{ path: {out_b} }} }}
2051"#,
2052            a = csv_a.display(),
2053            b = csv_b.display(),
2054            out_a = out_a.display(),
2055            out_b = out_b.display(),
2056        );
2057        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2058        let nodes = expand(&cfg).unwrap();
2059        let summary = run_expanded(
2060            nodes,
2061            ExecuteOptions {
2062                pipeline_name: "matrix".into(),
2063                execution: None,
2064                dry_run: false,
2065                limit: None,
2066                state_path_override: None,
2067                shard: None,
2068                auth: Default::default(),
2069                clock: chrono::Utc::now().fixed_offset(),
2070                cancel: None,
2071                resilience: None,
2072                sla: None,
2073                #[cfg(feature = "lineage")]
2074                lineage: None,
2075                #[cfg(feature = "lineage")]
2076                lineage_cfg: None,
2077                #[cfg(feature = "notify")]
2078                notifier: None,
2079                #[cfg(feature = "catalog")]
2080                catalog: None,
2081            },
2082        )
2083        .await
2084        .unwrap();
2085        assert_eq!(summary.invocations.len(), 2);
2086        assert!(out_a.exists());
2087        assert!(out_b.exists());
2088    }
2089
2090    #[tokio::test]
2091    async fn dag_child_fans_out_per_parent_record() {
2092        // Parent: CSV with two records (id=1, id=2).
2093        // Child: writes one JSONL file per parent id, using ${parent.id} in the path.
2094        let dir = tempfile::tempdir().unwrap();
2095        let parent_csv = dir.path().join("parents.csv");
2096        let child_csv = dir.path().join("child.csv");
2097        std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
2098        std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
2099        let parent_out = dir.path().join("parents.jsonl");
2100        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2101
2102        let yaml = format!(
2103            r#"version: 1
2104pipeline:
2105  source: {{ type: csv, config: {{ path: {parent} }} }}
2106  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
2107matrix:
2108  - id: parents
2109  - id: child
2110    parent: parents
2111    source: {{ config: {{ path: {child} }} }}
2112    sink:   {{ config: {{ path: "{child_out}" }} }}
2113"#,
2114            parent = parent_csv.display(),
2115            parent_out = parent_out.display(),
2116            child = child_csv.display(),
2117            child_out = child_out_pattern.display(),
2118        );
2119        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2120        let nodes = expand(&cfg).unwrap();
2121        let summary = run_expanded(
2122            nodes,
2123            ExecuteOptions {
2124                pipeline_name: "dagtest".into(),
2125                execution: None,
2126                dry_run: false,
2127                limit: None,
2128                state_path_override: None,
2129                shard: None,
2130                auth: Default::default(),
2131                clock: chrono::Utc::now().fixed_offset(),
2132                cancel: None,
2133                resilience: None,
2134                sla: None,
2135                #[cfg(feature = "lineage")]
2136                lineage: None,
2137                #[cfg(feature = "lineage")]
2138                lineage_cfg: None,
2139                #[cfg(feature = "notify")]
2140                notifier: None,
2141                #[cfg(feature = "catalog")]
2142                catalog: None,
2143            },
2144        )
2145        .await
2146        .unwrap();
2147
2148        // 1 parent invocation + 2 child invocations.
2149        assert_eq!(summary.invocations.len(), 3);
2150        assert!(!summary.had_failures(), "{:?}", summary);
2151        assert!(dir.path().join("child-1.jsonl").exists());
2152        assert!(dir.path().join("child-2.jsonl").exists());
2153    }
2154
2155    #[tokio::test]
2156    async fn depends_on_root_runs_after_dependency() {
2157        // `stage` writes a CSV that `load` reads — `load` can only succeed if
2158        // it genuinely starts after `stage` finishes (pure ordering, no
2159        // record hand-off).
2160        let dir = tempfile::tempdir().unwrap();
2161        let input = dir.path().join("in.csv");
2162        let mid = dir.path().join("mid.csv");
2163        let out = dir.path().join("out.jsonl");
2164        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2165
2166        let yaml = format!(
2167            r#"version: 1
2168pipeline:
2169  source: {{ type: csv, config: {{ path: {input} }} }}
2170  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2171matrix:
2172  - id: stage
2173    sink: {{ type: csv, config: {{ path: {mid} }} }}
2174  - id: load
2175    depends_on: [stage]
2176    source: {{ config: {{ path: {mid} }} }}
2177"#,
2178            input = input.display(),
2179            mid = mid.display(),
2180            out = out.display(),
2181        );
2182        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2183        let nodes = expand(&cfg).unwrap();
2184        let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
2185        assert_eq!(summary.invocations.len(), 2, "{summary:?}");
2186        assert!(!summary.had_failures(), "{summary:?}");
2187        let load = summary
2188            .invocations
2189            .iter()
2190            .find(|i| i.row_id == "load")
2191            .unwrap();
2192        assert_eq!(load.records_written, 2);
2193        let written = std::fs::read_to_string(&out).unwrap();
2194        assert_eq!(written.lines().count(), 2);
2195    }
2196
2197    #[tokio::test]
2198    async fn diamond_dependency_waits_for_all_prerequisites() {
2199        // c waits on both a and b (a diamond join): readiness must require
2200        // *every* dependency to be terminal, not just the first.
2201        let dir = tempfile::tempdir().unwrap();
2202        let input = dir.path().join("in.csv");
2203        let mid_a = dir.path().join("mid_a.csv");
2204        let mid_b = dir.path().join("mid_b.csv");
2205        let out = dir.path().join("out.jsonl");
2206        std::fs::write(&input, "name\nalice\n").unwrap();
2207
2208        let yaml = format!(
2209            r#"version: 1
2210pipeline:
2211  source: {{ type: csv, config: {{ path: {input} }} }}
2212  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2213matrix:
2214  - id: a
2215    sink: {{ type: csv, config: {{ path: {mid_a} }} }}
2216  - id: b
2217    sink: {{ type: csv, config: {{ path: {mid_b} }} }}
2218  - id: c
2219    depends_on: [a, b]
2220    source: {{ config: {{ path: {mid_a} }} }}
2221"#,
2222            input = input.display(),
2223            mid_a = mid_a.display(),
2224            mid_b = mid_b.display(),
2225            out = out.display(),
2226        );
2227        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2228        let nodes = expand(&cfg).unwrap();
2229        let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
2230        assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2231        assert!(!summary.had_failures(), "{summary:?}");
2232        assert!(mid_b.exists(), "b must have run before c became ready");
2233        assert!(out.exists());
2234    }
2235
2236    #[tokio::test]
2237    async fn failed_dependency_skips_dependent() {
2238        // `stage` fails (missing input file); `load` depends on it and must be
2239        // skipped — no invocation outcome, no output file.
2240        let dir = tempfile::tempdir().unwrap();
2241        let good_input = dir.path().join("good.csv");
2242        let out = dir.path().join("out.jsonl");
2243        std::fs::write(&good_input, "name\nalice\n").unwrap();
2244
2245        let yaml = format!(
2246            r#"version: 1
2247pipeline:
2248  source: {{ type: csv, config: {{ path: {good} }} }}
2249  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2250matrix:
2251  - id: stage
2252    source: {{ config: {{ path: {missing} }} }}
2253  - id: load
2254    depends_on: [stage]
2255"#,
2256            good = good_input.display(),
2257            missing = dir.path().join("nonexistent.csv").display(),
2258            out = out.display(),
2259        );
2260        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2261        let nodes = expand(&cfg).unwrap();
2262        let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
2263        assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2264        assert_eq!(summary.invocations[0].row_id, "stage");
2265        assert!(summary.invocations[0].error.is_some());
2266        assert!(
2267            !out.exists(),
2268            "dependent row must not run after its dependency failed"
2269        );
2270    }
2271
2272    #[tokio::test]
2273    async fn dependency_on_skipped_row_cascades() {
2274        // p fails → its child c is skipped → q (which depends on c) must be
2275        // skipped too, even though c itself never *failed*.
2276        let dir = tempfile::tempdir().unwrap();
2277        let good_input = dir.path().join("good.csv");
2278        let out = dir.path().join("q.jsonl");
2279        std::fs::write(&good_input, "id\n1\n").unwrap();
2280
2281        let yaml = format!(
2282            r#"version: 1
2283pipeline:
2284  source: {{ type: csv, config: {{ path: {good} }} }}
2285  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2286matrix:
2287  - id: p
2288    source: {{ config: {{ path: {missing} }} }}
2289  - id: c
2290    parent: p
2291  - id: q
2292    depends_on: [c]
2293"#,
2294            good = good_input.display(),
2295            missing = dir.path().join("nonexistent.csv").display(),
2296            out = out.display(),
2297        );
2298        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2299        let nodes = expand(&cfg).unwrap();
2300        let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
2301        assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2302        assert_eq!(summary.invocations[0].row_id, "p");
2303        assert!(summary.invocations[0].error.is_some());
2304        assert!(
2305            !out.exists(),
2306            "q must be skipped when its dependency was skipped"
2307        );
2308    }
2309
2310    #[tokio::test]
2311    async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
2312        // First root writes to an invalid sink path and fails. The second
2313        // ("good") root would succeed. Under `on_error: stop` the executor
2314        // calls `abort_all()` on the first failure, which cancels pending /
2315        // in-flight tasks at their next await point — but that is
2316        // best-effort: with `max_concurrent: 1` the two roots race for the
2317        // single permit, so "good" may already have completed before "bad"
2318        // fails. We therefore assert the guarantees that hold under *any*
2319        // scheduling rather than an exact invocation count (which was racy,
2320        // see issue #78 finding #24). The deterministic "stop actually
2321        // cancels in-flight work" path is covered by
2322        // `on_error_stop_under_parallelism_aborts_other_in_flight`.
2323        let dir = tempfile::tempdir().unwrap();
2324        let good_csv = dir.path().join("good.csv");
2325        std::fs::write(&good_csv, "x\n1\n").unwrap();
2326        let good_out = dir.path().join("good.jsonl");
2327        let bad_sink_dir = dir.path().to_path_buf();
2328
2329        let yaml = format!(
2330            r#"version: 1
2331pipeline:
2332  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2333  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
2334matrix:
2335  - id: bad
2336    sink: {{ config: {{ path: {bad_dir} }} }}
2337  - id: good
2338execution:
2339  max_concurrent: 1
2340  on_error: stop
2341"#,
2342            good_csv = good_csv.display(),
2343            good_out = good_out.display(),
2344            bad_dir = bad_sink_dir.display(),
2345        );
2346        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2347        let nodes = expand(&cfg).unwrap();
2348        let summary = run_expanded(
2349            nodes,
2350            ExecuteOptions {
2351                pipeline_name: "stoptest".into(),
2352                execution: cfg.execution.clone(),
2353                dry_run: false,
2354                limit: None,
2355                state_path_override: None,
2356                shard: None,
2357                auth: Default::default(),
2358                clock: chrono::Utc::now().fixed_offset(),
2359                cancel: None,
2360                resilience: None,
2361                sla: None,
2362                #[cfg(feature = "lineage")]
2363                lineage: None,
2364                #[cfg(feature = "lineage")]
2365                lineage_cfg: None,
2366                #[cfg(feature = "notify")]
2367                notifier: None,
2368                #[cfg(feature = "catalog")]
2369                catalog: None,
2370            },
2371        )
2372        .await
2373        .unwrap();
2374
2375        // Invariants that hold regardless of which root won the permit race:
2376        assert!(summary.had_failures(), "the failing root must be reported");
2377
2378        // "bad" ran exactly once and is recorded as a failure.
2379        let bad: Vec<_> = summary
2380            .invocations
2381            .iter()
2382            .filter(|o| o.row_id == "bad")
2383            .collect();
2384        assert_eq!(bad.len(), 1, "bad must run exactly once");
2385        assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
2386
2387        // No duplicate / extra invocations beyond the two work units.
2388        assert!(
2389            summary.invocations.len() <= 2,
2390            "at most the two roots may run, got {:?}",
2391            summary.invocations
2392        );
2393
2394        // "good" may: (a) win the permit first and run fully (writes its row,
2395        // file exists); (b) lose the race, acquire the permit after "bad" fails,
2396        // observe the cooperative stop-cancel at its first page boundary, and
2397        // return a 0-record success (no file); or (c) never appear if it was
2398        // still pending when the level finished. So the only invariant is: a
2399        // "good" that actually WROTE records must have produced its file.
2400        let good_wrote = summary
2401            .invocations
2402            .iter()
2403            .find(|o| o.row_id == "good" && o.error.is_none())
2404            .map(|o| o.records_written)
2405            .unwrap_or(0);
2406        if good_wrote > 0 {
2407            assert!(
2408                good_out.exists(),
2409                "a good that wrote records must have produced its output file"
2410            );
2411        }
2412    }
2413
2414    #[tokio::test]
2415    async fn invalid_pipeline_name_with_state_errors_up_front() {
2416        // A pipeline name that can't form a valid state key must fail up front
2417        // (at unit construction) when state is configured — not deep mid-run
2418        // as a `FaucetError::State`.
2419        let dir = tempfile::tempdir().unwrap();
2420        let input = dir.path().join("in.csv");
2421        let output = dir.path().join("out.jsonl");
2422        std::fs::write(&input, "name\nalice\n").unwrap();
2423        let yaml = format!(
2424            r#"version: 1
2425pipeline:
2426  source: {{ type: csv, config: {{ path: {input} }} }}
2427  sink:   {{ type: jsonl, config: {{ path: {output} }} }}
2428  state:  {{ type: memory }}
2429"#,
2430            input = input.display(),
2431            output = output.display(),
2432        );
2433        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2434        let nodes = expand(&cfg).unwrap();
2435        let err = run_expanded(
2436            nodes,
2437            ExecuteOptions {
2438                pipeline_name: "bad name".into(), // space is illegal in a state key
2439                execution: None,
2440                dry_run: false,
2441                limit: None,
2442                state_path_override: None,
2443                shard: None,
2444                auth: Default::default(),
2445                clock: chrono::Utc::now().fixed_offset(),
2446                cancel: None,
2447                resilience: None,
2448                sla: None,
2449                #[cfg(feature = "lineage")]
2450                lineage: None,
2451                #[cfg(feature = "lineage")]
2452                lineage_cfg: None,
2453                #[cfg(feature = "notify")]
2454                notifier: None,
2455                #[cfg(feature = "catalog")]
2456                catalog: None,
2457            },
2458        )
2459        .await
2460        .expect_err("an invalid pipeline name must be rejected up front when state is configured");
2461        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2462    }
2463
2464    #[tokio::test]
2465    async fn invalid_parent_key_value_with_state_errors_up_front() {
2466        // A parent-record value that yields an illegal state-key suffix must
2467        // fail up front at the child's unit construction, not mid-run.
2468        let dir = tempfile::tempdir().unwrap();
2469        let parent_csv = dir.path().join("parents.csv");
2470        let child_csv = dir.path().join("child.csv");
2471        // The parent `id` value contains a space — illegal in a state key.
2472        std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
2473        std::fs::write(&child_csv, "x\nA\n").unwrap();
2474        let parent_out = dir.path().join("parents.jsonl");
2475        let child_out = dir.path().join("child.jsonl");
2476        let yaml = format!(
2477            r#"version: 1
2478pipeline:
2479  source: {{ type: csv, config: {{ path: {parent} }} }}
2480  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
2481  state:  {{ type: memory }}
2482matrix:
2483  - id: parents
2484  - id: child
2485    parent: parents
2486    source: {{ config: {{ path: {child} }} }}
2487    sink:   {{ config: {{ path: {child_out} }} }}
2488"#,
2489            parent = parent_csv.display(),
2490            parent_out = parent_out.display(),
2491            child = child_csv.display(),
2492            child_out = child_out.display(),
2493        );
2494        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2495        let nodes = expand(&cfg).unwrap();
2496        let err = run_expanded(
2497            nodes,
2498            ExecuteOptions {
2499                pipeline_name: "ok".into(),
2500                execution: None,
2501                dry_run: false,
2502                limit: None,
2503                state_path_override: None,
2504                shard: None,
2505                auth: Default::default(),
2506                clock: chrono::Utc::now().fixed_offset(),
2507                cancel: None,
2508                resilience: None,
2509                sla: None,
2510                #[cfg(feature = "lineage")]
2511                lineage: None,
2512                #[cfg(feature = "lineage")]
2513                lineage_cfg: None,
2514                #[cfg(feature = "notify")]
2515                notifier: None,
2516                #[cfg(feature = "catalog")]
2517                catalog: None,
2518            },
2519        )
2520        .await
2521        .expect_err(
2522            "an illegal parent-key value must be rejected up front when state is configured",
2523        );
2524        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2525    }
2526
2527    #[tokio::test]
2528    async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
2529        // Three roots running with `max_concurrent: 3`. The bad row points
2530        // its sink at a directory (open fails fast). The other two point at
2531        // sinks that block forever on the writer end of a pipe — stuck *inside*
2532        // the sink write, they never reach a page boundary to observe the
2533        // cooperative stop-cancel, so the only way they can complete is the
2534        // hard-abort backstop that fires after the flush grace (#146 H16). The
2535        // test would hang if `on_error: stop` never aborted them, so a passing
2536        // run is itself the assertion.
2537        let dir = tempfile::tempdir().unwrap();
2538        let bad_sink_dir = dir.path().to_path_buf();
2539        // A real csv source with one row — small enough that the pipeline
2540        // proceeds straight to the sink phase.
2541        let good_csv = dir.path().join("good.csv");
2542        std::fs::write(&good_csv, "x\n1\n").unwrap();
2543        // The two "would never finish" sinks point at the same path as the
2544        // bad sink (an existing directory). Their sink-open also errors
2545        // out — but we still verify the *abort* path by counting how many
2546        // tasks make it past spawn before stop fires. The strict invariant
2547        // we assert: the bad row's failure is the first one observed.
2548        let yaml = format!(
2549            r#"version: 1
2550pipeline:
2551  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2552  sink:   {{ type: jsonl, config: {{ path: {bad_dir} }} }}
2553matrix:
2554  - id: bad
2555  - id: good_a
2556  - id: good_b
2557execution:
2558  max_concurrent: 3
2559  on_error: stop
2560"#,
2561            good_csv = good_csv.display(),
2562            bad_dir = bad_sink_dir.display(),
2563        );
2564        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2565        let nodes = expand(&cfg).unwrap();
2566        let summary = run_expanded(
2567            nodes,
2568            ExecuteOptions {
2569                pipeline_name: "stop_parallel".into(),
2570                execution: cfg.execution.clone(),
2571                dry_run: false,
2572                limit: None,
2573                state_path_override: None,
2574                shard: None,
2575                auth: Default::default(),
2576                clock: chrono::Utc::now().fixed_offset(),
2577                cancel: None,
2578                resilience: None,
2579                sla: None,
2580                #[cfg(feature = "lineage")]
2581                lineage: None,
2582                #[cfg(feature = "lineage")]
2583                lineage_cfg: None,
2584                #[cfg(feature = "notify")]
2585                notifier: None,
2586                #[cfg(feature = "catalog")]
2587                catalog: None,
2588            },
2589        )
2590        .await
2591        .unwrap();
2592
2593        // First-observed failure halts the run. The first outcome in the
2594        // summary is guaranteed to be a failure (other tasks either fail
2595        // too or get cancelled — both cases never push a *success* outcome
2596        // first because every sink in this matrix is configured to fail).
2597        assert!(
2598            summary.had_failures(),
2599            "summary should record at least one failure: {summary:?}"
2600        );
2601        assert!(
2602            summary.invocations[0].error.is_some(),
2603            "first outcome must be the failure that triggered stop: {summary:?}"
2604        );
2605        // No invocation should report `records_written > 0` — every sink is
2606        // bad. (Catches a regression where abort_all somehow let a task
2607        // bypass its broken sink.)
2608        for inv in &summary.invocations {
2609            assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
2610        }
2611    }
2612
2613    #[tokio::test]
2614    async fn on_error_continue_skips_failed_subtree_only() {
2615        // Two roots: one fails. The good one's invocation still completes.
2616        let dir = tempfile::tempdir().unwrap();
2617        let good_csv = dir.path().join("good.csv");
2618        std::fs::write(&good_csv, "x\n1\n").unwrap();
2619        let good_out = dir.path().join("good.jsonl");
2620
2621        let yaml = format!(
2622            r#"version: 1
2623pipeline:
2624  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2625  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
2626matrix:
2627  - id: bad
2628    sink: {{ config: {{ path: {bad_dir} }} }}
2629  - id: good
2630"#,
2631            good_csv = good_csv.display(),
2632            good_out = good_out.display(),
2633            bad_dir = dir.path().display(),
2634        );
2635        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2636        let nodes = expand(&cfg).unwrap();
2637        let summary = run_expanded(
2638            nodes,
2639            ExecuteOptions {
2640                pipeline_name: "continuetest".into(),
2641                execution: None,
2642                dry_run: false,
2643                limit: None,
2644                state_path_override: None,
2645                shard: None,
2646                auth: Default::default(),
2647                clock: chrono::Utc::now().fixed_offset(),
2648                cancel: None,
2649                resilience: None,
2650                sla: None,
2651                #[cfg(feature = "lineage")]
2652                lineage: None,
2653                #[cfg(feature = "lineage")]
2654                lineage_cfg: None,
2655                #[cfg(feature = "notify")]
2656                notifier: None,
2657                #[cfg(feature = "catalog")]
2658                catalog: None,
2659            },
2660        )
2661        .await
2662        .unwrap();
2663        assert_eq!(summary.invocations.len(), 2);
2664        assert_eq!(summary.failure_count(), 1);
2665        let good_outcome = summary
2666            .invocations
2667            .iter()
2668            .find(|i| i.row_id == "good")
2669            .unwrap();
2670        assert!(good_outcome.error.is_none());
2671    }
2672
2673    // ── projection helpers (#160) ─────────────────────────────────────────────
2674
2675    #[test]
2676    fn split_path_splits_on_dots() {
2677        assert_eq!(split_path("id"), vec!["id".to_string()]);
2678        assert_eq!(
2679            split_path("user.name"),
2680            vec!["user".to_string(), "name".to_string()]
2681        );
2682    }
2683
2684    #[test]
2685    fn minimal_paths_drops_descendants_of_kept_ancestors() {
2686        let paths = vec![
2687            vec!["user".into(), "name".into()],
2688            vec!["user".into()],
2689            vec!["id".into()],
2690            vec!["id".into()],
2691        ];
2692        let min = minimal_paths(paths);
2693        assert!(min.contains(&vec!["user".to_string()]));
2694        assert!(min.contains(&vec!["id".to_string()]));
2695        assert!(
2696            !min.contains(&vec!["user".to_string(), "name".to_string()]),
2697            "user.name must be dropped — covered by user"
2698        );
2699        assert_eq!(min.len(), 2);
2700    }
2701
2702    #[test]
2703    fn project_full_clones_whole_record() {
2704        let r = json!({"a": 1, "b": {"c": 2}});
2705        assert_eq!(project_record(&r, &Projection::Full), r);
2706    }
2707
2708    #[test]
2709    fn project_keeps_only_referenced_paths() {
2710        let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
2711        let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
2712        let got = project_record(&r, &p);
2713        assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
2714        assert!(got.get("blob").is_none());
2715        assert!(got["user"].get("age").is_none());
2716    }
2717
2718    #[test]
2719    fn project_array_index_path_resolves_same_as_original() {
2720        let r = json!({"tags": ["x", "y", "z"]});
2721        let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
2722        let got = project_record(&r, &p);
2723        assert_eq!(got, json!({"tags": {"0": "x"}}));
2724        assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
2725        assert_eq!(
2726            resolve_parent_key(&got, "tags.0"),
2727            resolve_parent_key(&r, "tags.0"),
2728            "reduced tree must resolve the same value as the original"
2729        );
2730    }
2731
2732    #[test]
2733    fn project_numeric_object_key_resolves_same_as_original() {
2734        // A numeric segment can address an OBJECT key in the original (not an
2735        // array index). The reduced all-objects tree stores it under the same
2736        // key, so resolution still matches — the parity property the design
2737        // relies on, distinct from the array-index case above.
2738        let r = json!({"data": {"0": "x", "1": "y"}});
2739        let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
2740        let got = project_record(&r, &p);
2741        assert_eq!(got, json!({"data": {"0": "x"}}));
2742        assert_eq!(
2743            resolve_parent_key(&got, "data.0"),
2744            resolve_parent_key(&r, "data.0"),
2745            "numeric object-key path must resolve identically on the reduced tree"
2746        );
2747    }
2748
2749    #[test]
2750    fn project_missing_path_is_omitted() {
2751        let r = json!({"id": 1});
2752        let p = Projection::Paths(vec![vec!["nope".into()]]);
2753        assert_eq!(project_record(&r, &p), json!({}));
2754    }
2755
2756    #[test]
2757    fn build_projections_unions_parent_key_and_refs() {
2758        use crate::config::ConnectorSpec;
2759        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2760
2761        fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
2762            ExpandedNode {
2763                id: id.into(),
2764                row_index: 0,
2765                role: NodeRole::Child {
2766                    parent_id: parent.into(),
2767                    parent_key: parent_key.into(),
2768                },
2769                source: ConnectorSpec {
2770                    kind: "csv".into(),
2771                    config: json!({}),
2772                    transforms: None,
2773                    inherit_transforms: true,
2774                },
2775                sink: ConnectorSpec {
2776                    kind: "jsonl".into(),
2777                    config: json!({}),
2778                    transforms: None,
2779                    inherit_transforms: true,
2780                },
2781                transforms: Vec::new(),
2782                state: None,
2783                dlq: None,
2784                delivery: faucet_core::DeliveryMode::AtLeastOnce,
2785                delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
2786                #[cfg(feature = "quality")]
2787                quality: None,
2788                #[cfg(feature = "contract")]
2789                contract: None,
2790                #[cfg(feature = "masking")]
2791                masking: None,
2792                sink_ref: "default".into(),
2793                schema: None,
2794                depends_on: Vec::new(),
2795                deferred_refs: refs
2796                    .iter()
2797                    .map(|(rid, p)| DeferredRef {
2798                        referenced_id: (*rid).into(),
2799                        dotted_path: (*p).into(),
2800                        token: format!("${{{rid}.{p}}}"),
2801                    })
2802                    .collect(),
2803                source_override: None,
2804            }
2805        }
2806
2807        let c1 = child("c1", "p", "id", &[("p", "user.name")]);
2808        let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
2809        let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
2810        let children_of =
2811            HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
2812
2813        let projs = build_projections(&nodes_by_id, &children_of);
2814        let p = projs.get("p").expect("projection for p");
2815        match &**p {
2816            Projection::Paths(paths) => {
2817                assert!(paths.contains(&vec!["id".to_string()]));
2818                assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
2819                assert!(paths.contains(&vec!["email".to_string()]));
2820                assert!(
2821                    !paths.iter().any(|p| p == &vec!["x".to_string()]),
2822                    "a ref to a different parent must not be captured under p"
2823                );
2824            }
2825            Projection::Full => panic!("expected Paths, got Full"),
2826        }
2827    }
2828
2829    #[test]
2830    fn build_projections_whole_record_ref_is_full() {
2831        use crate::config::ConnectorSpec;
2832        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2833        let c = ExpandedNode {
2834            id: "c".into(),
2835            row_index: 0,
2836            role: NodeRole::Child {
2837                parent_id: "p".into(),
2838                parent_key: "id".into(),
2839            },
2840            source: ConnectorSpec {
2841                kind: "csv".into(),
2842                config: json!({}),
2843                transforms: None,
2844                inherit_transforms: true,
2845            },
2846            sink: ConnectorSpec {
2847                kind: "jsonl".into(),
2848                config: json!({}),
2849                transforms: None,
2850                inherit_transforms: true,
2851            },
2852            transforms: Vec::new(),
2853            state: None,
2854            dlq: None,
2855            delivery: faucet_core::DeliveryMode::AtLeastOnce,
2856            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
2857            #[cfg(feature = "quality")]
2858            quality: None,
2859            #[cfg(feature = "contract")]
2860            contract: None,
2861            #[cfg(feature = "masking")]
2862            masking: None,
2863            sink_ref: "default".into(),
2864            schema: None,
2865            depends_on: Vec::new(),
2866            deferred_refs: vec![DeferredRef {
2867                referenced_id: "p".into(),
2868                dotted_path: "".into(),
2869                token: "${p}".into(),
2870            }],
2871            source_override: None,
2872        };
2873        let nodes_by_id = HashMap::from([("c".to_string(), c)]);
2874        let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
2875        let projs = build_projections(&nodes_by_id, &children_of);
2876        assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
2877    }
2878
2879    /// Helper: minimal `ExecuteOptions` with all optional knobs cleared.
2880    fn opts(name: &str) -> ExecuteOptions {
2881        ExecuteOptions {
2882            pipeline_name: name.into(),
2883            execution: None,
2884            dry_run: false,
2885            limit: None,
2886            state_path_override: None,
2887            shard: None,
2888            auth: Default::default(),
2889            clock: chrono::Utc::now().fixed_offset(),
2890            cancel: None,
2891            resilience: None,
2892            sla: None,
2893            #[cfg(feature = "lineage")]
2894            lineage: None,
2895            #[cfg(feature = "lineage")]
2896            lineage_cfg: None,
2897            #[cfg(feature = "notify")]
2898            notifier: None,
2899            #[cfg(feature = "catalog")]
2900            catalog: None,
2901        }
2902    }
2903
2904    #[tokio::test]
2905    async fn dry_run_counts_records_without_writing_sink_file() {
2906        // `--dry-run` swaps the real sink for a CountingSink — records flow but
2907        // no output file is produced.
2908        let dir = tempfile::tempdir().unwrap();
2909        let input = dir.path().join("in.csv");
2910        let output = dir.path().join("out.jsonl");
2911        std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
2912        let cfg = cfg_csv_to_jsonl(&input, &output);
2913        let nodes = expand(&cfg).unwrap();
2914        let mut o = opts("dry");
2915        o.dry_run = true;
2916        let summary = run_expanded(nodes, o).await.unwrap();
2917        assert_eq!(summary.invocations.len(), 1);
2918        assert_eq!(summary.invocations[0].records_written, 3);
2919        assert!(!summary.had_failures());
2920        assert!(
2921            !output.exists(),
2922            "dry-run must not create the real sink file"
2923        );
2924    }
2925
2926    #[tokio::test]
2927    async fn limit_caps_records_written_across_the_run() {
2928        // `--limit N` wraps the sink so only the first N records land.
2929        let dir = tempfile::tempdir().unwrap();
2930        let input = dir.path().join("in.csv");
2931        let output = dir.path().join("out.jsonl");
2932        std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
2933        let cfg = cfg_csv_to_jsonl(&input, &output);
2934        let nodes = expand(&cfg).unwrap();
2935        let mut o = opts("lim");
2936        o.limit = Some(2);
2937        let summary = run_expanded(nodes, o).await.unwrap();
2938        assert_eq!(summary.invocations[0].records_written, 2);
2939        let body = std::fs::read_to_string(&output).unwrap();
2940        assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
2941    }
2942
2943    #[tokio::test]
2944    async fn duplicate_state_key_among_siblings_is_rejected() {
2945        // Two parent records whose `parent_key` value collides (both id="dup")
2946        // produce two child units with the SAME state key. With state
2947        // configured, that collision must surface as DuplicateStateKey.
2948        let dir = tempfile::tempdir().unwrap();
2949        let parent_csv = dir.path().join("parents.csv");
2950        let child_csv = dir.path().join("child.csv");
2951        // Both rows share id="dup" — the per-child state-key suffix collides.
2952        std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
2953        std::fs::write(&child_csv, "x\nA\n").unwrap();
2954        let parent_out = dir.path().join("parents.jsonl");
2955        let child_out = dir.path().join("child.jsonl");
2956        let yaml = format!(
2957            r#"version: 1
2958pipeline:
2959  source: {{ type: csv, config: {{ path: {parent} }} }}
2960  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
2961  state:  {{ type: memory }}
2962matrix:
2963  - id: parents
2964  - id: child
2965    parent: parents
2966    source: {{ config: {{ path: {child} }} }}
2967    sink:   {{ config: {{ path: {child_out} }} }}
2968"#,
2969            parent = parent_csv.display(),
2970            parent_out = parent_out.display(),
2971            child = child_csv.display(),
2972            child_out = child_out.display(),
2973        );
2974        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2975        let nodes = expand(&cfg).unwrap();
2976        let err = run_expanded(nodes, opts("dupkey"))
2977            .await
2978            .expect_err("colliding sibling state keys must be rejected");
2979        match err {
2980            CliError::DuplicateStateKey { id, state_key } => {
2981                assert_eq!(id, "child");
2982                assert_eq!(state_key, "dupkey::child::dup");
2983            }
2984            other => panic!("expected DuplicateStateKey, got {other:?}"),
2985        }
2986    }
2987
2988    #[tokio::test]
2989    async fn state_path_override_writes_bookmark_file() {
2990        // `--state-path` with a node that has no `state:` block wires a
2991        // FileStateStore at the override path; running it should create the
2992        // bookmark file (REST-less csv source still opts into state via the
2993        // override).
2994        let dir = tempfile::tempdir().unwrap();
2995        let input = dir.path().join("in.csv");
2996        let output = dir.path().join("out.jsonl");
2997        let state_dir = dir.path().join("state");
2998        std::fs::write(&input, "name\nalice\n").unwrap();
2999        let cfg = cfg_csv_to_jsonl(&input, &output);
3000        let nodes = expand(&cfg).unwrap();
3001        let mut o = opts("statepath");
3002        o.state_path_override = Some(state_dir.clone());
3003        let summary = run_expanded(nodes, o).await.unwrap();
3004        assert!(!summary.had_failures());
3005        // The csv source has no natural state key, so the StateKeyOverride wrap
3006        // is skipped — but build_state_for_node still constructs the store.
3007        // The run completing without error exercises the (None, Some(path)) arm.
3008        assert_eq!(summary.invocations[0].records_written, 1);
3009    }
3010
3011    #[tokio::test]
3012    async fn build_dlq_config_maps_spec_fields() {
3013        use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
3014        let dir = tempfile::tempdir().unwrap();
3015        let dlq_out = dir.path().join("dlq.jsonl");
3016        let spec = DlqSpec {
3017            sink: ConnectorSpec {
3018                kind: "jsonl".into(),
3019                config: json!({ "path": dlq_out.to_str().unwrap() }),
3020                transforms: None,
3021                inherit_transforms: true,
3022            },
3023            on_batch_error: OnBatchErrorSpec::DlqAll,
3024            max_failures_per_page: Some(7),
3025            max_failures_total: Some(42),
3026            include_original_payload: false,
3027        };
3028        let cfg = build_dlq_config(&spec).await.unwrap();
3029        assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
3030        assert_eq!(cfg.max_failures_per_page, Some(7));
3031        assert_eq!(cfg.max_failures_total, Some(42));
3032        assert!(!cfg.include_original_payload);
3033    }
3034
3035    #[tokio::test]
3036    async fn build_state_for_node_arms() {
3037        let dir = tempfile::tempdir().unwrap();
3038
3039        // (None, None) → no store.
3040        let node = stub_node(None);
3041        assert!(build_state_for_node(&node, None).await.unwrap().is_none());
3042
3043        // (None, Some(path)) → FileStateStore from override.
3044        let p = dir.path().join("s1");
3045        assert!(
3046            build_state_for_node(&node, Some(&p))
3047                .await
3048                .unwrap()
3049                .is_some()
3050        );
3051
3052        // (Some(memory spec), None) → built from spec.
3053        let node_mem = stub_node(Some(crate::config::StateStoreSpec {
3054            kind: "memory".into(),
3055            config: json!({}),
3056        }));
3057        assert!(
3058            build_state_for_node(&node_mem, None)
3059                .await
3060                .unwrap()
3061                .is_some()
3062        );
3063
3064        // (Some(file spec), Some(path)) → file backend uses the override path.
3065        let node_file = stub_node(Some(crate::config::StateStoreSpec {
3066            kind: "file".into(),
3067            config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
3068        }));
3069        let p2 = dir.path().join("override2");
3070        assert!(
3071            build_state_for_node(&node_file, Some(&p2))
3072                .await
3073                .unwrap()
3074                .is_some()
3075        );
3076
3077        // (Some(memory spec), Some(path)) → non-file backend ignores override,
3078        // still builds from spec.
3079        let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
3080            kind: "memory".into(),
3081            config: json!({}),
3082        }));
3083        let p3 = dir.path().join("override3");
3084        assert!(
3085            build_state_for_node(&node_mem2, Some(&p3))
3086                .await
3087                .unwrap()
3088                .is_some()
3089        );
3090    }
3091
3092    /// Build a minimal root `ExpandedNode` carrying only an (optional) state spec.
3093    fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
3094        use crate::config::ConnectorSpec;
3095        ExpandedNode {
3096            id: "n".into(),
3097            row_index: 0,
3098            role: NodeRole::Root,
3099            source: ConnectorSpec {
3100                kind: "csv".into(),
3101                config: json!({}),
3102                transforms: None,
3103                inherit_transforms: true,
3104            },
3105            sink: ConnectorSpec {
3106                kind: "jsonl".into(),
3107                config: json!({}),
3108                transforms: None,
3109                inherit_transforms: true,
3110            },
3111            transforms: Vec::new(),
3112            state,
3113            dlq: None,
3114            delivery: faucet_core::DeliveryMode::AtLeastOnce,
3115            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3116            #[cfg(feature = "quality")]
3117            quality: None,
3118            #[cfg(feature = "contract")]
3119            contract: None,
3120            #[cfg(feature = "masking")]
3121            masking: None,
3122            sink_ref: "default".into(),
3123            schema: None,
3124            depends_on: Vec::new(),
3125            deferred_refs: Vec::new(),
3126            source_override: None,
3127        }
3128    }
3129
3130    #[tokio::test]
3131    async fn state_key_override_delegates_and_overrides_key() {
3132        // StateKeyOverride forwards fetch/bookmark to inner and reports its own key.
3133        let dir = tempfile::tempdir().unwrap();
3134        let input = dir.path().join("in.csv");
3135        std::fs::write(&input, "name\nz\n").unwrap();
3136        let inner = build_source(
3137            "csv",
3138            json!({"path": input.to_str().unwrap()}),
3139            &AuthCatalog::new(),
3140            None,
3141        )
3142        .await
3143        .unwrap();
3144        // The wrapped name must match the inner source's, whatever it reports.
3145        let inner_name = inner.connector_name();
3146        let ov = StateKeyOverride {
3147            inner,
3148            key: "my::custom::key".into(),
3149        };
3150        assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
3151        assert_eq!(ov.connector_name(), inner_name);
3152        let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
3153        assert_eq!(rows.len(), 1);
3154        // apply_start_bookmark delegates without error (csv ignores it).
3155        ov.apply_start_bookmark(json!({"any": "bookmark"}))
3156            .await
3157            .unwrap();
3158        // Capability passthroughs (csv defaults).
3159        assert!(!ov.supports_exactly_once());
3160        assert_eq!(
3161            ov.replay_guarantee(),
3162            faucet_core::ReplayGuarantee::NonDeterministic
3163        );
3164        assert_eq!(ov.capture_resume_position().await.unwrap(), None);
3165    }
3166
3167    #[tokio::test]
3168    async fn state_key_override_forwards_native_stream_pages() {
3169        // The wrap must preserve the inner source's NATIVE page stream —
3170        // per-page bookmarks included. Without the `stream_pages` forward, the
3171        // trait's buffering default kicks in and collapses everything into
3172        // final-page-bookmark-only pages (losing CDC per-transaction
3173        // durability and exactly-once per-page tokens).
3174        struct PerPageBookmarkSource;
3175        #[async_trait]
3176        impl Source for PerPageBookmarkSource {
3177            async fn fetch_with_context(
3178                &self,
3179                _ctx: &HashMap<String, Value>,
3180            ) -> Result<Vec<Value>, FaucetError> {
3181                Ok(vec![json!({"id": 1}), json!({"id": 2})])
3182            }
3183            fn stream_pages<'a>(
3184                &'a self,
3185                _ctx: &'a HashMap<String, Value>,
3186                _batch_size: usize,
3187            ) -> std::pin::Pin<
3188                Box<
3189                    dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
3190                        + Send
3191                        + 'a,
3192                >,
3193            > {
3194                Box::pin(faucet_core::async_stream::try_stream! {
3195                    yield faucet_core::StreamPage {
3196                        records: vec![json!({"id": 1})],
3197                        bookmark: Some(json!("bm-1")),
3198                    };
3199                    yield faucet_core::StreamPage {
3200                        records: vec![json!({"id": 2})],
3201                        bookmark: Some(json!("bm-2")),
3202                    };
3203                })
3204            }
3205            fn state_key(&self) -> Option<String> {
3206                Some("native".into())
3207            }
3208        }
3209
3210        use futures::StreamExt;
3211        let ov = StateKeyOverride {
3212            inner: Box::new(PerPageBookmarkSource),
3213            key: "override".into(),
3214        };
3215        let ctx = HashMap::new();
3216        let pages: Vec<_> = ov
3217            .stream_pages(&ctx, 1000)
3218            .collect::<Vec<_>>()
3219            .await
3220            .into_iter()
3221            .collect::<Result<Vec<_>, _>>()
3222            .unwrap();
3223        assert_eq!(pages.len(), 2, "native page boundaries survive the wrap");
3224        assert_eq!(pages[0].bookmark, Some(json!("bm-1")));
3225        assert_eq!(pages[1].bookmark, Some(json!("bm-2")));
3226    }
3227
3228    #[tokio::test]
3229    async fn capturing_sink_forwards_capabilities_and_captures_idempotent_writes() {
3230        struct IdemSink;
3231        #[async_trait]
3232        impl Sink for IdemSink {
3233            async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3234                Ok(records.len())
3235            }
3236            fn connector_name(&self) -> &'static str {
3237                "idem"
3238            }
3239            fn supports_idempotent_writes(&self) -> bool {
3240                true
3241            }
3242            fn dedups_by_key(&self) -> bool {
3243                true
3244            }
3245            fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
3246                &[
3247                    faucet_core::WriteMode::Append,
3248                    faucet_core::WriteMode::Upsert,
3249                ]
3250            }
3251            async fn write_batch_idempotent(
3252                &self,
3253                records: &[Value],
3254                _scope: &str,
3255                _token: &str,
3256            ) -> Result<usize, FaucetError> {
3257                Ok(records.len())
3258            }
3259            async fn last_committed_token(
3260                &self,
3261                _scope: &str,
3262            ) -> Result<Option<String>, FaucetError> {
3263                Ok(Some("tok".into()))
3264            }
3265        }
3266
3267        let captured = Arc::new(Mutex::new(Vec::new()));
3268        let sink = CapturingSink::wrap(
3269            Box::new(IdemSink),
3270            Arc::clone(&captured),
3271            Arc::new(Projection::Full),
3272        );
3273        // Capability passthroughs: a parent row feeding children keeps the
3274        // inner sink's delivery semantics.
3275        assert!(sink.supports_idempotent_writes());
3276        assert!(sink.dedups_by_key());
3277        assert_eq!(
3278            sink.sink_guarantee(),
3279            faucet_core::SinkGuarantee::AtomicWatermark
3280        );
3281        assert!(
3282            sink.supported_write_modes()
3283                .contains(&faucet_core::WriteMode::Upsert)
3284        );
3285        assert_eq!(
3286            sink.last_committed_token("k").await.unwrap(),
3287            Some("tok".into())
3288        );
3289        assert_eq!(sink.current_schema().await.unwrap(), None);
3290        assert!(!sink.supports_schema_evolution());
3291        // Idempotent writes are captured for child fan-out like plain writes.
3292        let n = sink
3293            .write_batch_idempotent(&[json!({"id": 7})], "k", "t")
3294            .await
3295            .unwrap();
3296        assert_eq!(n, 1);
3297        assert_eq!(*captured.lock().await, vec![json!({"id": 7})]);
3298    }
3299
3300    #[tokio::test]
3301    async fn orphaned_child_surfaces_executor_deadlock() {
3302        // A child node whose parent id is never present among the nodes can
3303        // never become ready. `expand` would reject this, but a hand-built node
3304        // list exercises the executor's own deadlock guard (lines 227-233).
3305        use crate::config::ConnectorSpec;
3306        let orphan = ExpandedNode {
3307            id: "orphan".into(),
3308            row_index: 0,
3309            role: NodeRole::Child {
3310                parent_id: "missing-parent".into(),
3311                parent_key: "id".into(),
3312            },
3313            source: ConnectorSpec {
3314                kind: "csv".into(),
3315                config: json!({}),
3316                transforms: None,
3317                inherit_transforms: true,
3318            },
3319            sink: ConnectorSpec {
3320                kind: "jsonl".into(),
3321                config: json!({}),
3322                transforms: None,
3323                inherit_transforms: true,
3324            },
3325            transforms: Vec::new(),
3326            state: None,
3327            dlq: None,
3328            delivery: faucet_core::DeliveryMode::AtLeastOnce,
3329            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3330            #[cfg(feature = "quality")]
3331            quality: None,
3332            #[cfg(feature = "contract")]
3333            contract: None,
3334            #[cfg(feature = "masking")]
3335            masking: None,
3336            sink_ref: "default".into(),
3337            schema: None,
3338            depends_on: Vec::new(),
3339            deferred_refs: Vec::new(),
3340            source_override: None,
3341        };
3342        let err = run_expanded(vec![orphan], opts("deadlock"))
3343            .await
3344            .expect_err("an orphaned child must surface as an executor deadlock");
3345        match err {
3346            CliError::Internal(msg) => {
3347                assert!(msg.contains("executor deadlock"), "{msg}");
3348                assert!(msg.contains("orphan"), "{msg}");
3349            }
3350            other => panic!("expected Internal deadlock error, got {other:?}"),
3351        }
3352    }
3353
3354    #[test]
3355    fn value_to_string_brief_unquotes_strings_only() {
3356        assert_eq!(value_to_string_brief(&json!("hello")), "hello");
3357        assert_eq!(value_to_string_brief(&json!(42)), "42");
3358        assert_eq!(value_to_string_brief(&json!(true)), "true");
3359        assert_eq!(value_to_string_brief(&json!(null)), "null");
3360        assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
3361    }
3362
3363    #[test]
3364    fn build_state_key_with_and_without_parent() {
3365        assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
3366        assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
3367    }
3368
3369    #[test]
3370    fn resolve_parent_key_walks_objects_arrays_and_misses() {
3371        let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
3372        assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
3373        assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
3374        // Missing key → None.
3375        assert_eq!(resolve_parent_key(&r, "user.age"), None);
3376        // Descending into a scalar → None.
3377        assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
3378        // Non-numeric array index → None.
3379        assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
3380    }
3381
3382    #[tokio::test]
3383    async fn cooperative_cancel_returns_partial_ok() {
3384        // A pre-cancelled token makes the run stop at the first page boundary
3385        // and flush — returning Ok with a partial (possibly empty) result
3386        // rather than erroring. Covers the cancel-threading path.
3387        let dir = tempfile::tempdir().unwrap();
3388        let input = dir.path().join("in.csv");
3389        let output = dir.path().join("out.jsonl");
3390        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3391        let cfg = cfg_csv_to_jsonl(&input, &output);
3392        let nodes = expand(&cfg).unwrap();
3393        let token = CancellationToken::new();
3394        token.cancel(); // already cancelled before the run starts
3395        let mut o = opts("cancel");
3396        o.cancel = Some(token);
3397        let summary = run_expanded(nodes, o).await.unwrap();
3398        // The single root invocation completes (Ok) — it is not reported as a
3399        // failure even though it was cancelled.
3400        assert_eq!(summary.invocations.len(), 1);
3401        assert!(
3402            !summary.had_failures(),
3403            "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
3404        );
3405    }
3406
3407    #[tokio::test]
3408    async fn fanout_projects_away_unreferenced_parent_fields() {
3409        // Parent CSV has id + a big unreferenced "payload" column. The child only
3410        // references ${parents.id} (in its output path), so projection keeps "id"
3411        // and the parent_key but drops "payload" — and fan-out still works.
3412        let dir = tempfile::tempdir().unwrap();
3413        let parent_csv = dir.path().join("parents.csv");
3414        let child_csv = dir.path().join("child.csv");
3415        std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
3416        std::fs::write(&child_csv, "x\nA\n").unwrap();
3417        let parent_out = dir.path().join("parents.jsonl");
3418        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3419
3420        let yaml = format!(
3421            r#"version: 1
3422pipeline:
3423  source: {{ type: csv, config: {{ path: {parent} }} }}
3424  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
3425matrix:
3426  - id: parents
3427  - id: child
3428    parent: parents
3429    source: {{ config: {{ path: {child} }} }}
3430    sink:   {{ config: {{ path: "{child_out}" }} }}
3431"#,
3432            parent = parent_csv.display(),
3433            parent_out = parent_out.display(),
3434            child = child_csv.display(),
3435            child_out = child_out_pattern.display(),
3436        );
3437        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3438        let nodes = expand(&cfg).unwrap();
3439        let summary = run_expanded(
3440            nodes,
3441            ExecuteOptions {
3442                pipeline_name: "projtest".into(),
3443                execution: None,
3444                dry_run: false,
3445                limit: None,
3446                state_path_override: None,
3447                shard: None,
3448                auth: Default::default(),
3449                clock: chrono::Utc::now().fixed_offset(),
3450                cancel: None,
3451                resilience: None,
3452                sla: None,
3453                #[cfg(feature = "lineage")]
3454                lineage: None,
3455                #[cfg(feature = "lineage")]
3456                lineage_cfg: None,
3457                #[cfg(feature = "notify")]
3458                notifier: None,
3459                #[cfg(feature = "catalog")]
3460                catalog: None,
3461            },
3462        )
3463        .await
3464        .unwrap();
3465
3466        // 1 parent + 2 child invocations (one per parent record) — cardinality kept.
3467        assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3468        assert!(!summary.had_failures(), "{summary:?}");
3469        // ${parents.id} resolved correctly for each child despite "payload" being projected away.
3470        assert!(dir.path().join("child-1.jsonl").exists());
3471        assert!(dir.path().join("child-2.jsonl").exists());
3472    }
3473}