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    // `${backfill.*}` tokens are substituted by the `faucet backfill`
797    // orchestrator before nodes reach the executor. One still present here
798    // means a window-scoped config is being driven by `run`/`schedule`/
799    // `serve` — fail loudly instead of handing the literal token to the
800    // connector (#282).
801    reject_unresolved_backfill_tokens(&source_cfg, "source")?;
802    reject_unresolved_backfill_tokens(&sink_cfg, "sink")?;
803
804    if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
805        let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
806        resolve_inplace(&mut source_cfg, &ctx)?;
807        resolve_inplace(&mut sink_cfg, &ctx)?;
808    }
809
810    // 2) Build source + sink. `faucet dlq replay` injects a pre-built source
811    //    (an envelope-unwrapping DLQ reader) via `source_override`; every
812    //    config-driven node builds from the connector registry. The override
813    //    is taken once — replay runs a single invocation.
814    let source = match node.source_override.as_ref().and_then(|o| o.take()) {
815        Some(prebuilt) => prebuilt,
816        None => {
817            build_source(
818                &node.source.kind,
819                source_cfg,
820                &opts.auth,
821                opts.resilience.as_ref().map(|r| &r.retry),
822            )
823            .await?
824        }
825    };
826
827    // Catalog identity (#279): read the dataset URIs off the *raw* connectors,
828    // before any wrapper is layered on.
829    #[cfg(feature = "catalog")]
830    let source_dataset_uri = source.dataset_uri();
831
832    // Clustered Mode B: narrow the raw source to its assigned shard BEFORE any
833    // wrapping (the TransformingSource / StateKeyOverride wrappers do not forward
834    // apply_shard, so it must reach the concrete connector).
835    if let Some(shard) = &opts.shard {
836        source
837            .apply_shard(shard)
838            .await
839            .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
840    }
841    let raw_sink: Box<dyn Sink> = if opts.dry_run {
842        Box::new(CountingSink::new())
843    } else {
844        build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
845    };
846    #[cfg(feature = "catalog")]
847    let sink_dataset_uri = raw_sink.dataset_uri();
848    let raw_sink: Box<dyn Sink> = match opts.limit {
849        Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
850        None => raw_sink,
851    };
852    let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
853    let sink: Box<dyn Sink> = match &capture {
854        Some(projection) => Box::new(CapturingSink::wrap(
855            raw_sink,
856            Arc::clone(&captured),
857            Arc::clone(projection),
858        )),
859        None => raw_sink,
860    };
861
862    // ── Lineage: sampling wrappers (only for requested facets) ───────────────
863    // `in_sample` taps the source's pre-transform records (input schema /
864    // column lineage); `out_sample` taps the sink's written records (output
865    // schema / RUNNING-heartbeat throughput counter). Both stay `None` when no
866    // facet/event needs them, so lineage adds zero per-record overhead.
867    #[cfg(feature = "lineage")]
868    let (in_sample, out_sample) = {
869        use std::sync::Arc as StdArc;
870        let mut want = false;
871        let mut cap = 0usize;
872        if let (Some(_), Some(lc)) = (&lineage, &lineage_cfg) {
873            let want_schema = lc.include_schema_facet || lc.include_column_lineage;
874            if want_schema {
875                cap = cap.max(lc.sample_records);
876            }
877            want = want_schema || lc.emit_on.running;
878        }
879        // The catalog (#279) needs input/output samples for schema inference
880        // and record counts regardless of any `lineage:` block; take the max
881        // of both caps when both are active.
882        #[cfg(feature = "catalog")]
883        if catalog_active {
884            want = true;
885            cap = cap.max(
886                opts.catalog
887                    .as_ref()
888                    .map(|h| h.sample_records)
889                    .unwrap_or(crate::catalog::DEFAULT_SAMPLE_RECORDS),
890            );
891        }
892        if want {
893            (
894                Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
895                Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
896            )
897        } else {
898            (None, None)
899        }
900    };
901
902    // Wrap the raw source so it samples PRE-transform input records — this must
903    // sit between `build_source` and `TransformingSource`.
904    #[cfg(feature = "lineage")]
905    let source: Box<dyn Source> = match &in_sample {
906        Some(state) => Box::new(faucet_lineage::SamplingSource::new(
907            source,
908            std::sync::Arc::clone(state),
909        )),
910        None => source,
911    };
912
913    // 3) Compile transforms. Resolve `${now.*}` run-clock tokens in each
914    //    transform's config first — exactly as for source/sink above — so e.g. a
915    //    `set` transform stamping `${now.date}` writes the real date instead of
916    //    the literal token string. Without this the token leaks into every record.
917    let stages = if node.transforms.is_empty() {
918        compile_transforms(&node.transforms)?
919    } else {
920        let mut transforms = node.transforms.clone();
921        for t in &mut transforms {
922            resolve_now_inplace(&mut t.config, opts.clock)?;
923        }
924        compile_transforms(&transforms)?
925    };
926    let source: Box<dyn Source> = if stages.is_empty() {
927        source
928    } else {
929        Box::new(faucet_core::TransformingSource::new(
930            source,
931            stages,
932            obs_labels.clone(),
933        )?)
934    };
935
936    // 4) Build state store. If the source opts into state, wrap it so the
937    //    executor's per-row state key is used instead of the source's natural
938    //    one (which is shared across all matrix rows of the same kind).
939    let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
940    // Preview modes must not persist bookmarks: the counting/truncating sinks
941    // return `Ok` without a real write, so a persisted (advanced) bookmark would
942    // make the next real run skip unwritten records (#321 H1). Wrap the store so
943    // reads still resume faithfully but writes are dropped.
944    let state: Option<Arc<dyn StateStore>> = match state {
945        Some(inner) if opts.dry_run || opts.limit.is_some() => {
946            Some(Arc::new(ReadOnlyStateStore { inner }))
947        }
948        other => other,
949    };
950    // Keep a handle for the post-run SLA pass (#202) — `state` itself is moved
951    // into the pipeline below.
952    let sla_store = state.clone();
953    // Per-shard bookmark: suffix the state key with the shard id so a reassigned
954    // shard resumes where its dead owner left off, independent of sibling shards.
955    let effective_state_key = match &opts.shard {
956        Some(shard) => format!("{state_key}::{}", shard.id),
957        None => state_key.to_owned(),
958    };
959    let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
960        Box::new(StateKeyOverride {
961            inner: source,
962            key: effective_state_key,
963        })
964    } else {
965        source
966    };
967
968    // Wrap the sink so it samples written records — outermost, immediately
969    // before the pipeline is constructed (after capture/limit wrappers).
970    #[cfg(feature = "lineage")]
971    let sink: Box<dyn Sink> = match &out_sample {
972        Some(state) => Box::new(faucet_lineage::SamplingSink::new(
973            sink,
974            std::sync::Arc::clone(state),
975        )),
976        None => sink,
977    };
978
979    // 5) Run.
980    // When lineage is enabled the START/terminal lifecycle below still needs
981    // `pipeline_name` / `row_id` / `run_id`, so hand the builder clones; the
982    // non-lineage build moves them straight in (byte-identical to before).
983    #[cfg(feature = "lineage")]
984    let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
985        .with_name(pipeline_name.clone())
986        .with_row(row_id.clone())
987        .with_run_id(run_id.clone());
988    #[cfg(not(feature = "lineage"))]
989    let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
990        .with_name(pipeline_name)
991        .with_row(row_id)
992        .with_run_id(run_id);
993    let pipeline = match state {
994        Some(store) => pipeline.with_state_store(store),
995        None => pipeline,
996    };
997    let pipeline = if let Some(ref dlq_spec) = node.dlq {
998        let dlq_cfg = build_dlq_config(dlq_spec).await?;
999        pipeline.with_dlq(dlq_cfg)
1000    } else {
1001        pipeline
1002    };
1003    // Cooperative cancellation: a cancelled token makes the streaming loop stop
1004    // at the next page boundary and flush the sink (#146 H16). The pipeline
1005    // takes a clone so the lineage terminal-event classification and the SLA
1006    // pass below can still read `cancel.is_cancelled()` (cheap — the token is
1007    // an `Arc`).
1008    let pipeline = pipeline.with_cancel(cancel.clone());
1009    // Pipeline-level quality checks (v1: no matrix-row override). `expand`
1010    // already validated this spec, but compile again here to obtain the
1011    // runtime `CompiledQuality`; map any error to a config-level failure.
1012    #[cfg(feature = "quality")]
1013    let pipeline = if let Some(ref quality_spec) = node.quality {
1014        let compiled = Arc::new(
1015            faucet_core::CompiledQuality::compile(quality_spec)
1016                .map_err(|e| CliError::Config(format!("quality: {e}")))?,
1017        );
1018        pipeline.with_quality(compiled)
1019    } else {
1020        pipeline
1021    };
1022    // Pipeline-level data contract (v1: no matrix-row override). `expand`
1023    // already validated the spec; compile again here to obtain the runtime
1024    // `CompiledContract`.
1025    #[cfg(feature = "contract")]
1026    let pipeline = if let Some(ref contract_spec) = node.contract {
1027        let compiled = Arc::new(
1028            faucet_core::CompiledContract::compile(contract_spec)
1029                .map_err(|e| CliError::Config(format!("contract: {e}")))?,
1030        );
1031        pipeline.with_contract(compiled)
1032    } else {
1033        pipeline
1034    };
1035    // Pipeline-level PII masking (v1: no matrix-row override). Compile scoped
1036    // to this node's destination sink — by template name (`sink_ref`) and by
1037    // connector kind — so `applies_to` per-destination rules resolve. When no
1038    // rule applies to this sink the compiled policy is empty and the pass is
1039    // skipped entirely.
1040    #[cfg(feature = "masking")]
1041    let pipeline = if let Some(ref masking_spec) = node.masking {
1042        let sink_ids = [node.sink_ref.as_str(), node.sink.kind.as_str()];
1043        let compiled = faucet_core::CompiledMasking::compile_for_sink(masking_spec, &sink_ids)
1044            .map_err(|e| CliError::Config(format!("masking: {e}")))?;
1045        if compiled.is_empty() {
1046            pipeline
1047        } else {
1048            pipeline.with_masking(Arc::new(compiled))
1049        }
1050    } else {
1051        pipeline
1052    };
1053    // Schema-drift policy (pipeline-level in v1; same for every invocation).
1054    let pipeline = if let Some(ref sd) = node.schema {
1055        pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd))
1056    } else {
1057        pipeline
1058    };
1059    // Execution-level adaptive batch-size controller (shared by all rows).
1060    let pipeline = if let Some(ab) = opts
1061        .execution
1062        .as_ref()
1063        .and_then(|e| e.adaptive_batch_size.clone())
1064    {
1065        ab.validate()
1066            .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
1067        pipeline.with_adaptive(ab)
1068    } else {
1069        pipeline
1070    };
1071    // Resilience policy (retry/backoff/circuit-breaker/poison). Top-level in v1,
1072    // so the same policy is attached to every invocation.
1073    let pipeline = if let Some(policy) = opts.resilience.clone() {
1074        pipeline.with_resilience(policy)
1075    } else {
1076        pipeline
1077    };
1078    // Delivery guarantee (exactly-once resume/skip when the node opted in; the
1079    // expand gate already verified source/sink/state support). Preview modes
1080    // (`--dry-run`, `--limit`) swap in counting/truncating sinks that cannot
1081    // uphold an atomic watermark — and a token committed for a truncated page
1082    // would corrupt it — so they always run at-least-once.
1083    let effective_delivery = if opts.dry_run || opts.limit.is_some() {
1084        faucet_core::idempotency::DeliveryMode::AtLeastOnce
1085    } else {
1086        node.delivery
1087    };
1088    let pipeline = pipeline.with_delivery(effective_delivery);
1089    // ── Lineage: START + heartbeat + terminal ────────────────────────────────
1090    #[cfg(feature = "lineage")]
1091    let lineage_ctx = match (&lineage, &lineage_cfg) {
1092        (Some(em), Some(lc)) => {
1093            let job_name =
1094                crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
1095            let mut ctx = faucet_lineage::RunLifecycle {
1096                job_namespace: lc.namespace.clone(),
1097                job_name,
1098                run_id: run_id.clone(),
1099                parent: lc.parent_job.clone(),
1100                input: faucet_lineage::DatasetRef {
1101                    namespace: lc.namespace.clone(),
1102                    name: source.dataset_uri(),
1103                },
1104                output: faucet_lineage::DatasetRef {
1105                    namespace: lc.namespace.clone(),
1106                    name: sink.dataset_uri(),
1107                },
1108                started_at: chrono::Utc::now(),
1109                finished_at: None,
1110                records: 0,
1111                error: None,
1112                input_schema: None,
1113                output_schema: None,
1114                column_lineage: None,
1115                source_code: None,
1116            };
1117            em.emit(faucet_lineage::EventType::Start, &ctx).await;
1118            // Heartbeat task — periodic RUNNING events with the live throughput
1119            // count read off the output sampler.
1120            let hb_handle = if lc.emit_on.running {
1121                let em2 = std::sync::Arc::clone(em);
1122                let interval = lc.heartbeat_interval;
1123                let mut beat_ctx = ctx.clone();
1124                let counter = out_sample.clone();
1125                Some(tokio::spawn(async move {
1126                    let mut tick = tokio::time::interval(interval);
1127                    tick.tick().await; // skip the immediate first tick
1128                    loop {
1129                        tick.tick().await;
1130                        if let Some(c) = &counter {
1131                            beat_ctx.records = c.count();
1132                        }
1133                        em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
1134                            .await;
1135                    }
1136                }))
1137            } else {
1138                None
1139            };
1140            ctx.source_code = if lc.include_source_code_facet {
1141                Some(serde_json::to_string(&node.source.config).unwrap_or_default())
1142            } else {
1143                None
1144            };
1145            Some((std::sync::Arc::clone(em), ctx, hb_handle))
1146        }
1147        _ => None,
1148    };
1149
1150    // Combine run + final flush into one outcome BEFORE emitting the terminal
1151    // lineage event, preserving the original semantics (run error → skip flush;
1152    // run ok but flush error → overall error). The terminal event is classified
1153    // from this combined `result`, then `?`-propagated below — restoring the
1154    // original early-return behaviour while still firing the terminal event on
1155    // both success and error.
1156    let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1157        Ok(r) => sink.flush().await.map(|_| r),
1158        Err(e) => Err(e),
1159    };
1160
1161    #[cfg(feature = "lineage")]
1162    if let Some((em, mut ctx, hb)) = lineage_ctx {
1163        if let Some(h) = hb {
1164            h.abort();
1165        }
1166        ctx.finished_at = Some(chrono::Utc::now());
1167        if let Some(state) = &out_sample {
1168            ctx.records = state.count();
1169            if lineage_cfg
1170                .as_ref()
1171                .map(|l| l.include_schema_facet)
1172                .unwrap_or(false)
1173            {
1174                ctx.output_schema = Some(state.inferred_schema());
1175            }
1176        }
1177        if let Some(state) = &in_sample
1178            && lineage_cfg
1179                .as_ref()
1180                .map(|l| l.include_schema_facet || l.include_column_lineage)
1181                .unwrap_or(false)
1182        {
1183            let in_schema = state.inferred_schema();
1184            if lineage_cfg
1185                .as_ref()
1186                .map(|l| l.include_column_lineage)
1187                .unwrap_or(false)
1188            {
1189                let input_fields: Vec<String> =
1190                    in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1191                #[cfg(feature = "masking")]
1192                let has_masking = node.masking.is_some();
1193                #[cfg(not(feature = "masking"))]
1194                let has_masking = false;
1195                let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1196                ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1197            }
1198            if lineage_cfg
1199                .as_ref()
1200                .map(|l| l.include_schema_facet)
1201                .unwrap_or(false)
1202            {
1203                ctx.input_schema = Some(in_schema);
1204            }
1205        }
1206        let ev = match &result {
1207            Err(e) => {
1208                ctx.error = Some(e.to_string());
1209                faucet_lineage::EventType::Fail
1210            }
1211            Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1212            Ok(_) => faucet_lineage::EventType::Complete,
1213        };
1214        em.emit(ev, &ctx).await;
1215    }
1216
1217    // ── SLA post-run evaluation (#202) ───────────────────────────────────────
1218    // Roots only: children fan out per parent record, so their volumes are not
1219    // a stable series to baseline (same scoping as `faucet doctor`). Skipped
1220    // for dry-run / --limit (synthetic volumes would poison the baseline), for
1221    // shard executions (a shard's volume is a fraction of the row's, and shard
1222    // counts change run to run), and for cancelled runs (a partial volume is
1223    // not a signal). Monitoring never fails the run — see `evaluate_post_run`.
1224    // Roots only, and never for dry-run / --limit / shard / cancelled runs —
1225    // the same scoping the notification pass below reuses.
1226    let is_notifiable_root = matches!(node.role, NodeRole::Root)
1227        && !opts.dry_run
1228        && opts.limit.is_none()
1229        && opts.shard.is_none()
1230        && !cancel.is_cancelled();
1231
1232    #[cfg_attr(not(feature = "notify"), allow(unused_variables))]
1233    let sla_violations = if let Some(spec) = &opts.sla
1234        && is_notifiable_root
1235    {
1236        let outcome = match &result {
1237            Ok(r) => crate::sla::RunOutcome::Success {
1238                rows: r.records_written as u64,
1239            },
1240            Err(_) => crate::sla::RunOutcome::Failure,
1241        };
1242        crate::sla::evaluate_post_run(
1243            spec,
1244            sla_store.as_ref(),
1245            state_key,
1246            &obs_labels.pipeline,
1247            &obs_labels.row,
1248            outcome,
1249            chrono::Utc::now().timestamp(),
1250        )
1251        .await
1252    } else {
1253        Vec::new()
1254    };
1255
1256    // ── Notifications (#280) ─────────────────────────────────────────────────
1257    // Fan run success/failure, SLA breach, circuit-open, contract-abort, and
1258    // DLQ-threshold out to the configured channels. Same root/real-run scoping
1259    // as SLA; delivery is fire-and-forget and never fails the run.
1260    #[cfg(feature = "notify")]
1261    if let Some(notifier) = &opts.notifier
1262        && is_notifiable_root
1263    {
1264        use crate::notify::NotifyEvent;
1265        let pipeline = obs_labels.pipeline.to_string();
1266        let row = obs_labels.row.to_string();
1267        match &result {
1268            Ok(r) => {
1269                notifier
1270                    .emit(NotifyEvent::run_success(
1271                        pipeline.clone(),
1272                        row.clone(),
1273                        r.records_written as u64,
1274                    ))
1275                    .await;
1276                if let Some(dlq) = &r.dlq
1277                    && dlq.records_dlq > 0
1278                {
1279                    notifier
1280                        .emit(NotifyEvent::dlq_threshold(
1281                            pipeline.clone(),
1282                            row.clone(),
1283                            dlq.records_dlq as u64,
1284                        ))
1285                        .await;
1286                }
1287            }
1288            Err(e) => {
1289                notifier.emit(error_event(&pipeline, &row, e)).await;
1290            }
1291        }
1292        for v in &sla_violations {
1293            notifier
1294                .emit(NotifyEvent::sla_breach(
1295                    pipeline.clone(),
1296                    row.clone(),
1297                    v.kind(),
1298                    v.to_string(),
1299                ))
1300                .await;
1301        }
1302    }
1303
1304    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
1305    // Fold this run's dataset observations + lineage edge into the catalog.
1306    // Successful, complete root runs only (a cancelled run's partial volume is
1307    // not a signal). Recording never fails the run — see `catalog::record`.
1308    #[cfg(feature = "catalog")]
1309    if let Some(handle) = &opts.catalog
1310        && catalog_active
1311        && !cancel.is_cancelled()
1312        && let Ok(pipeline_result) = &result
1313    {
1314        use crate::catalog::model::{canonicalize_uri, schema_from_samples};
1315        use crate::serve::history::catalog::{CatalogUpdate, DatasetObservation, DatasetRole};
1316
1317        let records_written = pipeline_result.records_written as u64;
1318        let source_schema = in_sample
1319            .as_ref()
1320            .and_then(|s| schema_from_samples(&s.samples()));
1321        let sink_schema = out_sample
1322            .as_ref()
1323            .and_then(|s| schema_from_samples(&s.samples()));
1324        // The samplers are always installed while the catalog is active, so the
1325        // unwrap_or arms are defensive only.
1326        let records_read = in_sample
1327            .as_ref()
1328            .map(|s| s.count())
1329            .unwrap_or(records_written);
1330        let records_out = out_sample
1331            .as_ref()
1332            .map(|s| s.count())
1333            .unwrap_or(records_written);
1334
1335        // Column lineage for the edge — the same derivation `faucet-lineage`
1336        // emits, so the catalog's edges match the OpenLineage output.
1337        let column_lineage = in_sample.as_ref().and_then(|s| {
1338            let input_fields: Vec<String> = s
1339                .inferred_schema()
1340                .fields
1341                .iter()
1342                .map(|(n, _)| n.clone())
1343                .collect();
1344            #[cfg(feature = "masking")]
1345            let has_masking = node.masking.is_some();
1346            #[cfg(not(feature = "masking"))]
1347            let has_masking = false;
1348            let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1349            faucet_lineage::derive_column_lineage(&input_fields, &ops).map(|cl| {
1350                // `ColumnLineage` is not `Serialize` (IndexMap); render the
1351                // stable `{"fields": {out: [in, …]}}` shape by hand.
1352                let fields: serde_json::Map<String, Value> = cl
1353                    .edges
1354                    .iter()
1355                    .map(|(out, ins)| {
1356                        (
1357                            out.clone(),
1358                            Value::Array(ins.iter().map(|s| Value::String(s.clone())).collect()),
1359                        )
1360                    })
1361                    .collect();
1362                serde_json::json!({ "fields": fields })
1363            })
1364        });
1365
1366        let update = CatalogUpdate {
1367            run_id: handle.run_id.clone().unwrap_or_else(|| run_id.clone()),
1368            pipeline: obs_labels.pipeline.to_string(),
1369            row: obs_labels.row.to_string(),
1370            recorded_at: chrono::Utc::now(),
1371            source: DatasetObservation {
1372                uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
1373                kind: node.source.kind.clone(),
1374                role: DatasetRole::Source,
1375                schema: source_schema,
1376                records: records_read,
1377            },
1378            sink: DatasetObservation {
1379                uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
1380                kind: node.sink.kind.clone(),
1381                role: DatasetRole::Sink,
1382                schema: sink_schema,
1383                records: records_out,
1384            },
1385            column_lineage,
1386        };
1387        crate::catalog::record(handle, &update).await;
1388    }
1389
1390    let result = result?;
1391
1392    let captured = if capture.is_some() {
1393        std::mem::take(&mut *captured.lock().await)
1394    } else {
1395        Vec::new()
1396    };
1397    Ok((captured, result.records_written))
1398}
1399
1400async fn build_state_for_node(
1401    node: &ExpandedNode,
1402    state_path_override: Option<&Path>,
1403) -> CliResult<Option<Arc<dyn StateStore>>> {
1404    match (&node.state, state_path_override) {
1405        (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1406        (None, Some(path)) => Ok(Some(state_from_override(path))),
1407        (Some(spec), Some(path)) => {
1408            if spec.kind == "file" {
1409                Ok(Some(state_from_override(path)))
1410            } else {
1411                tracing::warn!(
1412                    state = %spec.kind,
1413                    "--state-path is only meaningful for the 'file' backend; ignoring override"
1414                );
1415                Ok(Some(build_state_store(spec).await?))
1416            }
1417        }
1418        (None, None) => Ok(None),
1419    }
1420}
1421
1422fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1423    Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1424}
1425
1426/// Translate a [`crate::config::DlqSpec`] from the YAML/JSON config into a
1427/// runtime [`DlqConfig`] ready to attach to a [`Pipeline`].
1428pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1429    // DLQ sinks resolve against an empty catalog — shared `auth: { ref }` on a
1430    // DLQ sink is out of scope (DLQ targets are typically local jsonl/stdout).
1431    let sink = build_sink(
1432        &spec.sink.kind,
1433        spec.sink.config.clone(),
1434        &AuthCatalog::new(),
1435    )
1436    .await?;
1437    Ok(DlqConfig {
1438        sink: Arc::from(sink),
1439        on_batch_error: match spec.on_batch_error {
1440            crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1441            crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1442        },
1443        max_failures_per_page: spec.max_failures_per_page,
1444        max_failures_total: spec.max_failures_total,
1445        include_original_payload: spec.include_original_payload,
1446    })
1447}
1448
1449/// Classify a pipeline error into a notification event (#280). A circuit-breaker
1450/// trip and a contract-abort breach get their own event kinds; everything else
1451/// is a generic `run_failure` carrying a short error-kind label.
1452#[cfg(feature = "notify")]
1453fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
1454    use crate::notify::NotifyEvent;
1455    match err {
1456        FaucetError::CircuitOpen { failures, cooldown } => {
1457            NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
1458        }
1459        FaucetError::ContractViolation { message, .. } => {
1460            NotifyEvent::contract_abort(pipeline, row, message.clone())
1461        }
1462        other => {
1463            NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
1464        }
1465    }
1466}
1467
1468/// Short, stable label for a `FaucetError` variant used as the `error_kind`
1469/// detail on a `run_failure` notification. (`faucet-core`'s own `error_kind`
1470/// helper is `pub(crate)`, so we keep a small CLI-side mapping.)
1471#[cfg(feature = "notify")]
1472fn faucet_error_kind(err: &FaucetError) -> &'static str {
1473    match err {
1474        FaucetError::Config(_) => "config",
1475        FaucetError::Source(_) => "source",
1476        FaucetError::Sink(_) => "sink",
1477        FaucetError::State(_) => "state",
1478        FaucetError::QualityFailure { .. } => "quality",
1479        FaucetError::SchemaDrift { .. } => "schema_drift",
1480        _ => "error",
1481    }
1482}
1483
1484/// In-place `${now.*}` resolution against the run clock. Walks every string
1485/// leaf and rewrites `${now.<token>}`; all other `${...}` tokens are untouched.
1486/// Shared with `faucet test`, which applies the same pre-pass to transform
1487/// configs under the case clock.
1488/// Error on a leftover `${backfill.*}` token: those resolve only inside
1489/// `faucet backfill`, which substitutes them per window unit before the
1490/// executor runs. Reaching here means another runtime picked up a
1491/// window-scoped config.
1492fn reject_unresolved_backfill_tokens(value: &Value, owner: &str) -> CliResult<()> {
1493    fn walk(value: &Value, owner: &str) -> CliResult<()> {
1494        match value {
1495            Value::String(s) if s.contains("${backfill.") => Err(CliError::Config(format!(
1496                "the {owner} config references a `${{backfill.*}}` token, which only                  `faucet backfill` resolves — run this config via `faucet backfill                  --from … --to …`, or remove the token"
1497            ))),
1498            Value::Array(a) => a.iter().try_for_each(|v| walk(v, owner)),
1499            Value::Object(m) => m.values().try_for_each(|v| walk(v, owner)),
1500            _ => Ok(()),
1501        }
1502    }
1503    walk(value, owner)
1504}
1505
1506pub(crate) fn resolve_now_inplace(
1507    value: &mut Value,
1508    clock: DateTime<FixedOffset>,
1509) -> CliResult<()> {
1510    match value {
1511        Value::String(s) => {
1512            *s = crate::interpolate::resolve_now(s, clock)?;
1513            Ok(())
1514        }
1515        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1516        Value::Object(m) => m
1517            .values_mut()
1518            .try_for_each(|v| resolve_now_inplace(v, clock)),
1519        _ => Ok(()),
1520    }
1521}
1522
1523/// In-place runtime interpolation against a parent-record context. Walks every
1524/// string leaf in `value` and replaces `${id.path}` tokens with stringified
1525/// values from `ctx`.
1526fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1527    match value {
1528        Value::String(s) => {
1529            let resolved = interpolate_record(s, ctx)?;
1530            *s = resolved;
1531            Ok(())
1532        }
1533        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1534        Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1535        _ => Ok(()),
1536    }
1537}
1538
1539// ── Adapter sinks/sources ───────────────────────────────────────────────────
1540
1541/// Wraps a [`StateStore`] so reads pass through but writes/deletes are dropped.
1542///
1543/// Attached under `--dry-run` / `--limit`: those modes swap in counting /
1544/// truncating sinks that return `Ok` without a real durable write, and
1545/// `run_stream` persists a page's bookmark after any `Ok`. Persisting an
1546/// advanced bookmark from a preview would make the next *real* run resume past
1547/// records that were never written — a `--dry-run` silently causing data loss
1548/// (audit #321 H1; for postgres-cdc it also lets Postgres recycle WAL for
1549/// undelivered changes). Reads still pass through so the preview faithfully
1550/// resumes from the existing bookmark.
1551struct ReadOnlyStateStore {
1552    inner: Arc<dyn StateStore>,
1553}
1554
1555#[async_trait]
1556impl StateStore for ReadOnlyStateStore {
1557    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1558        self.inner.get(key).await
1559    }
1560    async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1561        Ok(())
1562    }
1563    async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1564        Ok(())
1565    }
1566}
1567
1568/// Wraps a source so its `state_key()` returns the executor-provided value
1569/// instead of the source's natural one. Lets every matrix invocation use a
1570/// distinct state-store entry even when the underlying source kind is shared.
1571struct StateKeyOverride {
1572    inner: Box<dyn Source>,
1573    key: String,
1574}
1575
1576#[async_trait]
1577impl Source for StateKeyOverride {
1578    async fn fetch_with_context(
1579        &self,
1580        ctx: &HashMap<String, Value>,
1581    ) -> Result<Vec<Value>, FaucetError> {
1582        self.inner.fetch_with_context(ctx).await
1583    }
1584    async fn fetch_with_context_incremental(
1585        &self,
1586        ctx: &HashMap<String, Value>,
1587    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1588        self.inner.fetch_with_context_incremental(ctx).await
1589    }
1590    // Forward `stream_pages` so the wrapped connector's *native* page stream
1591    // survives the wrap. Without this the trait's buffering default kicks in
1592    // for every stateful run — losing per-page bookmarks (CDC per-transaction
1593    // durability, exactly-once per-page tokens) and the O(batch_size) memory
1594    // bound.
1595    fn stream_pages<'a>(
1596        &'a self,
1597        ctx: &'a HashMap<String, Value>,
1598        batch_size: usize,
1599    ) -> std::pin::Pin<
1600        Box<
1601            dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
1602                + Send
1603                + 'a,
1604        >,
1605    > {
1606        self.inner.stream_pages(ctx, batch_size)
1607    }
1608    fn connector_name(&self) -> &'static str {
1609        self.inner.connector_name()
1610    }
1611    fn dataset_uri(&self) -> String {
1612        self.inner.dataset_uri()
1613    }
1614    fn state_key(&self) -> Option<String> {
1615        Some(self.key.clone())
1616    }
1617    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1618        self.inner.apply_start_bookmark(bookmark).await
1619    }
1620    fn supports_exactly_once(&self) -> bool {
1621        self.inner.supports_exactly_once()
1622    }
1623    fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
1624        self.inner.replay_guarantee()
1625    }
1626    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1627        self.inner.capture_resume_position().await
1628    }
1629}
1630
1631/// Forwards each record to an inner sink while also capturing a **projected**
1632/// copy into a shared buffer for descendant rows to consume. Projecting to only
1633/// the fields children reference bounds orchestrator memory (#160).
1634struct CapturingSink {
1635    inner: Box<dyn Sink>,
1636    captured: Arc<Mutex<Vec<Value>>>,
1637    projection: Arc<Projection>,
1638}
1639
1640impl CapturingSink {
1641    fn wrap(
1642        inner: Box<dyn Sink>,
1643        captured: Arc<Mutex<Vec<Value>>>,
1644        projection: Arc<Projection>,
1645    ) -> Self {
1646        Self {
1647            inner,
1648            captured,
1649            projection,
1650        }
1651    }
1652}
1653
1654#[async_trait]
1655impl Sink for CapturingSink {
1656    fn connector_name(&self) -> &'static str {
1657        self.inner.connector_name()
1658    }
1659    fn dataset_uri(&self) -> String {
1660        self.inner.dataset_uri()
1661    }
1662    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1663        let written = self.inner.write_batch(records).await?;
1664        // Capture only what actually landed (LimitedSink may have dropped some),
1665        // projected to the fields children reference (#160).
1666        let n = written.min(records.len());
1667        let mut buf = self.captured.lock().await;
1668        buf.extend(
1669            records
1670                .iter()
1671                .take(n)
1672                .map(|r| project_record(r, &self.projection)),
1673        );
1674        Ok(written)
1675    }
1676    async fn flush(&self) -> Result<(), FaucetError> {
1677        self.inner.flush().await
1678    }
1679    // Capability + exactly-once passthroughs, so a parent row that fans out to
1680    // children (which is what this wrapper serves) keeps the inner sink's
1681    // delivery semantics instead of being masked down to the trait defaults.
1682    fn supports_idempotent_writes(&self) -> bool {
1683        self.inner.supports_idempotent_writes()
1684    }
1685    fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
1686        self.inner.sink_guarantee()
1687    }
1688    fn dedups_by_key(&self) -> bool {
1689        self.inner.dedups_by_key()
1690    }
1691    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
1692        self.inner.supported_write_modes()
1693    }
1694    async fn write_batch_idempotent(
1695        &self,
1696        records: &[Value],
1697        scope: &str,
1698        token: &str,
1699    ) -> Result<usize, FaucetError> {
1700        let written = self
1701            .inner
1702            .write_batch_idempotent(records, scope, token)
1703            .await?;
1704        let n = written.min(records.len());
1705        let mut buf = self.captured.lock().await;
1706        buf.extend(
1707            records
1708                .iter()
1709                .take(n)
1710                .map(|r| project_record(r, &self.projection)),
1711        );
1712        Ok(written)
1713    }
1714    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1715        self.inner.last_committed_token(scope).await
1716    }
1717    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1718        self.inner.current_schema().await
1719    }
1720    fn supports_schema_evolution(&self) -> bool {
1721        self.inner.supports_schema_evolution()
1722    }
1723    async fn evolve_schema(
1724        &self,
1725        evolution: &faucet_core::SchemaEvolution,
1726    ) -> Result<(), FaucetError> {
1727        self.inner.evolve_schema(evolution).await
1728    }
1729}
1730
1731/// Cap on records written. Each `write_batch` call truncates `records` to the
1732/// remaining budget before delegating.
1733struct LimitedSink {
1734    inner: Box<dyn Sink>,
1735    remaining: AtomicUsize,
1736}
1737
1738impl LimitedSink {
1739    fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1740        Self {
1741            inner,
1742            remaining: AtomicUsize::new(cap),
1743        }
1744    }
1745}
1746
1747#[async_trait]
1748impl Sink for LimitedSink {
1749    fn connector_name(&self) -> &'static str {
1750        self.inner.connector_name()
1751    }
1752    fn dataset_uri(&self) -> String {
1753        self.inner.dataset_uri()
1754    }
1755    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1756        let remaining = self.remaining.load(Ordering::Relaxed);
1757        if remaining == 0 {
1758            return Ok(0);
1759        }
1760        let take = remaining.min(records.len());
1761        let slice = &records[..take];
1762        let written = self.inner.write_batch(slice).await?;
1763        self.remaining
1764            .fetch_sub(written.min(remaining), Ordering::Relaxed);
1765        Ok(written)
1766    }
1767    async fn flush(&self) -> Result<(), FaucetError> {
1768        self.inner.flush().await
1769    }
1770}
1771
1772/// No-op sink used in `--dry-run`. Counts records seen so the rest of the
1773/// pipeline (transforms, source) still runs.
1774struct CountingSink {
1775    seen: AtomicUsize,
1776}
1777
1778impl CountingSink {
1779    fn new() -> Self {
1780        Self {
1781            seen: AtomicUsize::new(0),
1782        }
1783    }
1784}
1785
1786#[async_trait]
1787impl Sink for CountingSink {
1788    fn connector_name(&self) -> &'static str {
1789        "dry-run"
1790    }
1791    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1792        self.seen.fetch_add(records.len(), Ordering::Relaxed);
1793        Ok(records.len())
1794    }
1795}
1796
1797/// Render a JSON value compactly for use as a state-key suffix or log line.
1798/// Strings pass through unquoted; numbers/bools/null/composites use to_string.
1799fn value_to_string_brief(v: &Value) -> String {
1800    match v {
1801        Value::String(s) => s.clone(),
1802        other => other.to_string(),
1803    }
1804}
1805
1806#[cfg(test)]
1807mod tests {
1808    use super::*;
1809    use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1810    use crate::expand::expand;
1811    use serde_json::json;
1812
1813    fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
1814        PipelineConfig {
1815            version: 1,
1816            name: Some("test".into()),
1817            vars: None,
1818            auth: None,
1819            pipeline: PipelineSpec {
1820                source: Some(ConnectorSpec {
1821                    kind: "csv".into(),
1822                    config: json!({"path": input.to_str().unwrap()}),
1823                    transforms: None,
1824                    inherit_transforms: true,
1825                }),
1826                sink: Some(ConnectorSpec {
1827                    kind: "jsonl".into(),
1828                    config: json!({"path": output.to_str().unwrap()}),
1829                    transforms: None,
1830                    inherit_transforms: true,
1831                }),
1832                sources: Default::default(),
1833                sinks: Default::default(),
1834                transforms: Vec::new(),
1835                state: None,
1836                dlq: None,
1837                #[cfg(feature = "quality")]
1838                quality: None,
1839                #[cfg(feature = "contract")]
1840                contract: None,
1841                #[cfg(feature = "masking")]
1842                masking: None,
1843                schema: None,
1844            },
1845            matrix: Vec::new(),
1846            execution: None,
1847            observability: None,
1848            delivery: faucet_core::DeliveryMode::default(),
1849            resilience: None,
1850            sla: None,
1851            shard: None,
1852            replication: None,
1853            backfill: None,
1854            #[cfg(feature = "schedule")]
1855            schedule: None,
1856            #[cfg(feature = "lineage")]
1857            lineage: None,
1858            #[cfg(feature = "catalog")]
1859            catalog: None,
1860            #[cfg(feature = "notify")]
1861            notifications: Vec::new(),
1862        }
1863    }
1864
1865    #[tokio::test]
1866    async fn empty_matrix_runs_pipeline_once() {
1867        let dir = tempfile::tempdir().unwrap();
1868        let input = dir.path().join("in.csv");
1869        let output = dir.path().join("out.jsonl");
1870        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
1871        let cfg = cfg_csv_to_jsonl(&input, &output);
1872        let nodes = expand(&cfg).unwrap();
1873        let summary = run_expanded(
1874            nodes,
1875            ExecuteOptions {
1876                pipeline_name: "t".into(),
1877                execution: None,
1878                dry_run: false,
1879                limit: None,
1880                state_path_override: None,
1881                shard: None,
1882                auth: Default::default(),
1883                clock: chrono::Utc::now().fixed_offset(),
1884                cancel: None,
1885                resilience: None,
1886                sla: None,
1887                #[cfg(feature = "lineage")]
1888                lineage: None,
1889                #[cfg(feature = "lineage")]
1890                lineage_cfg: None,
1891                #[cfg(feature = "notify")]
1892                notifier: None,
1893                #[cfg(feature = "catalog")]
1894                catalog: None,
1895            },
1896        )
1897        .await
1898        .unwrap();
1899        assert_eq!(summary.invocations.len(), 1);
1900        assert_eq!(summary.invocations[0].records_written, 2);
1901        assert!(!summary.had_failures());
1902        let body = std::fs::read_to_string(&output).unwrap();
1903        assert_eq!(body.lines().count(), 2);
1904    }
1905
1906    /// Minimal options with a catalog handle attached.
1907    #[cfg(feature = "catalog")]
1908    fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
1909        let mut o = opts(name);
1910        o.catalog = Some(handle);
1911        o
1912    }
1913
1914    #[cfg(feature = "catalog")]
1915    #[tokio::test]
1916    async fn catalog_records_schema_timeline_across_two_runs() {
1917        // Acceptance (#279): running the same pipeline twice with a schema
1918        // change in between produces exactly two schema-timeline entries for
1919        // the dataset, the second carrying a computed diff.
1920        use crate::catalog::CatalogHandle;
1921        use crate::serve::history::RunHistory as _;
1922        use crate::serve::history::catalog::{self, CatalogListFilter};
1923        use crate::serve::history::memory::MemoryHistory;
1924
1925        let dir = tempfile::tempdir().unwrap();
1926        let input = dir.path().join("in.csv");
1927        let output = dir.path().join("out.jsonl");
1928        let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
1929        let handle = CatalogHandle {
1930            store: store.clone(),
1931            run_id: None,
1932            sample_records: 10,
1933        };
1934
1935        std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
1936        let cfg = cfg_csv_to_jsonl(&input, &output);
1937        let nodes = expand(&cfg).unwrap();
1938        let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
1939            .await
1940            .unwrap();
1941        assert!(!summary.had_failures());
1942
1943        // Second run: same pipeline, schema gains an `email` column.
1944        std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
1945        let nodes = expand(&cfg).unwrap();
1946        let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
1947            .await
1948            .unwrap();
1949        assert!(!summary.had_failures());
1950
1951        // Two datasets (source + sink), each with a 2-entry deduped timeline.
1952        let page = store
1953            .catalog_list_datasets(&CatalogListFilter {
1954                limit: 10,
1955                ..Default::default()
1956            })
1957            .await
1958            .unwrap();
1959        assert_eq!(page.datasets.len(), 2, "source + sink datasets");
1960        for ds in &page.datasets {
1961            let detail = store
1962                .catalog_get_dataset(&ds.id)
1963                .await
1964                .unwrap()
1965                .expect("dataset detail");
1966            assert_eq!(detail.dataset.runs, 2);
1967            assert_eq!(
1968                detail.schema_timeline.len(),
1969                2,
1970                "exactly two timeline entries for {}",
1971                ds.uri
1972            );
1973            assert!(detail.schema_timeline[0].diff.is_none());
1974            let diff = detail.schema_timeline[1]
1975                .diff
1976                .as_ref()
1977                .expect("second version carries a diff");
1978            assert!(
1979                diff["added"]
1980                    .as_array()
1981                    .unwrap()
1982                    .iter()
1983                    .any(|c| c["column"] == "email"),
1984                "diff must show the added email column: {diff}"
1985            );
1986            assert_eq!(detail.stats.len(), 2, "one volume point per run");
1987        }
1988        // One lineage edge, csv → jsonl, traversed twice.
1989        let edges = store.catalog_lineage(None, 5).await.unwrap();
1990        assert_eq!(edges.len(), 1);
1991        assert_eq!(edges[0].runs, 2);
1992        assert_eq!(edges[0].last_records, 2);
1993        assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
1994    }
1995
1996    /// A catalog store whose writes always fail — drives the never-fail-the-run
1997    /// contract.
1998    #[cfg(feature = "catalog")]
1999    struct FailingCatalogStore;
2000
2001    #[cfg(feature = "catalog")]
2002    #[async_trait]
2003    impl crate::serve::history::RunHistory for FailingCatalogStore {
2004        async fn claim_idempotency(
2005            &self,
2006            _: &str,
2007            _: &str,
2008            _: &str,
2009            _: std::time::Duration,
2010        ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
2011            Err(crate::serve::history::HistoryError::Backend("down".into()))
2012        }
2013        async fn upsert(
2014            &self,
2015            _: &crate::serve::history::RunRecord,
2016        ) -> Result<(), crate::serve::history::HistoryError> {
2017            Err(crate::serve::history::HistoryError::Backend("down".into()))
2018        }
2019        async fn get(
2020            &self,
2021            _: &str,
2022        ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
2023        {
2024            Err(crate::serve::history::HistoryError::Backend("down".into()))
2025        }
2026        async fn list(
2027            &self,
2028            _: &crate::serve::history::ListFilter,
2029        ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
2030            Err(crate::serve::history::HistoryError::Backend("down".into()))
2031        }
2032        async fn delete(
2033            &self,
2034            _: &str,
2035        ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
2036        {
2037            Err(crate::serve::history::HistoryError::Backend("down".into()))
2038        }
2039        async fn purge_expired(
2040            &self,
2041            _: std::time::Duration,
2042        ) -> Result<usize, crate::serve::history::HistoryError> {
2043            Err(crate::serve::history::HistoryError::Backend("down".into()))
2044        }
2045        async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
2046            Err(crate::serve::history::HistoryError::Backend("down".into()))
2047        }
2048        async fn catalog_record(
2049            &self,
2050            _: &crate::serve::history::catalog::CatalogUpdate,
2051        ) -> Result<(), crate::serve::history::HistoryError> {
2052            Err(crate::serve::history::HistoryError::Backend(
2053                "catalog write refused".into(),
2054            ))
2055        }
2056        fn degraded(&self) -> bool {
2057            false
2058        }
2059    }
2060
2061    #[cfg(feature = "catalog")]
2062    #[tokio::test]
2063    async fn catalog_write_failure_never_fails_the_run() {
2064        // Acceptance (#279): a forced catalog-backend error degrades (logged)
2065        // while the pipeline still succeeds and writes its output.
2066        use crate::catalog::CatalogHandle;
2067        let dir = tempfile::tempdir().unwrap();
2068        let input = dir.path().join("in.csv");
2069        let output = dir.path().join("out.jsonl");
2070        std::fs::write(&input, "name\nalice\n").unwrap();
2071        let cfg = cfg_csv_to_jsonl(&input, &output);
2072        let nodes = expand(&cfg).unwrap();
2073        let handle = CatalogHandle {
2074            store: Arc::new(FailingCatalogStore),
2075            run_id: None,
2076            sample_records: 10,
2077        };
2078        let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
2079            .await
2080            .unwrap();
2081        assert!(
2082            !summary.had_failures(),
2083            "catalog failure must not fail the run"
2084        );
2085        assert_eq!(summary.invocations[0].records_written, 1);
2086        assert_eq!(
2087            std::fs::read_to_string(&output).unwrap().lines().count(),
2088            1,
2089            "sink output written despite the catalog error"
2090        );
2091    }
2092
2093    #[tokio::test]
2094    async fn matrix_two_independent_roots_both_run() {
2095        // Two roots: one writes alice, the other writes bob — to two separate files.
2096        let dir = tempfile::tempdir().unwrap();
2097        let csv_a = dir.path().join("a.csv");
2098        let csv_b = dir.path().join("b.csv");
2099        let out_a = dir.path().join("a.jsonl");
2100        let out_b = dir.path().join("b.jsonl");
2101        std::fs::write(&csv_a, "name\nalice\n").unwrap();
2102        std::fs::write(&csv_b, "name\nbob\n").unwrap();
2103
2104        let yaml = format!(
2105            r#"version: 1
2106pipeline:
2107  source: {{ type: csv, config: {{ path: {a} }} }}
2108  sink:   {{ type: jsonl, config: {{ path: {out_a} }} }}
2109matrix:
2110  - id: rowA
2111  - id: rowB
2112    source: {{ config: {{ path: {b} }} }}
2113    sink:   {{ config: {{ path: {out_b} }} }}
2114"#,
2115            a = csv_a.display(),
2116            b = csv_b.display(),
2117            out_a = out_a.display(),
2118            out_b = out_b.display(),
2119        );
2120        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2121        let nodes = expand(&cfg).unwrap();
2122        let summary = run_expanded(
2123            nodes,
2124            ExecuteOptions {
2125                pipeline_name: "matrix".into(),
2126                execution: None,
2127                dry_run: false,
2128                limit: None,
2129                state_path_override: None,
2130                shard: None,
2131                auth: Default::default(),
2132                clock: chrono::Utc::now().fixed_offset(),
2133                cancel: None,
2134                resilience: None,
2135                sla: None,
2136                #[cfg(feature = "lineage")]
2137                lineage: None,
2138                #[cfg(feature = "lineage")]
2139                lineage_cfg: None,
2140                #[cfg(feature = "notify")]
2141                notifier: None,
2142                #[cfg(feature = "catalog")]
2143                catalog: None,
2144            },
2145        )
2146        .await
2147        .unwrap();
2148        assert_eq!(summary.invocations.len(), 2);
2149        assert!(out_a.exists());
2150        assert!(out_b.exists());
2151    }
2152
2153    #[tokio::test]
2154    async fn dag_child_fans_out_per_parent_record() {
2155        // Parent: CSV with two records (id=1, id=2).
2156        // Child: writes one JSONL file per parent id, using ${parent.id} in the path.
2157        let dir = tempfile::tempdir().unwrap();
2158        let parent_csv = dir.path().join("parents.csv");
2159        let child_csv = dir.path().join("child.csv");
2160        std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
2161        std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
2162        let parent_out = dir.path().join("parents.jsonl");
2163        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2164
2165        let yaml = format!(
2166            r#"version: 1
2167pipeline:
2168  source: {{ type: csv, config: {{ path: {parent} }} }}
2169  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
2170matrix:
2171  - id: parents
2172  - id: child
2173    parent: parents
2174    source: {{ config: {{ path: {child} }} }}
2175    sink:   {{ config: {{ path: "{child_out}" }} }}
2176"#,
2177            parent = parent_csv.display(),
2178            parent_out = parent_out.display(),
2179            child = child_csv.display(),
2180            child_out = child_out_pattern.display(),
2181        );
2182        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2183        let nodes = expand(&cfg).unwrap();
2184        let summary = run_expanded(
2185            nodes,
2186            ExecuteOptions {
2187                pipeline_name: "dagtest".into(),
2188                execution: None,
2189                dry_run: false,
2190                limit: None,
2191                state_path_override: None,
2192                shard: None,
2193                auth: Default::default(),
2194                clock: chrono::Utc::now().fixed_offset(),
2195                cancel: None,
2196                resilience: None,
2197                sla: None,
2198                #[cfg(feature = "lineage")]
2199                lineage: None,
2200                #[cfg(feature = "lineage")]
2201                lineage_cfg: None,
2202                #[cfg(feature = "notify")]
2203                notifier: None,
2204                #[cfg(feature = "catalog")]
2205                catalog: None,
2206            },
2207        )
2208        .await
2209        .unwrap();
2210
2211        // 1 parent invocation + 2 child invocations.
2212        assert_eq!(summary.invocations.len(), 3);
2213        assert!(!summary.had_failures(), "{:?}", summary);
2214        assert!(dir.path().join("child-1.jsonl").exists());
2215        assert!(dir.path().join("child-2.jsonl").exists());
2216    }
2217
2218    #[tokio::test]
2219    async fn depends_on_root_runs_after_dependency() {
2220        // `stage` writes a CSV that `load` reads — `load` can only succeed if
2221        // it genuinely starts after `stage` finishes (pure ordering, no
2222        // record hand-off).
2223        let dir = tempfile::tempdir().unwrap();
2224        let input = dir.path().join("in.csv");
2225        let mid = dir.path().join("mid.csv");
2226        let out = dir.path().join("out.jsonl");
2227        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2228
2229        let yaml = format!(
2230            r#"version: 1
2231pipeline:
2232  source: {{ type: csv, config: {{ path: {input} }} }}
2233  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2234matrix:
2235  - id: stage
2236    sink: {{ type: csv, config: {{ path: {mid} }} }}
2237  - id: load
2238    depends_on: [stage]
2239    source: {{ config: {{ path: {mid} }} }}
2240"#,
2241            input = input.display(),
2242            mid = mid.display(),
2243            out = out.display(),
2244        );
2245        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2246        let nodes = expand(&cfg).unwrap();
2247        let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
2248        assert_eq!(summary.invocations.len(), 2, "{summary:?}");
2249        assert!(!summary.had_failures(), "{summary:?}");
2250        let load = summary
2251            .invocations
2252            .iter()
2253            .find(|i| i.row_id == "load")
2254            .unwrap();
2255        assert_eq!(load.records_written, 2);
2256        let written = std::fs::read_to_string(&out).unwrap();
2257        assert_eq!(written.lines().count(), 2);
2258    }
2259
2260    #[tokio::test]
2261    async fn diamond_dependency_waits_for_all_prerequisites() {
2262        // c waits on both a and b (a diamond join): readiness must require
2263        // *every* dependency to be terminal, not just the first.
2264        let dir = tempfile::tempdir().unwrap();
2265        let input = dir.path().join("in.csv");
2266        let mid_a = dir.path().join("mid_a.csv");
2267        let mid_b = dir.path().join("mid_b.csv");
2268        let out = dir.path().join("out.jsonl");
2269        std::fs::write(&input, "name\nalice\n").unwrap();
2270
2271        let yaml = format!(
2272            r#"version: 1
2273pipeline:
2274  source: {{ type: csv, config: {{ path: {input} }} }}
2275  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2276matrix:
2277  - id: a
2278    sink: {{ type: csv, config: {{ path: {mid_a} }} }}
2279  - id: b
2280    sink: {{ type: csv, config: {{ path: {mid_b} }} }}
2281  - id: c
2282    depends_on: [a, b]
2283    source: {{ config: {{ path: {mid_a} }} }}
2284"#,
2285            input = input.display(),
2286            mid_a = mid_a.display(),
2287            mid_b = mid_b.display(),
2288            out = out.display(),
2289        );
2290        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2291        let nodes = expand(&cfg).unwrap();
2292        let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
2293        assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2294        assert!(!summary.had_failures(), "{summary:?}");
2295        assert!(mid_b.exists(), "b must have run before c became ready");
2296        assert!(out.exists());
2297    }
2298
2299    #[tokio::test]
2300    async fn failed_dependency_skips_dependent() {
2301        // `stage` fails (missing input file); `load` depends on it and must be
2302        // skipped — no invocation outcome, no output file.
2303        let dir = tempfile::tempdir().unwrap();
2304        let good_input = dir.path().join("good.csv");
2305        let out = dir.path().join("out.jsonl");
2306        std::fs::write(&good_input, "name\nalice\n").unwrap();
2307
2308        let yaml = format!(
2309            r#"version: 1
2310pipeline:
2311  source: {{ type: csv, config: {{ path: {good} }} }}
2312  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2313matrix:
2314  - id: stage
2315    source: {{ config: {{ path: {missing} }} }}
2316  - id: load
2317    depends_on: [stage]
2318"#,
2319            good = good_input.display(),
2320            missing = dir.path().join("nonexistent.csv").display(),
2321            out = out.display(),
2322        );
2323        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2324        let nodes = expand(&cfg).unwrap();
2325        let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
2326        assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2327        assert_eq!(summary.invocations[0].row_id, "stage");
2328        assert!(summary.invocations[0].error.is_some());
2329        assert!(
2330            !out.exists(),
2331            "dependent row must not run after its dependency failed"
2332        );
2333    }
2334
2335    #[tokio::test]
2336    async fn dependency_on_skipped_row_cascades() {
2337        // p fails → its child c is skipped → q (which depends on c) must be
2338        // skipped too, even though c itself never *failed*.
2339        let dir = tempfile::tempdir().unwrap();
2340        let good_input = dir.path().join("good.csv");
2341        let out = dir.path().join("q.jsonl");
2342        std::fs::write(&good_input, "id\n1\n").unwrap();
2343
2344        let yaml = format!(
2345            r#"version: 1
2346pipeline:
2347  source: {{ type: csv, config: {{ path: {good} }} }}
2348  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2349matrix:
2350  - id: p
2351    source: {{ config: {{ path: {missing} }} }}
2352  - id: c
2353    parent: p
2354  - id: q
2355    depends_on: [c]
2356"#,
2357            good = good_input.display(),
2358            missing = dir.path().join("nonexistent.csv").display(),
2359            out = out.display(),
2360        );
2361        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2362        let nodes = expand(&cfg).unwrap();
2363        let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
2364        assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2365        assert_eq!(summary.invocations[0].row_id, "p");
2366        assert!(summary.invocations[0].error.is_some());
2367        assert!(
2368            !out.exists(),
2369            "q must be skipped when its dependency was skipped"
2370        );
2371    }
2372
2373    #[tokio::test]
2374    async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
2375        // First root writes to an invalid sink path and fails. The second
2376        // ("good") root would succeed. Under `on_error: stop` the executor
2377        // calls `abort_all()` on the first failure, which cancels pending /
2378        // in-flight tasks at their next await point — but that is
2379        // best-effort: with `max_concurrent: 1` the two roots race for the
2380        // single permit, so "good" may already have completed before "bad"
2381        // fails. We therefore assert the guarantees that hold under *any*
2382        // scheduling rather than an exact invocation count (which was racy,
2383        // see issue #78 finding #24). The deterministic "stop actually
2384        // cancels in-flight work" path is covered by
2385        // `on_error_stop_under_parallelism_aborts_other_in_flight`.
2386        let dir = tempfile::tempdir().unwrap();
2387        let good_csv = dir.path().join("good.csv");
2388        std::fs::write(&good_csv, "x\n1\n").unwrap();
2389        let good_out = dir.path().join("good.jsonl");
2390        let bad_sink_dir = dir.path().to_path_buf();
2391
2392        let yaml = format!(
2393            r#"version: 1
2394pipeline:
2395  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2396  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
2397matrix:
2398  - id: bad
2399    sink: {{ config: {{ path: {bad_dir} }} }}
2400  - id: good
2401execution:
2402  max_concurrent: 1
2403  on_error: stop
2404"#,
2405            good_csv = good_csv.display(),
2406            good_out = good_out.display(),
2407            bad_dir = bad_sink_dir.display(),
2408        );
2409        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2410        let nodes = expand(&cfg).unwrap();
2411        let summary = run_expanded(
2412            nodes,
2413            ExecuteOptions {
2414                pipeline_name: "stoptest".into(),
2415                execution: cfg.execution.clone(),
2416                dry_run: false,
2417                limit: None,
2418                state_path_override: None,
2419                shard: None,
2420                auth: Default::default(),
2421                clock: chrono::Utc::now().fixed_offset(),
2422                cancel: None,
2423                resilience: None,
2424                sla: None,
2425                #[cfg(feature = "lineage")]
2426                lineage: None,
2427                #[cfg(feature = "lineage")]
2428                lineage_cfg: None,
2429                #[cfg(feature = "notify")]
2430                notifier: None,
2431                #[cfg(feature = "catalog")]
2432                catalog: None,
2433            },
2434        )
2435        .await
2436        .unwrap();
2437
2438        // Invariants that hold regardless of which root won the permit race:
2439        assert!(summary.had_failures(), "the failing root must be reported");
2440
2441        // "bad" ran exactly once and is recorded as a failure.
2442        let bad: Vec<_> = summary
2443            .invocations
2444            .iter()
2445            .filter(|o| o.row_id == "bad")
2446            .collect();
2447        assert_eq!(bad.len(), 1, "bad must run exactly once");
2448        assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
2449
2450        // No duplicate / extra invocations beyond the two work units.
2451        assert!(
2452            summary.invocations.len() <= 2,
2453            "at most the two roots may run, got {:?}",
2454            summary.invocations
2455        );
2456
2457        // "good" may: (a) win the permit first and run fully (writes its row,
2458        // file exists); (b) lose the race, acquire the permit after "bad" fails,
2459        // observe the cooperative stop-cancel at its first page boundary, and
2460        // return a 0-record success (no file); or (c) never appear if it was
2461        // still pending when the level finished. So the only invariant is: a
2462        // "good" that actually WROTE records must have produced its file.
2463        let good_wrote = summary
2464            .invocations
2465            .iter()
2466            .find(|o| o.row_id == "good" && o.error.is_none())
2467            .map(|o| o.records_written)
2468            .unwrap_or(0);
2469        if good_wrote > 0 {
2470            assert!(
2471                good_out.exists(),
2472                "a good that wrote records must have produced its output file"
2473            );
2474        }
2475    }
2476
2477    #[tokio::test]
2478    async fn invalid_pipeline_name_with_state_errors_up_front() {
2479        // A pipeline name that can't form a valid state key must fail up front
2480        // (at unit construction) when state is configured — not deep mid-run
2481        // as a `FaucetError::State`.
2482        let dir = tempfile::tempdir().unwrap();
2483        let input = dir.path().join("in.csv");
2484        let output = dir.path().join("out.jsonl");
2485        std::fs::write(&input, "name\nalice\n").unwrap();
2486        let yaml = format!(
2487            r#"version: 1
2488pipeline:
2489  source: {{ type: csv, config: {{ path: {input} }} }}
2490  sink:   {{ type: jsonl, config: {{ path: {output} }} }}
2491  state:  {{ type: memory }}
2492"#,
2493            input = input.display(),
2494            output = output.display(),
2495        );
2496        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2497        let nodes = expand(&cfg).unwrap();
2498        let err = run_expanded(
2499            nodes,
2500            ExecuteOptions {
2501                pipeline_name: "bad name".into(), // space is illegal in a state key
2502                execution: None,
2503                dry_run: false,
2504                limit: None,
2505                state_path_override: None,
2506                shard: None,
2507                auth: Default::default(),
2508                clock: chrono::Utc::now().fixed_offset(),
2509                cancel: None,
2510                resilience: None,
2511                sla: None,
2512                #[cfg(feature = "lineage")]
2513                lineage: None,
2514                #[cfg(feature = "lineage")]
2515                lineage_cfg: None,
2516                #[cfg(feature = "notify")]
2517                notifier: None,
2518                #[cfg(feature = "catalog")]
2519                catalog: None,
2520            },
2521        )
2522        .await
2523        .expect_err("an invalid pipeline name must be rejected up front when state is configured");
2524        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2525    }
2526
2527    #[tokio::test]
2528    async fn invalid_parent_key_value_with_state_errors_up_front() {
2529        // A parent-record value that yields an illegal state-key suffix must
2530        // fail up front at the child's unit construction, not mid-run.
2531        let dir = tempfile::tempdir().unwrap();
2532        let parent_csv = dir.path().join("parents.csv");
2533        let child_csv = dir.path().join("child.csv");
2534        // The parent `id` value contains a space — illegal in a state key.
2535        std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
2536        std::fs::write(&child_csv, "x\nA\n").unwrap();
2537        let parent_out = dir.path().join("parents.jsonl");
2538        let child_out = dir.path().join("child.jsonl");
2539        let yaml = format!(
2540            r#"version: 1
2541pipeline:
2542  source: {{ type: csv, config: {{ path: {parent} }} }}
2543  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
2544  state:  {{ type: memory }}
2545matrix:
2546  - id: parents
2547  - id: child
2548    parent: parents
2549    source: {{ config: {{ path: {child} }} }}
2550    sink:   {{ config: {{ path: {child_out} }} }}
2551"#,
2552            parent = parent_csv.display(),
2553            parent_out = parent_out.display(),
2554            child = child_csv.display(),
2555            child_out = child_out.display(),
2556        );
2557        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2558        let nodes = expand(&cfg).unwrap();
2559        let err = run_expanded(
2560            nodes,
2561            ExecuteOptions {
2562                pipeline_name: "ok".into(),
2563                execution: None,
2564                dry_run: false,
2565                limit: None,
2566                state_path_override: None,
2567                shard: None,
2568                auth: Default::default(),
2569                clock: chrono::Utc::now().fixed_offset(),
2570                cancel: None,
2571                resilience: None,
2572                sla: None,
2573                #[cfg(feature = "lineage")]
2574                lineage: None,
2575                #[cfg(feature = "lineage")]
2576                lineage_cfg: None,
2577                #[cfg(feature = "notify")]
2578                notifier: None,
2579                #[cfg(feature = "catalog")]
2580                catalog: None,
2581            },
2582        )
2583        .await
2584        .expect_err(
2585            "an illegal parent-key value must be rejected up front when state is configured",
2586        );
2587        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2588    }
2589
2590    #[tokio::test]
2591    async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
2592        // Three roots running with `max_concurrent: 3`. The bad row points
2593        // its sink at a directory (open fails fast). The other two point at
2594        // sinks that block forever on the writer end of a pipe — stuck *inside*
2595        // the sink write, they never reach a page boundary to observe the
2596        // cooperative stop-cancel, so the only way they can complete is the
2597        // hard-abort backstop that fires after the flush grace (#146 H16). The
2598        // test would hang if `on_error: stop` never aborted them, so a passing
2599        // run is itself the assertion.
2600        let dir = tempfile::tempdir().unwrap();
2601        let bad_sink_dir = dir.path().to_path_buf();
2602        // A real csv source with one row — small enough that the pipeline
2603        // proceeds straight to the sink phase.
2604        let good_csv = dir.path().join("good.csv");
2605        std::fs::write(&good_csv, "x\n1\n").unwrap();
2606        // The two "would never finish" sinks point at the same path as the
2607        // bad sink (an existing directory). Their sink-open also errors
2608        // out — but we still verify the *abort* path by counting how many
2609        // tasks make it past spawn before stop fires. The strict invariant
2610        // we assert: the bad row's failure is the first one observed.
2611        let yaml = format!(
2612            r#"version: 1
2613pipeline:
2614  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2615  sink:   {{ type: jsonl, config: {{ path: {bad_dir} }} }}
2616matrix:
2617  - id: bad
2618  - id: good_a
2619  - id: good_b
2620execution:
2621  max_concurrent: 3
2622  on_error: stop
2623"#,
2624            good_csv = good_csv.display(),
2625            bad_dir = bad_sink_dir.display(),
2626        );
2627        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2628        let nodes = expand(&cfg).unwrap();
2629        let summary = run_expanded(
2630            nodes,
2631            ExecuteOptions {
2632                pipeline_name: "stop_parallel".into(),
2633                execution: cfg.execution.clone(),
2634                dry_run: false,
2635                limit: None,
2636                state_path_override: None,
2637                shard: None,
2638                auth: Default::default(),
2639                clock: chrono::Utc::now().fixed_offset(),
2640                cancel: None,
2641                resilience: None,
2642                sla: None,
2643                #[cfg(feature = "lineage")]
2644                lineage: None,
2645                #[cfg(feature = "lineage")]
2646                lineage_cfg: None,
2647                #[cfg(feature = "notify")]
2648                notifier: None,
2649                #[cfg(feature = "catalog")]
2650                catalog: None,
2651            },
2652        )
2653        .await
2654        .unwrap();
2655
2656        // First-observed failure halts the run. The first outcome in the
2657        // summary is guaranteed to be a failure (other tasks either fail
2658        // too or get cancelled — both cases never push a *success* outcome
2659        // first because every sink in this matrix is configured to fail).
2660        assert!(
2661            summary.had_failures(),
2662            "summary should record at least one failure: {summary:?}"
2663        );
2664        assert!(
2665            summary.invocations[0].error.is_some(),
2666            "first outcome must be the failure that triggered stop: {summary:?}"
2667        );
2668        // No invocation should report `records_written > 0` — every sink is
2669        // bad. (Catches a regression where abort_all somehow let a task
2670        // bypass its broken sink.)
2671        for inv in &summary.invocations {
2672            assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
2673        }
2674    }
2675
2676    #[tokio::test]
2677    async fn on_error_continue_skips_failed_subtree_only() {
2678        // Two roots: one fails. The good one's invocation still completes.
2679        let dir = tempfile::tempdir().unwrap();
2680        let good_csv = dir.path().join("good.csv");
2681        std::fs::write(&good_csv, "x\n1\n").unwrap();
2682        let good_out = dir.path().join("good.jsonl");
2683
2684        let yaml = format!(
2685            r#"version: 1
2686pipeline:
2687  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2688  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
2689matrix:
2690  - id: bad
2691    sink: {{ config: {{ path: {bad_dir} }} }}
2692  - id: good
2693"#,
2694            good_csv = good_csv.display(),
2695            good_out = good_out.display(),
2696            bad_dir = dir.path().display(),
2697        );
2698        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2699        let nodes = expand(&cfg).unwrap();
2700        let summary = run_expanded(
2701            nodes,
2702            ExecuteOptions {
2703                pipeline_name: "continuetest".into(),
2704                execution: None,
2705                dry_run: false,
2706                limit: None,
2707                state_path_override: None,
2708                shard: None,
2709                auth: Default::default(),
2710                clock: chrono::Utc::now().fixed_offset(),
2711                cancel: None,
2712                resilience: None,
2713                sla: None,
2714                #[cfg(feature = "lineage")]
2715                lineage: None,
2716                #[cfg(feature = "lineage")]
2717                lineage_cfg: None,
2718                #[cfg(feature = "notify")]
2719                notifier: None,
2720                #[cfg(feature = "catalog")]
2721                catalog: None,
2722            },
2723        )
2724        .await
2725        .unwrap();
2726        assert_eq!(summary.invocations.len(), 2);
2727        assert_eq!(summary.failure_count(), 1);
2728        let good_outcome = summary
2729            .invocations
2730            .iter()
2731            .find(|i| i.row_id == "good")
2732            .unwrap();
2733        assert!(good_outcome.error.is_none());
2734    }
2735
2736    // ── projection helpers (#160) ─────────────────────────────────────────────
2737
2738    #[test]
2739    fn split_path_splits_on_dots() {
2740        assert_eq!(split_path("id"), vec!["id".to_string()]);
2741        assert_eq!(
2742            split_path("user.name"),
2743            vec!["user".to_string(), "name".to_string()]
2744        );
2745    }
2746
2747    #[test]
2748    fn minimal_paths_drops_descendants_of_kept_ancestors() {
2749        let paths = vec![
2750            vec!["user".into(), "name".into()],
2751            vec!["user".into()],
2752            vec!["id".into()],
2753            vec!["id".into()],
2754        ];
2755        let min = minimal_paths(paths);
2756        assert!(min.contains(&vec!["user".to_string()]));
2757        assert!(min.contains(&vec!["id".to_string()]));
2758        assert!(
2759            !min.contains(&vec!["user".to_string(), "name".to_string()]),
2760            "user.name must be dropped — covered by user"
2761        );
2762        assert_eq!(min.len(), 2);
2763    }
2764
2765    #[test]
2766    fn project_full_clones_whole_record() {
2767        let r = json!({"a": 1, "b": {"c": 2}});
2768        assert_eq!(project_record(&r, &Projection::Full), r);
2769    }
2770
2771    #[test]
2772    fn project_keeps_only_referenced_paths() {
2773        let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
2774        let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
2775        let got = project_record(&r, &p);
2776        assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
2777        assert!(got.get("blob").is_none());
2778        assert!(got["user"].get("age").is_none());
2779    }
2780
2781    #[test]
2782    fn project_array_index_path_resolves_same_as_original() {
2783        let r = json!({"tags": ["x", "y", "z"]});
2784        let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
2785        let got = project_record(&r, &p);
2786        assert_eq!(got, json!({"tags": {"0": "x"}}));
2787        assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
2788        assert_eq!(
2789            resolve_parent_key(&got, "tags.0"),
2790            resolve_parent_key(&r, "tags.0"),
2791            "reduced tree must resolve the same value as the original"
2792        );
2793    }
2794
2795    #[test]
2796    fn project_numeric_object_key_resolves_same_as_original() {
2797        // A numeric segment can address an OBJECT key in the original (not an
2798        // array index). The reduced all-objects tree stores it under the same
2799        // key, so resolution still matches — the parity property the design
2800        // relies on, distinct from the array-index case above.
2801        let r = json!({"data": {"0": "x", "1": "y"}});
2802        let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
2803        let got = project_record(&r, &p);
2804        assert_eq!(got, json!({"data": {"0": "x"}}));
2805        assert_eq!(
2806            resolve_parent_key(&got, "data.0"),
2807            resolve_parent_key(&r, "data.0"),
2808            "numeric object-key path must resolve identically on the reduced tree"
2809        );
2810    }
2811
2812    #[test]
2813    fn project_missing_path_is_omitted() {
2814        let r = json!({"id": 1});
2815        let p = Projection::Paths(vec![vec!["nope".into()]]);
2816        assert_eq!(project_record(&r, &p), json!({}));
2817    }
2818
2819    #[test]
2820    fn build_projections_unions_parent_key_and_refs() {
2821        use crate::config::ConnectorSpec;
2822        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2823
2824        fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
2825            ExpandedNode {
2826                id: id.into(),
2827                row_index: 0,
2828                role: NodeRole::Child {
2829                    parent_id: parent.into(),
2830                    parent_key: parent_key.into(),
2831                },
2832                source: ConnectorSpec {
2833                    kind: "csv".into(),
2834                    config: json!({}),
2835                    transforms: None,
2836                    inherit_transforms: true,
2837                },
2838                sink: ConnectorSpec {
2839                    kind: "jsonl".into(),
2840                    config: json!({}),
2841                    transforms: None,
2842                    inherit_transforms: true,
2843                },
2844                transforms: Vec::new(),
2845                state: None,
2846                dlq: None,
2847                delivery: faucet_core::DeliveryMode::AtLeastOnce,
2848                delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
2849                #[cfg(feature = "quality")]
2850                quality: None,
2851                #[cfg(feature = "contract")]
2852                contract: None,
2853                #[cfg(feature = "masking")]
2854                masking: None,
2855                sink_ref: "default".into(),
2856                schema: None,
2857                depends_on: Vec::new(),
2858                deferred_refs: refs
2859                    .iter()
2860                    .map(|(rid, p)| DeferredRef {
2861                        referenced_id: (*rid).into(),
2862                        dotted_path: (*p).into(),
2863                        token: format!("${{{rid}.{p}}}"),
2864                    })
2865                    .collect(),
2866                source_override: None,
2867            }
2868        }
2869
2870        let c1 = child("c1", "p", "id", &[("p", "user.name")]);
2871        let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
2872        let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
2873        let children_of =
2874            HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
2875
2876        let projs = build_projections(&nodes_by_id, &children_of);
2877        let p = projs.get("p").expect("projection for p");
2878        match &**p {
2879            Projection::Paths(paths) => {
2880                assert!(paths.contains(&vec!["id".to_string()]));
2881                assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
2882                assert!(paths.contains(&vec!["email".to_string()]));
2883                assert!(
2884                    !paths.iter().any(|p| p == &vec!["x".to_string()]),
2885                    "a ref to a different parent must not be captured under p"
2886                );
2887            }
2888            Projection::Full => panic!("expected Paths, got Full"),
2889        }
2890    }
2891
2892    #[test]
2893    fn build_projections_whole_record_ref_is_full() {
2894        use crate::config::ConnectorSpec;
2895        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2896        let c = ExpandedNode {
2897            id: "c".into(),
2898            row_index: 0,
2899            role: NodeRole::Child {
2900                parent_id: "p".into(),
2901                parent_key: "id".into(),
2902            },
2903            source: ConnectorSpec {
2904                kind: "csv".into(),
2905                config: json!({}),
2906                transforms: None,
2907                inherit_transforms: true,
2908            },
2909            sink: ConnectorSpec {
2910                kind: "jsonl".into(),
2911                config: json!({}),
2912                transforms: None,
2913                inherit_transforms: true,
2914            },
2915            transforms: Vec::new(),
2916            state: None,
2917            dlq: None,
2918            delivery: faucet_core::DeliveryMode::AtLeastOnce,
2919            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
2920            #[cfg(feature = "quality")]
2921            quality: None,
2922            #[cfg(feature = "contract")]
2923            contract: None,
2924            #[cfg(feature = "masking")]
2925            masking: None,
2926            sink_ref: "default".into(),
2927            schema: None,
2928            depends_on: Vec::new(),
2929            deferred_refs: vec![DeferredRef {
2930                referenced_id: "p".into(),
2931                dotted_path: "".into(),
2932                token: "${p}".into(),
2933            }],
2934            source_override: None,
2935        };
2936        let nodes_by_id = HashMap::from([("c".to_string(), c)]);
2937        let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
2938        let projs = build_projections(&nodes_by_id, &children_of);
2939        assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
2940    }
2941
2942    /// Helper: minimal `ExecuteOptions` with all optional knobs cleared.
2943    fn opts(name: &str) -> ExecuteOptions {
2944        ExecuteOptions {
2945            pipeline_name: name.into(),
2946            execution: None,
2947            dry_run: false,
2948            limit: None,
2949            state_path_override: None,
2950            shard: None,
2951            auth: Default::default(),
2952            clock: chrono::Utc::now().fixed_offset(),
2953            cancel: None,
2954            resilience: None,
2955            sla: None,
2956            #[cfg(feature = "lineage")]
2957            lineage: None,
2958            #[cfg(feature = "lineage")]
2959            lineage_cfg: None,
2960            #[cfg(feature = "notify")]
2961            notifier: None,
2962            #[cfg(feature = "catalog")]
2963            catalog: None,
2964        }
2965    }
2966
2967    #[tokio::test]
2968    async fn dry_run_counts_records_without_writing_sink_file() {
2969        // `--dry-run` swaps the real sink for a CountingSink — records flow but
2970        // no output file is produced.
2971        let dir = tempfile::tempdir().unwrap();
2972        let input = dir.path().join("in.csv");
2973        let output = dir.path().join("out.jsonl");
2974        std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
2975        let cfg = cfg_csv_to_jsonl(&input, &output);
2976        let nodes = expand(&cfg).unwrap();
2977        let mut o = opts("dry");
2978        o.dry_run = true;
2979        let summary = run_expanded(nodes, o).await.unwrap();
2980        assert_eq!(summary.invocations.len(), 1);
2981        assert_eq!(summary.invocations[0].records_written, 3);
2982        assert!(!summary.had_failures());
2983        assert!(
2984            !output.exists(),
2985            "dry-run must not create the real sink file"
2986        );
2987    }
2988
2989    #[tokio::test]
2990    async fn read_only_state_store_drops_writes_keeps_reads() {
2991        // #321 H1: reads pass through; put/delete are dropped so a preview never
2992        // mutates the durable bookmark.
2993        let inner = Arc::new(faucet_core::MemoryStateStore::new()) as Arc<dyn StateStore>;
2994        inner.put("k", &json!("v0")).await.unwrap();
2995        let ro = ReadOnlyStateStore {
2996            inner: inner.clone(),
2997        };
2998        assert_eq!(ro.get("k").await.unwrap(), Some(json!("v0")));
2999        // A write must be a no-op — the inner store keeps its original value.
3000        ro.put("k", &json!("advanced")).await.unwrap();
3001        assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3002        // A delete must be a no-op too.
3003        ro.delete("k").await.unwrap();
3004        assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3005    }
3006
3007    #[tokio::test]
3008    async fn dry_run_with_state_does_not_persist_bookmark() {
3009        // #321 H1: a `--dry-run` with a durable state store must not advance the
3010        // persisted bookmark. Pre-seed a bookmark file, run dry, and confirm it is
3011        // byte-for-byte unchanged (the ReadOnlyStateStore wrapper drops writes).
3012        let dir = tempfile::tempdir().unwrap();
3013        let input = dir.path().join("in.csv");
3014        let output = dir.path().join("out.jsonl");
3015        let state_dir = dir.path().join("state");
3016        std::fs::create_dir_all(&state_dir).unwrap();
3017        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3018        let cfg = cfg_csv_to_jsonl(&input, &output);
3019        let nodes = expand(&cfg).unwrap();
3020        let mut o = opts("drystate");
3021        o.dry_run = true;
3022        o.state_path_override = Some(state_dir.clone());
3023        let summary = run_expanded(nodes, o).await.unwrap();
3024        assert!(!summary.had_failures());
3025        assert!(!output.exists(), "dry-run must not write the sink file");
3026        // The state store is wrapped read-only under dry-run, so no bookmark
3027        // file is ever persisted — the state dir stays empty.
3028        let persisted: Vec<_> = std::fs::read_dir(&state_dir)
3029            .unwrap()
3030            .filter_map(Result::ok)
3031            .collect();
3032        assert!(
3033            persisted.is_empty(),
3034            "dry-run must not persist any bookmark file, found: {persisted:?}"
3035        );
3036    }
3037
3038    #[tokio::test]
3039    async fn limit_caps_records_written_across_the_run() {
3040        // `--limit N` wraps the sink so only the first N records land.
3041        let dir = tempfile::tempdir().unwrap();
3042        let input = dir.path().join("in.csv");
3043        let output = dir.path().join("out.jsonl");
3044        std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
3045        let cfg = cfg_csv_to_jsonl(&input, &output);
3046        let nodes = expand(&cfg).unwrap();
3047        let mut o = opts("lim");
3048        o.limit = Some(2);
3049        let summary = run_expanded(nodes, o).await.unwrap();
3050        assert_eq!(summary.invocations[0].records_written, 2);
3051        let body = std::fs::read_to_string(&output).unwrap();
3052        assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
3053    }
3054
3055    #[tokio::test]
3056    async fn duplicate_state_key_among_siblings_is_rejected() {
3057        // Two parent records whose `parent_key` value collides (both id="dup")
3058        // produce two child units with the SAME state key. With state
3059        // configured, that collision must surface as DuplicateStateKey.
3060        let dir = tempfile::tempdir().unwrap();
3061        let parent_csv = dir.path().join("parents.csv");
3062        let child_csv = dir.path().join("child.csv");
3063        // Both rows share id="dup" — the per-child state-key suffix collides.
3064        std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
3065        std::fs::write(&child_csv, "x\nA\n").unwrap();
3066        let parent_out = dir.path().join("parents.jsonl");
3067        let child_out = dir.path().join("child.jsonl");
3068        let yaml = format!(
3069            r#"version: 1
3070pipeline:
3071  source: {{ type: csv, config: {{ path: {parent} }} }}
3072  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
3073  state:  {{ type: memory }}
3074matrix:
3075  - id: parents
3076  - id: child
3077    parent: parents
3078    source: {{ config: {{ path: {child} }} }}
3079    sink:   {{ config: {{ path: {child_out} }} }}
3080"#,
3081            parent = parent_csv.display(),
3082            parent_out = parent_out.display(),
3083            child = child_csv.display(),
3084            child_out = child_out.display(),
3085        );
3086        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3087        let nodes = expand(&cfg).unwrap();
3088        let err = run_expanded(nodes, opts("dupkey"))
3089            .await
3090            .expect_err("colliding sibling state keys must be rejected");
3091        match err {
3092            CliError::DuplicateStateKey { id, state_key } => {
3093                assert_eq!(id, "child");
3094                assert_eq!(state_key, "dupkey::child::dup");
3095            }
3096            other => panic!("expected DuplicateStateKey, got {other:?}"),
3097        }
3098    }
3099
3100    #[tokio::test]
3101    async fn state_path_override_writes_bookmark_file() {
3102        // `--state-path` with a node that has no `state:` block wires a
3103        // FileStateStore at the override path; running it should create the
3104        // bookmark file (REST-less csv source still opts into state via the
3105        // override).
3106        let dir = tempfile::tempdir().unwrap();
3107        let input = dir.path().join("in.csv");
3108        let output = dir.path().join("out.jsonl");
3109        let state_dir = dir.path().join("state");
3110        std::fs::write(&input, "name\nalice\n").unwrap();
3111        let cfg = cfg_csv_to_jsonl(&input, &output);
3112        let nodes = expand(&cfg).unwrap();
3113        let mut o = opts("statepath");
3114        o.state_path_override = Some(state_dir.clone());
3115        let summary = run_expanded(nodes, o).await.unwrap();
3116        assert!(!summary.had_failures());
3117        // The csv source has no natural state key, so the StateKeyOverride wrap
3118        // is skipped — but build_state_for_node still constructs the store.
3119        // The run completing without error exercises the (None, Some(path)) arm.
3120        assert_eq!(summary.invocations[0].records_written, 1);
3121    }
3122
3123    #[tokio::test]
3124    async fn build_dlq_config_maps_spec_fields() {
3125        use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
3126        let dir = tempfile::tempdir().unwrap();
3127        let dlq_out = dir.path().join("dlq.jsonl");
3128        let spec = DlqSpec {
3129            sink: ConnectorSpec {
3130                kind: "jsonl".into(),
3131                config: json!({ "path": dlq_out.to_str().unwrap() }),
3132                transforms: None,
3133                inherit_transforms: true,
3134            },
3135            on_batch_error: OnBatchErrorSpec::DlqAll,
3136            max_failures_per_page: Some(7),
3137            max_failures_total: Some(42),
3138            include_original_payload: false,
3139        };
3140        let cfg = build_dlq_config(&spec).await.unwrap();
3141        assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
3142        assert_eq!(cfg.max_failures_per_page, Some(7));
3143        assert_eq!(cfg.max_failures_total, Some(42));
3144        assert!(!cfg.include_original_payload);
3145    }
3146
3147    #[tokio::test]
3148    async fn build_state_for_node_arms() {
3149        let dir = tempfile::tempdir().unwrap();
3150
3151        // (None, None) → no store.
3152        let node = stub_node(None);
3153        assert!(build_state_for_node(&node, None).await.unwrap().is_none());
3154
3155        // (None, Some(path)) → FileStateStore from override.
3156        let p = dir.path().join("s1");
3157        assert!(
3158            build_state_for_node(&node, Some(&p))
3159                .await
3160                .unwrap()
3161                .is_some()
3162        );
3163
3164        // (Some(memory spec), None) → built from spec.
3165        let node_mem = stub_node(Some(crate::config::StateStoreSpec {
3166            kind: "memory".into(),
3167            config: json!({}),
3168        }));
3169        assert!(
3170            build_state_for_node(&node_mem, None)
3171                .await
3172                .unwrap()
3173                .is_some()
3174        );
3175
3176        // (Some(file spec), Some(path)) → file backend uses the override path.
3177        let node_file = stub_node(Some(crate::config::StateStoreSpec {
3178            kind: "file".into(),
3179            config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
3180        }));
3181        let p2 = dir.path().join("override2");
3182        assert!(
3183            build_state_for_node(&node_file, Some(&p2))
3184                .await
3185                .unwrap()
3186                .is_some()
3187        );
3188
3189        // (Some(memory spec), Some(path)) → non-file backend ignores override,
3190        // still builds from spec.
3191        let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
3192            kind: "memory".into(),
3193            config: json!({}),
3194        }));
3195        let p3 = dir.path().join("override3");
3196        assert!(
3197            build_state_for_node(&node_mem2, Some(&p3))
3198                .await
3199                .unwrap()
3200                .is_some()
3201        );
3202    }
3203
3204    /// Build a minimal root `ExpandedNode` carrying only an (optional) state spec.
3205    fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
3206        use crate::config::ConnectorSpec;
3207        ExpandedNode {
3208            id: "n".into(),
3209            row_index: 0,
3210            role: NodeRole::Root,
3211            source: ConnectorSpec {
3212                kind: "csv".into(),
3213                config: json!({}),
3214                transforms: None,
3215                inherit_transforms: true,
3216            },
3217            sink: ConnectorSpec {
3218                kind: "jsonl".into(),
3219                config: json!({}),
3220                transforms: None,
3221                inherit_transforms: true,
3222            },
3223            transforms: Vec::new(),
3224            state,
3225            dlq: None,
3226            delivery: faucet_core::DeliveryMode::AtLeastOnce,
3227            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3228            #[cfg(feature = "quality")]
3229            quality: None,
3230            #[cfg(feature = "contract")]
3231            contract: None,
3232            #[cfg(feature = "masking")]
3233            masking: None,
3234            sink_ref: "default".into(),
3235            schema: None,
3236            depends_on: Vec::new(),
3237            deferred_refs: Vec::new(),
3238            source_override: None,
3239        }
3240    }
3241
3242    #[tokio::test]
3243    async fn state_key_override_delegates_and_overrides_key() {
3244        // StateKeyOverride forwards fetch/bookmark to inner and reports its own key.
3245        let dir = tempfile::tempdir().unwrap();
3246        let input = dir.path().join("in.csv");
3247        std::fs::write(&input, "name\nz\n").unwrap();
3248        let inner = build_source(
3249            "csv",
3250            json!({"path": input.to_str().unwrap()}),
3251            &AuthCatalog::new(),
3252            None,
3253        )
3254        .await
3255        .unwrap();
3256        // The wrapped name must match the inner source's, whatever it reports.
3257        let inner_name = inner.connector_name();
3258        let ov = StateKeyOverride {
3259            inner,
3260            key: "my::custom::key".into(),
3261        };
3262        assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
3263        assert_eq!(ov.connector_name(), inner_name);
3264        let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
3265        assert_eq!(rows.len(), 1);
3266        // apply_start_bookmark delegates without error (csv ignores it).
3267        ov.apply_start_bookmark(json!({"any": "bookmark"}))
3268            .await
3269            .unwrap();
3270        // Capability passthroughs (csv defaults).
3271        assert!(!ov.supports_exactly_once());
3272        assert_eq!(
3273            ov.replay_guarantee(),
3274            faucet_core::ReplayGuarantee::NonDeterministic
3275        );
3276        assert_eq!(ov.capture_resume_position().await.unwrap(), None);
3277    }
3278
3279    #[tokio::test]
3280    async fn state_key_override_forwards_native_stream_pages() {
3281        // The wrap must preserve the inner source's NATIVE page stream —
3282        // per-page bookmarks included. Without the `stream_pages` forward, the
3283        // trait's buffering default kicks in and collapses everything into
3284        // final-page-bookmark-only pages (losing CDC per-transaction
3285        // durability and exactly-once per-page tokens).
3286        struct PerPageBookmarkSource;
3287        #[async_trait]
3288        impl Source for PerPageBookmarkSource {
3289            async fn fetch_with_context(
3290                &self,
3291                _ctx: &HashMap<String, Value>,
3292            ) -> Result<Vec<Value>, FaucetError> {
3293                Ok(vec![json!({"id": 1}), json!({"id": 2})])
3294            }
3295            fn stream_pages<'a>(
3296                &'a self,
3297                _ctx: &'a HashMap<String, Value>,
3298                _batch_size: usize,
3299            ) -> std::pin::Pin<
3300                Box<
3301                    dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
3302                        + Send
3303                        + 'a,
3304                >,
3305            > {
3306                Box::pin(faucet_core::async_stream::try_stream! {
3307                    yield faucet_core::StreamPage {
3308                        records: vec![json!({"id": 1})],
3309                        bookmark: Some(json!("bm-1")),
3310                    };
3311                    yield faucet_core::StreamPage {
3312                        records: vec![json!({"id": 2})],
3313                        bookmark: Some(json!("bm-2")),
3314                    };
3315                })
3316            }
3317            fn state_key(&self) -> Option<String> {
3318                Some("native".into())
3319            }
3320        }
3321
3322        use futures::StreamExt;
3323        let ov = StateKeyOverride {
3324            inner: Box::new(PerPageBookmarkSource),
3325            key: "override".into(),
3326        };
3327        let ctx = HashMap::new();
3328        let pages: Vec<_> = ov
3329            .stream_pages(&ctx, 1000)
3330            .collect::<Vec<_>>()
3331            .await
3332            .into_iter()
3333            .collect::<Result<Vec<_>, _>>()
3334            .unwrap();
3335        assert_eq!(pages.len(), 2, "native page boundaries survive the wrap");
3336        assert_eq!(pages[0].bookmark, Some(json!("bm-1")));
3337        assert_eq!(pages[1].bookmark, Some(json!("bm-2")));
3338    }
3339
3340    #[tokio::test]
3341    async fn capturing_sink_forwards_capabilities_and_captures_idempotent_writes() {
3342        struct IdemSink;
3343        #[async_trait]
3344        impl Sink for IdemSink {
3345            async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3346                Ok(records.len())
3347            }
3348            fn connector_name(&self) -> &'static str {
3349                "idem"
3350            }
3351            fn supports_idempotent_writes(&self) -> bool {
3352                true
3353            }
3354            fn dedups_by_key(&self) -> bool {
3355                true
3356            }
3357            fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
3358                &[
3359                    faucet_core::WriteMode::Append,
3360                    faucet_core::WriteMode::Upsert,
3361                ]
3362            }
3363            async fn write_batch_idempotent(
3364                &self,
3365                records: &[Value],
3366                _scope: &str,
3367                _token: &str,
3368            ) -> Result<usize, FaucetError> {
3369                Ok(records.len())
3370            }
3371            async fn last_committed_token(
3372                &self,
3373                _scope: &str,
3374            ) -> Result<Option<String>, FaucetError> {
3375                Ok(Some("tok".into()))
3376            }
3377        }
3378
3379        let captured = Arc::new(Mutex::new(Vec::new()));
3380        let sink = CapturingSink::wrap(
3381            Box::new(IdemSink),
3382            Arc::clone(&captured),
3383            Arc::new(Projection::Full),
3384        );
3385        // Capability passthroughs: a parent row feeding children keeps the
3386        // inner sink's delivery semantics.
3387        assert!(sink.supports_idempotent_writes());
3388        assert!(sink.dedups_by_key());
3389        assert_eq!(
3390            sink.sink_guarantee(),
3391            faucet_core::SinkGuarantee::AtomicWatermark
3392        );
3393        assert!(
3394            sink.supported_write_modes()
3395                .contains(&faucet_core::WriteMode::Upsert)
3396        );
3397        assert_eq!(
3398            sink.last_committed_token("k").await.unwrap(),
3399            Some("tok".into())
3400        );
3401        assert_eq!(sink.current_schema().await.unwrap(), None);
3402        assert!(!sink.supports_schema_evolution());
3403        // Idempotent writes are captured for child fan-out like plain writes.
3404        let n = sink
3405            .write_batch_idempotent(&[json!({"id": 7})], "k", "t")
3406            .await
3407            .unwrap();
3408        assert_eq!(n, 1);
3409        assert_eq!(*captured.lock().await, vec![json!({"id": 7})]);
3410    }
3411
3412    #[tokio::test]
3413    async fn orphaned_child_surfaces_executor_deadlock() {
3414        // A child node whose parent id is never present among the nodes can
3415        // never become ready. `expand` would reject this, but a hand-built node
3416        // list exercises the executor's own deadlock guard (lines 227-233).
3417        use crate::config::ConnectorSpec;
3418        let orphan = ExpandedNode {
3419            id: "orphan".into(),
3420            row_index: 0,
3421            role: NodeRole::Child {
3422                parent_id: "missing-parent".into(),
3423                parent_key: "id".into(),
3424            },
3425            source: ConnectorSpec {
3426                kind: "csv".into(),
3427                config: json!({}),
3428                transforms: None,
3429                inherit_transforms: true,
3430            },
3431            sink: ConnectorSpec {
3432                kind: "jsonl".into(),
3433                config: json!({}),
3434                transforms: None,
3435                inherit_transforms: true,
3436            },
3437            transforms: Vec::new(),
3438            state: None,
3439            dlq: None,
3440            delivery: faucet_core::DeliveryMode::AtLeastOnce,
3441            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3442            #[cfg(feature = "quality")]
3443            quality: None,
3444            #[cfg(feature = "contract")]
3445            contract: None,
3446            #[cfg(feature = "masking")]
3447            masking: None,
3448            sink_ref: "default".into(),
3449            schema: None,
3450            depends_on: Vec::new(),
3451            deferred_refs: Vec::new(),
3452            source_override: None,
3453        };
3454        let err = run_expanded(vec![orphan], opts("deadlock"))
3455            .await
3456            .expect_err("an orphaned child must surface as an executor deadlock");
3457        match err {
3458            CliError::Internal(msg) => {
3459                assert!(msg.contains("executor deadlock"), "{msg}");
3460                assert!(msg.contains("orphan"), "{msg}");
3461            }
3462            other => panic!("expected Internal deadlock error, got {other:?}"),
3463        }
3464    }
3465
3466    #[test]
3467    fn value_to_string_brief_unquotes_strings_only() {
3468        assert_eq!(value_to_string_brief(&json!("hello")), "hello");
3469        assert_eq!(value_to_string_brief(&json!(42)), "42");
3470        assert_eq!(value_to_string_brief(&json!(true)), "true");
3471        assert_eq!(value_to_string_brief(&json!(null)), "null");
3472        assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
3473    }
3474
3475    #[test]
3476    fn build_state_key_with_and_without_parent() {
3477        assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
3478        assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
3479    }
3480
3481    #[test]
3482    fn resolve_parent_key_walks_objects_arrays_and_misses() {
3483        let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
3484        assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
3485        assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
3486        // Missing key → None.
3487        assert_eq!(resolve_parent_key(&r, "user.age"), None);
3488        // Descending into a scalar → None.
3489        assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
3490        // Non-numeric array index → None.
3491        assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
3492    }
3493
3494    #[tokio::test]
3495    async fn cooperative_cancel_returns_partial_ok() {
3496        // A pre-cancelled token makes the run stop at the first page boundary
3497        // and flush — returning Ok with a partial (possibly empty) result
3498        // rather than erroring. Covers the cancel-threading path.
3499        let dir = tempfile::tempdir().unwrap();
3500        let input = dir.path().join("in.csv");
3501        let output = dir.path().join("out.jsonl");
3502        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3503        let cfg = cfg_csv_to_jsonl(&input, &output);
3504        let nodes = expand(&cfg).unwrap();
3505        let token = CancellationToken::new();
3506        token.cancel(); // already cancelled before the run starts
3507        let mut o = opts("cancel");
3508        o.cancel = Some(token);
3509        let summary = run_expanded(nodes, o).await.unwrap();
3510        // The single root invocation completes (Ok) — it is not reported as a
3511        // failure even though it was cancelled.
3512        assert_eq!(summary.invocations.len(), 1);
3513        assert!(
3514            !summary.had_failures(),
3515            "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
3516        );
3517    }
3518
3519    #[tokio::test]
3520    async fn fanout_projects_away_unreferenced_parent_fields() {
3521        // Parent CSV has id + a big unreferenced "payload" column. The child only
3522        // references ${parents.id} (in its output path), so projection keeps "id"
3523        // and the parent_key but drops "payload" — and fan-out still works.
3524        let dir = tempfile::tempdir().unwrap();
3525        let parent_csv = dir.path().join("parents.csv");
3526        let child_csv = dir.path().join("child.csv");
3527        std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
3528        std::fs::write(&child_csv, "x\nA\n").unwrap();
3529        let parent_out = dir.path().join("parents.jsonl");
3530        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3531
3532        let yaml = format!(
3533            r#"version: 1
3534pipeline:
3535  source: {{ type: csv, config: {{ path: {parent} }} }}
3536  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
3537matrix:
3538  - id: parents
3539  - id: child
3540    parent: parents
3541    source: {{ config: {{ path: {child} }} }}
3542    sink:   {{ config: {{ path: "{child_out}" }} }}
3543"#,
3544            parent = parent_csv.display(),
3545            parent_out = parent_out.display(),
3546            child = child_csv.display(),
3547            child_out = child_out_pattern.display(),
3548        );
3549        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3550        let nodes = expand(&cfg).unwrap();
3551        let summary = run_expanded(
3552            nodes,
3553            ExecuteOptions {
3554                pipeline_name: "projtest".into(),
3555                execution: None,
3556                dry_run: false,
3557                limit: None,
3558                state_path_override: None,
3559                shard: None,
3560                auth: Default::default(),
3561                clock: chrono::Utc::now().fixed_offset(),
3562                cancel: None,
3563                resilience: None,
3564                sla: None,
3565                #[cfg(feature = "lineage")]
3566                lineage: None,
3567                #[cfg(feature = "lineage")]
3568                lineage_cfg: None,
3569                #[cfg(feature = "notify")]
3570                notifier: None,
3571                #[cfg(feature = "catalog")]
3572                catalog: None,
3573            },
3574        )
3575        .await
3576        .unwrap();
3577
3578        // 1 parent + 2 child invocations (one per parent record) — cardinality kept.
3579        assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3580        assert!(!summary.had_failures(), "{summary:?}");
3581        // ${parents.id} resolved correctly for each child despite "payload" being projected away.
3582        assert!(dir.path().join("child-1.jsonl").exists());
3583        assert!(dir.path().join("child-2.jsonl").exists());
3584    }
3585}