Skip to main content

faucet_cli/
executor.rs

1//! Run a list of [`ExpandedNode`]s under a bounded-concurrency executor.
2//!
3//! Semantics:
4//!
5//! - Roots run concurrently under `Semaphore(max_concurrent)`.
6//! - Each root captures its written records (via a `CapturingSink` wrapper)
7//!   so descendants can fan out per parent record.
8//! - For each child whose parent has finished successfully, one pipeline
9//!   invocation runs per parent record. `${parent.dotted.path}` tokens in the
10//!   source / sink config and state-key suffix are resolved against that
11//!   record via [`interpolate_record`].
12//! - All invocations share one global semaphore — children and roots compete
13//!   for the same budget.
14//! - A node with `depends_on: [row, …]` starts only after every listed row's
15//!   invocations finish successfully — pure completion-ordering, no record
16//!   hand-off. A failed or skipped dependency skips the node (and, in turn,
17//!   its own subtree and dependents).
18//! - `on_error: continue` (default) skips a failed node's subtree but keeps
19//!   running siblings. `on_error: stop` cancels everything after the first
20//!   failure.
21//! - State-key collisions among children of the same parent surface as a
22//!   `CliError::DuplicateStateKey`.
23
24use crate::auth_catalog::AuthCatalog;
25use crate::config::{ExecutionSpec, OnError};
26use crate::error::{CliError, CliResult};
27use crate::expand::{ExpandedNode, NodeRole};
28use crate::interpolate::interpolate_record;
29use crate::registry::{build_sink, build_source};
30use crate::state::build_state_store;
31use async_trait::async_trait;
32use chrono::{DateTime, FixedOffset};
33use faucet_core::observability::Labels;
34use faucet_core::{DlqConfig, FaucetError, OnBatchError, Pipeline, Sink, Source, StateStore};
35use serde_json::Value;
36use std::collections::{HashMap, HashSet};
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use std::sync::atomic::{AtomicUsize, Ordering};
40use std::time::Duration;
41use tokio::sync::{Mutex, Semaphore};
42
43/// Captured fan-out records, keyed by node id. Records are held as `Arc<Value>`
44/// so the per-level snapshot and per-child-unit hand-off are pointer bumps, not
45/// deep clones of the JSON tree (#160).
46type CapturedRecords = Arc<Mutex<HashMap<String, Vec<Arc<Value>>>>>;
47
48/// Enumerated discovery dimensions (#501), keyed by discovery row id. Populated
49/// by discovery invocations, read when building the `for_each` cross-product.
50type DiscoveredDims = Arc<Mutex<HashMap<String, crate::discovery_matrix::Dim>>>;
51
52/// Collected (list-valued) discovery dimensions (#531), keyed by discovery row
53/// id. A chained `discover:` row (`for_each:` + `collect: true`) populates one
54/// per-tuple list here; a consuming `Product` row injects the matching list into
55/// each tuple ctx via [`discovery_matrix::inject_collected`].
56type CollectedDims = Arc<Mutex<HashMap<String, crate::discovery_matrix::CollectedDim>>>;
57use tokio_util::sync::CancellationToken;
58
59/// Knobs passed to [`run_expanded`].
60pub struct ExecuteOptions {
61    /// Pipeline name — used in log lines and as the first segment of every
62    /// state key.
63    pub pipeline_name: String,
64    /// Correlation id for the submitted run (#480). `faucet serve` passes the
65    /// id it returned from `POST /v1/runs` so a completion notification can be
66    /// matched back to the submission; every other runtime leaves it `None` and
67    /// each invocation falls back to its own generated id.
68    ///
69    /// Deliberately *not* the observability `run_id`: one submitted run expands
70    /// to one invocation per matrix row, and the span identity stays unique per
71    /// invocation. Notifications carry both — `run_id` (this) to correlate to
72    /// the submission, `invocation_id` to tell rows apart.
73    pub run_id: Option<String>,
74    /// Override for `execution.max_concurrent`. `None` → use the value in
75    /// `ExecutionSpec` or the default (`num_cpus::get().min(4)`, floored at 1).
76    pub execution: Option<ExecutionSpec>,
77    /// `--dry-run` — every sink is replaced with a no-op counter.
78    pub dry_run: bool,
79    /// `--limit N` — wraps every sink to drop records past the cap.
80    pub limit: Option<usize>,
81    /// `--state-path PATH` — overrides the `file` state-store path.
82    pub state_path_override: Option<PathBuf>,
83    /// Clustered Mode B (#230): narrow this run's single source to one shard
84    /// before streaming, and suffix its state key with the shard id so resume is
85    /// per-shard. `None` (the default) runs the whole source unchanged. Only set
86    /// by the serve shard executor; every other caller leaves it `None`.
87    pub shard: Option<faucet_core::ShardSpec>,
88    /// Shared auth providers built from the top-level `auth:` block. Connectors
89    /// that reference one via `auth: { ref }` resolve against this catalog;
90    /// every row sharing a provider gets the same `Arc` (one token, shared).
91    pub auth: AuthCatalog,
92    /// Wall-clock instant for `${now.*}` interpolation in this run's configs.
93    /// `faucet run` sets process-start (or `--clock`); `faucet schedule` sets
94    /// the tick's scheduled time in the schedule timezone.
95    pub clock: DateTime<FixedOffset>,
96    /// Optional external cancellation token. When set and cancelled, in-flight
97    /// invocations stop at their next page boundary and **flush** their sinks
98    /// (so buffered output like a Parquet footer is durable), rather than being
99    /// hard-dropped (#146 H16). `faucet serve` wires this to run-cancel /
100    /// timeout / shutdown; `faucet run` leaves it `None`.
101    pub cancel: Option<CancellationToken>,
102    /// Optional resilience policy (retry/backoff/circuit-breaker/poison),
103    /// attached to every invocation's `RunStreamOptions` and injected into
104    /// rest/xml/graphql sources. Built once from the top-level `resilience:`
105    /// block; `None` preserves today's behaviour.
106    pub resilience: Option<faucet_core::ResiliencePolicy>,
107    /// Optional freshness/volume SLA (#202), evaluated after every **root**
108    /// invocation against history persisted in the node's state store.
109    /// Violations emit metrics + warnings; they never fail the run. `None`
110    /// disables the pass entirely.
111    pub sla: Option<crate::sla::SlaSpec>,
112    /// Optional completeness reconciliation (#502): after every successful
113    /// **root** invocation, run a count probe and **fail the run** on a shortfall
114    /// beyond tolerance. `None` disables the pass. Same root/real-run scoping as
115    /// the SLA pass (skipped for dry-run / `--limit` / shard / cancelled runs).
116    pub reconcile: Option<crate::reconcile::ReconcileSpec>,
117    /// Shared OpenLineage emitter, built once from the `lineage:` block. `None`
118    /// disables lineage (and adds zero overhead). Gated on the `lineage` feature.
119    #[cfg(feature = "lineage")]
120    pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
121    /// The resolved `lineage:` config block (facet/event toggles, sampling, job
122    /// name template). Carried alongside the emitter so `run_one_invocation`
123    /// knows which facets/events to assemble. Gated on the `lineage` feature.
124    #[cfg(feature = "lineage")]
125    pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
126    /// Optional notification/incident-routing notifier (#280), built once from
127    /// the top-level `notifications:` block and shared across invocations. Fires
128    /// run success/failure, SLA breach, circuit-open, contract-abort, and
129    /// DLQ-threshold events after every **root** invocation. `None` disables
130    /// notifications entirely (zero overhead). Gated on the `notify` feature.
131    #[cfg(feature = "notify")]
132    pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
133    /// Optional Data Movement Catalog store (#279), recorded into after every
134    /// successful **root** invocation (dataset identity, schema timeline,
135    /// volume/freshness, lineage edge). `faucet serve` passes its run-history
136    /// backend + the serve run id; the CLI runtimes connect one from the
137    /// `catalog:` block. `None` disables recording entirely (zero overhead).
138    /// Recording never fails the run. Gated on the `catalog` feature.
139    #[cfg(feature = "catalog")]
140    pub catalog: Option<crate::catalog::CatalogHandle>,
141}
142
143/// Grace window granted to in-flight invocations to flush cooperatively after
144/// an `on_error: stop` cancellation, before the remaining tasks are
145/// hard-aborted (the backstop for a sink genuinely stuck mid-write). Bounded so
146/// a hung sink can't wedge the whole run.
147const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
148
149/// One pipeline invocation's outcome.
150#[derive(Debug)]
151pub struct InvocationOutcome {
152    pub row_id: String,
153    /// `None` for root invocations; for children, the value at `parent_key` in
154    /// the parent record (rendered to a string).
155    pub parent_record_key: Option<String>,
156    pub records_written: usize,
157    pub error: Option<String>,
158    /// Machine-readable per-invocation stats for `faucet run --output json`
159    /// (#390). `None` for synthetic outcomes (a task panic, or the placeholder
160    /// outcomes the replication / schedule orchestrators build) where no
161    /// pipeline actually ran.
162    pub metrics: Option<InvocationMetrics>,
163}
164
165/// Per-invocation stats surfaced by `faucet run --output json` (#390). Every
166/// field comes from counters the pipeline already maintains — no new
167/// measurement. `records_read` is `None` unless input sampling was active
168/// (a `lineage:` or `catalog:` block installs the source sampler).
169#[derive(Debug, Clone, Default)]
170pub struct InvocationMetrics {
171    pub source_kind: String,
172    pub sink_kind: String,
173    pub duration_ms: u64,
174    pub records_read: Option<u64>,
175    pub dlq_count: u64,
176    pub bookmark: Option<Value>,
177}
178
179/// What [`run_one_invocation`] hands back on success alongside the captured
180/// records — the pipeline-level counters `run_unit` folds into an
181/// [`InvocationMetrics`] (which then adds the connector kinds + wall-clock).
182struct PipelineStats {
183    records_written: usize,
184    records_read: Option<u64>,
185    dlq_count: u64,
186    bookmark: Option<Value>,
187}
188
189/// Aggregate outcome of `run_expanded`.
190#[derive(Debug)]
191pub struct RunSummary {
192    pub invocations: Vec<InvocationOutcome>,
193}
194
195impl RunSummary {
196    pub fn failure_count(&self) -> usize {
197        self.invocations
198            .iter()
199            .filter(|i| i.error.is_some())
200            .count()
201    }
202    pub fn had_failures(&self) -> bool {
203        self.failure_count() > 0
204    }
205}
206
207/// Default number of matrix invocations to run in parallel when neither the
208/// config's `execution.max_concurrent` nor a flag specifies one.
209///
210/// Scales with the core count but is capped at 8. The cap is deliberate: each
211/// invocation is a *full pipeline* with its own connection pools / HTTP
212/// clients, and matrix rows often target the same external system (one API,
213/// one database), so an unbounded fan-out across, say, a 64-core box would
214/// blow through that system's connection or rate limits rather than going
215/// faster. Workloads that genuinely benefit from more parallelism set
216/// `execution.max_concurrent` explicitly to opt out of the cap (#78 LOW).
217fn default_concurrency() -> usize {
218    std::thread::available_parallelism()
219        .map(|n| n.get())
220        .unwrap_or(4)
221        .clamp(1, 8)
222}
223
224/// Execute every node in `nodes`. `nodes` must be in BFS order (roots first
225/// then children) — that's what [`crate::expand::expand`] returns.
226pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
227    let on_error = opts
228        .execution
229        .as_ref()
230        .map(|e| e.on_error)
231        .unwrap_or_default();
232    let max_concurrent = opts
233        .execution
234        .as_ref()
235        .and_then(|e| e.max_concurrent)
236        .unwrap_or_else(default_concurrency)
237        .max(1);
238    let semaphore = Arc::new(Semaphore::new(max_concurrent));
239
240    // Index nodes by id for parent → children lookups.
241    // parent id → child node ids. Keyed and valued by id (not Vec index) so the
242    // failure cascade can look children up directly instead of indexing into a
243    // HashMap's nondeterministic iteration order (#78/#24).
244    let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
245    for n in nodes.iter() {
246        if let NodeRole::Child { parent_id, .. } = &n.role {
247            children_of
248                .entry(parent_id.clone())
249                .or_default()
250                .push(n.id.clone());
251        }
252    }
253
254    // Captured records per node id. Only populated for nodes that have
255    // children (= are referenced by another node's `parent:`). Records are held
256    // as `Arc<Value>` so the per-level snapshot clone and the per-child-unit
257    // hand-off are pointer bumps, not deep clones of the JSON tree (#160).
258    let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
259    // Discovery dimensions (#501): value-sets enumerated by `discover:` rows,
260    // read when a `for_each` row builds its cross-product.
261    let discovered: DiscoveredDims = Arc::new(Mutex::new(HashMap::new()));
262    // Collected (list-valued) discovery dimensions (#531): per-tuple lists a
263    // chained `discover:` row publishes, injected into a consuming row's tuples.
264    let collected: CollectedDims = Arc::new(Mutex::new(HashMap::new()));
265
266    let mut outcomes: Vec<InvocationOutcome> = Vec::new();
267    let mut skipped_subtrees: HashSet<String> = HashSet::new();
268
269    // Root cooperative-cancel token: the caller's (serve wires run-cancel /
270    // timeout / shutdown) or a fresh one. Each level derives a child token so
271    // an `on_error: stop` cancels only that level's invocations, while an
272    // external cancel of the root propagates to every level (#146 H16).
273    let cancel = opts.cancel.clone().unwrap_or_default();
274    let opts = Arc::new(opts);
275
276    // We execute level-by-level. Each level is "every node whose parent is
277    // already done." Roots are level 0. For each level, we spawn one task per
278    // (node, parent-record) pair and await them all before moving on.
279    let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
280    let mut completed: HashSet<String> = HashSet::new();
281    let nodes_by_id: HashMap<String, ExpandedNode> =
282        nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
283
284    // Per-parent projection: what to keep from each captured record so the
285    // fan-out buffer holds only the fields children reference (#160).
286    let projections = build_projections(&nodes_by_id, &children_of);
287
288    // Sort node ids in their original BFS row order so the executor is
289    // deterministic — important for `on_error: stop`, where the first failure
290    // halts the rest of the level.
291    let bfs_order: Vec<String> = {
292        let mut ids: Vec<(usize, String)> = nodes_by_id
293            .values()
294            .map(|n| (n.row_index, n.id.clone()))
295            .collect();
296        ids.sort_by_key(|(i, _)| *i);
297        ids.into_iter().map(|(_, id)| id).collect()
298    };
299
300    while !remaining.is_empty() {
301        // Pick every remaining node whose parent (if any) and every
302        // `depends_on` row are already terminal (completed or skipped), in
303        // deterministic BFS order. Whether a skipped/failed prerequisite
304        // *skips* the node is decided in the unit loop below — readiness only
305        // asks "is there anything left to wait for".
306        let ready: Vec<String> = bfs_order
307            .iter()
308            .filter(|id| remaining.contains(*id))
309            .filter(|id| {
310                let node = &nodes_by_id[*id];
311                let parent_done = match &node.role {
312                    // Discovery/Product have no `parent:`; their prerequisites
313                    // (the `for_each` dims) are carried in `depends_on`.
314                    NodeRole::Root | NodeRole::Discovery { .. } | NodeRole::Product { .. } => true,
315                    NodeRole::Child { parent_id, .. } => {
316                        completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
317                    }
318                };
319                parent_done
320                    && node
321                        .depends_on
322                        .iter()
323                        .all(|d| completed.contains(d) || skipped_subtrees.contains(d))
324            })
325            .cloned()
326            .collect();
327
328        if ready.is_empty() {
329            // No node is ready but some remain — an expand.rs invariant was
330            // violated (e.g. an orphaned parent reference). Surface it instead
331            // of silently dropping the remaining work and reporting success
332            // (#78/#24).
333            let mut stuck: Vec<String> = remaining.iter().cloned().collect();
334            stuck.sort();
335            return Err(CliError::Internal(format!(
336                "executor deadlock: {} node(s) never became ready (no completed/skipped \
337                 parent or dependency): {}",
338                stuck.len(),
339                stuck.join(", ")
340            )));
341        }
342
343        // Build the work units for this level. Each unit is one invocation —
344        // a root runs once; a child runs once per parent record.
345        let mut units: Vec<Unit> = Vec::new();
346        // Move only the captured records of the parents whose children run this
347        // level out of the shared map. This both narrows the snapshot and frees
348        // each parent's buffer the moment its children consume it: all of a
349        // parent's children become ready in the same level, so its records are
350        // needed exactly once. Units hold their own `Arc<Value>` clones, so
351        // removing the map entry here only drops the map's hold (#160).
352        let level_records: HashMap<String, Vec<Arc<Value>>> = {
353            let consumed_parents: HashSet<&str> = ready
354                .iter()
355                .filter_map(|id| match &nodes_by_id[id].role {
356                    NodeRole::Child { parent_id, .. } => Some(parent_id.as_str()),
357                    _ => None,
358                })
359                .collect();
360            let mut cap = captured.lock().await;
361            consumed_parents
362                .iter()
363                .filter_map(|p| cap.remove(*p).map(|v| (p.to_string(), v)))
364                .collect()
365        };
366        for id in &ready {
367            let node = &nodes_by_id[id];
368            // If a parent failed (and on_error=continue), the subtree is
369            // skipped. Surface a synthetic "skipped" outcome and move on.
370            if let NodeRole::Child { parent_id, .. } = &node.role
371                && skipped_subtrees.contains(parent_id)
372            {
373                skipped_subtrees.insert(id.clone());
374                tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
375                continue;
376            }
377            // Same for a failed/skipped `depends_on` prerequisite: the row
378            // waited for something that never succeeded, so running it would
379            // violate the ordering contract. Mark it skipped so its own
380            // subtree and dependents cascade too.
381            if let Some(dep) = node
382                .depends_on
383                .iter()
384                .find(|d| skipped_subtrees.contains(d.as_str()))
385            {
386                skipped_subtrees.insert(id.clone());
387                tracing::warn!(
388                    row = %id, dependency = %dep,
389                    "skipping row: a depends_on row failed or was skipped"
390                );
391                continue;
392            }
393            match &node.role {
394                NodeRole::Root => {
395                    let uses_state = node.state.is_some() || opts.state_path_override.is_some();
396                    let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
397                    validate_unit_state_key(&node.id, uses_state, &state_key)?;
398                    units.push(Unit {
399                        node: node.clone(),
400                        parent_record: None,
401                        state_key,
402                        parent_record_key: None,
403                        product_ctx: None,
404                    });
405                }
406                NodeRole::Discovery { dims, .. } => {
407                    if dims.is_empty() {
408                        // One-level discovery (#501): one invocation; run_unit
409                        // intercepts it to enumerate the dimension. State is
410                        // unused, so the key is a placeholder.
411                        let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
412                        units.push(Unit {
413                            node: node.clone(),
414                            parent_record: None,
415                            state_key,
416                            parent_record_key: None,
417                            product_ctx: None,
418                        });
419                    } else {
420                        // Chained discovery (#531): fan out over the upstream
421                        // dimensions, running the enumeration once per tuple.
422                        let resolved = match resolve_product_dims(dims, &discovered, id).await? {
423                            Some(r) => r,
424                            None => {
425                                skipped_subtrees.insert(id.clone());
426                                continue;
427                            }
428                        };
429                        for ctx in crate::discovery_matrix::cartesian(&resolved) {
430                            let suffix =
431                                crate::discovery_matrix::tuple_state_key_suffix(&resolved, &ctx);
432                            let state_key =
433                                build_state_key(&opts.pipeline_name, &node.id, Some(&suffix));
434                            units.push(Unit {
435                                node: node.clone(),
436                                parent_record: None,
437                                state_key,
438                                parent_record_key: Some(suffix),
439                                product_ctx: Some(ctx),
440                            });
441                        }
442                    }
443                }
444                NodeRole::Product {
445                    dims,
446                    collected: collected_ids,
447                } => {
448                    // Gather the enumerated dimensions in declared order. They
449                    // are guaranteed present: each dim is in this row's
450                    // `depends_on`, so a skipped/failed dim already skipped this
451                    // row above. The guard inside is defensive.
452                    let resolved = match resolve_product_dims(dims, &discovered, id).await? {
453                        Some(r) => r,
454                        None => {
455                            skipped_subtrees.insert(id.clone());
456                            continue;
457                        }
458                    };
459                    // Collected (list-valued) dimensions (#531) this row injects
460                    // into each tuple ctx. Guaranteed present (each is in
461                    // `depends_on`); a missing entry injects an empty list.
462                    let collected_dims: Vec<crate::discovery_matrix::CollectedDim> = {
463                        let cmap = collected.lock().await;
464                        collected_ids
465                            .iter()
466                            .filter_map(|cid| cmap.get(cid).cloned())
467                            .collect()
468                    };
469                    let uses_state = node.state.is_some() || opts.state_path_override.is_some();
470                    let mut seen_keys: HashSet<String> = HashSet::new();
471                    for mut ctx in crate::discovery_matrix::cartesian(&resolved) {
472                        crate::discovery_matrix::inject_collected(&mut ctx, &collected_dims);
473                        let suffix =
474                            crate::discovery_matrix::tuple_state_key_suffix(&resolved, &ctx);
475                        let state_key =
476                            build_state_key(&opts.pipeline_name, &node.id, Some(&suffix));
477                        validate_unit_state_key(&node.id, uses_state, &state_key)?;
478                        if !seen_keys.insert(state_key.clone()) {
479                            return Err(CliError::DuplicateStateKey {
480                                id: node.id.clone(),
481                                state_key,
482                            });
483                        }
484                        units.push(Unit {
485                            node: node.clone(),
486                            parent_record: None,
487                            state_key,
488                            parent_record_key: Some(suffix),
489                            product_ctx: Some(ctx),
490                        });
491                    }
492                }
493                NodeRole::Child {
494                    parent_id,
495                    parent_key,
496                } => {
497                    let parent_records = level_records.get(parent_id).cloned().unwrap_or_default();
498                    if parent_records.is_empty() {
499                        tracing::info!(
500                            row = %id, parent = %parent_id,
501                            "parent produced no records — child skipped"
502                        );
503                        continue;
504                    }
505                    // Detect state-key collisions among siblings sharing one parent.
506                    let uses_state = node.state.is_some() || opts.state_path_override.is_some();
507                    let mut seen_keys: HashSet<String> = HashSet::new();
508                    for record in &parent_records {
509                        let pk_value = resolve_parent_key(record, parent_key);
510                        let pk_string = pk_value
511                            .as_ref()
512                            .map(value_to_string_brief)
513                            .unwrap_or_else(|| "(missing)".to_string());
514                        let state_key =
515                            build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
516                        validate_unit_state_key(&node.id, uses_state, &state_key)?;
517                        if !seen_keys.insert(state_key.clone()) {
518                            return Err(CliError::DuplicateStateKey {
519                                id: node.id.clone(),
520                                state_key,
521                            });
522                        }
523                        units.push(Unit {
524                            node: node.clone(),
525                            parent_record: Some(record.clone()),
526                            state_key,
527                            parent_record_key: Some(pk_string),
528                            product_ctx: None,
529                        });
530                    }
531                }
532            }
533        }
534        drop(level_records);
535
536        // #552: overwrite fan-out groups. A `write_mode: overwrite` node that
537        // fans out over N parent records (or discovery tuples) into ONE shared
538        // destination must truncate+swap that destination exactly ONCE across
539        // the whole group — not once per invocation, which would leave only the
540        // last parent's rows (silent data loss). Group the level's units by
541        // their *resolved* sink destination: units sharing a destination form a
542        // group whose lifecycle the executor owns (begin here, commit/abort
543        // after the join); units with per-parent destinations (`${parent.*}` in
544        // the table) each land in their own single-member group, exactly as
545        // before. `dry_run` uses counting sinks with no real destination, so it
546        // is skipped entirely.
547        let (overwrite_groups, overwrite_task_group) = if opts.dry_run {
548            (Vec::new(), HashMap::new())
549        } else {
550            plan_overwrite_groups(&units, opts.as_ref())?
551        };
552        for group in &overwrite_groups {
553            // Build a short-lived sink just for begin, then drop it (closing its
554            // pool) before the invocations run — so it never contends with the
555            // per-invocation sinks on a single-writer backend.
556            let sink = build_sink(&group.kind, group.cfg.clone(), &opts.auth).await?;
557            sink.begin_overwrite().await.map_err(|e| {
558                CliError::Internal(format!(
559                    "overwrite: preparing destination '{}': {e}",
560                    group.dest
561                ))
562            })?;
563        }
564        // Group indices whose fan-out had at least one failed invocation: the
565        // whole group must abort (a full-refresh must never commit partial data).
566        let mut failed_overwrite_groups: HashSet<usize> = HashSet::new();
567
568        let mut had_level_failure = false;
569        let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
570
571        // Unified parallel execution. Tasks run concurrently under the global
572        // semaphore. Under `on_error: stop`, the first failure triggers
573        // `JoinSet::abort_all()` — pending tasks waiting on a permit are
574        // dropped before they do real work, and in-flight tasks are
575        // cancelled at their next `.await` point (potentially leaving
576        // partial sink state — the trade-off users opt into by choosing
577        // `stop`). Under `on_error: continue` every spawned task runs to
578        // completion regardless of sibling failures.
579        // Per-level cancel token: cancelling it (on `on_error: stop`) stops only
580        // this level's invocations cooperatively; it is a child of the root
581        // token, so an external cancel (serve) still propagates here (#146 H16).
582        let level_cancel = cancel.child_token();
583        let mut joinset = tokio::task::JoinSet::new();
584        // Map each spawned task's id back to its row id + parent key so that a
585        // panic (surfaced as a JoinError, which doesn't carry the unit) can be
586        // attributed to the right invocation.
587        let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
588        for unit in units {
589            let sem = Arc::clone(&semaphore);
590            let opts2 = Arc::clone(&opts);
591            let captured = Arc::clone(&captured);
592            let discovered = Arc::clone(&discovered);
593            let collected = Arc::clone(&collected);
594            let capture = projections.get(&unit.node.id).cloned();
595            let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
596            // #552: a unit whose destination is part of an executor-managed
597            // overwrite group runs with its per-invocation begin/commit/abort
598            // suppressed — the group's single lifecycle is driven here instead.
599            let suppress_overwrite = overwrite_task_group.contains_key(&meta);
600            let unit_cancel = level_cancel.clone();
601            let handle = joinset.spawn(async move {
602                let _permit = sem.acquire().await.expect("semaphore not closed");
603                run_unit(
604                    &unit,
605                    capture,
606                    &captured,
607                    &discovered,
608                    &collected,
609                    &opts2,
610                    unit_cancel,
611                    suppress_overwrite,
612                )
613                .await
614            });
615            task_meta.insert(handle.id(), meta);
616        }
617
618        let mut stop_triggered = false;
619        let mut aborted = false;
620        let mut stop_deadline: Option<tokio::time::Instant> = None;
621        loop {
622            // Once `on_error: stop` has cancelled the level, give in-flight
623            // invocations a bounded grace to flush cooperatively, then
624            // hard-abort the stragglers (the backstop for a sink stuck
625            // mid-write that can't reach a page boundary to observe the cancel).
626            let joined = match stop_deadline {
627                Some(deadline) if !aborted => {
628                    match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
629                        Ok(j) => j,
630                        Err(_) => {
631                            tracing::warn!(
632                                "on_error: stop — flush grace elapsed; aborting remaining \
633                                 in-flight invocations"
634                            );
635                            joinset.abort_all();
636                            aborted = true;
637                            continue;
638                        }
639                    }
640                }
641                _ => joinset.join_next_with_id().await,
642            };
643            let Some(joined) = joined else { break };
644            // A failure (an `Err` outcome or a panicked task) marks the level
645            // failed and, under `on_error: stop`, stops the rest. A panicking
646            // connector must NOT take down the whole process (#78/#24).
647            let outcome = match joined {
648                Ok((_id, outcome)) => outcome,
649                Err(e) if e.is_cancelled() => {
650                    // Expected after abort_all() — cancelled before/at an await.
651                    // Not counted as a failure or a success.
652                    continue;
653                }
654                Err(e) => {
655                    let (row_id, parent_record_key) = task_meta
656                        .get(&e.id())
657                        .cloned()
658                        .unwrap_or_else(|| ("<unknown>".to_string(), None));
659                    InvocationOutcome {
660                        row_id,
661                        parent_record_key,
662                        records_written: 0,
663                        error: Some(format!("pipeline invocation task panicked: {e}")),
664                        metrics: None,
665                    }
666                }
667            };
668
669            if let Some(err) = &outcome.error {
670                tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
671                had_level_failure = true;
672                nodes_with_any_failure.insert(outcome.row_id.clone());
673                // #552: fail the invocation's overwrite group so it aborts
674                // (discards staging) rather than committing a partial refresh.
675                if let Some(gi) = overwrite_task_group
676                    .get(&(outcome.row_id.clone(), outcome.parent_record_key.clone()))
677                {
678                    failed_overwrite_groups.insert(*gi);
679                }
680                if matches!(on_error, OnError::Stop) && !stop_triggered {
681                    stop_triggered = true;
682                    tracing::error!(
683                        "on_error: stop — cancelling in-flight invocations (cooperative \
684                         flush), then aborting any that don't stop within the grace window"
685                    );
686                    // Cooperative first: in-flight pipelines flush at their next
687                    // page boundary so a Parquet footer / S3 upload is completed
688                    // rather than orphaned (#146 H16).
689                    level_cancel.cancel();
690                    stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
691                }
692            } else {
693                tracing::info!(
694                    row = %outcome.row_id,
695                    records_written = outcome.records_written,
696                    "pipeline invocation completed"
697                );
698            }
699            outcomes.push(outcome);
700        }
701
702        // #552: finalize each overwrite group's single truncate/swap. Commit
703        // only a group with no failed invocation on a level that was not
704        // cancelled/stopped — otherwise abort (best-effort), leaving the prior
705        // destination exactly as it was. This runs once per shared destination
706        // across the whole fan-out, so every parent's rows are swapped in
707        // together, atomically.
708        let level_cancelled = level_cancel.is_cancelled() || cancel.is_cancelled();
709        for (gi, group) in overwrite_groups.iter().enumerate() {
710            let must_abort = level_cancelled || failed_overwrite_groups.contains(&gi);
711            let sink = build_sink(&group.kind, group.cfg.clone(), &opts.auth).await?;
712            if must_abort {
713                if let Err(e) = sink.abort_overwrite().await {
714                    tracing::warn!(
715                        error = %e, dest = %group.dest,
716                        "overwrite: discarding staging after a failed/cancelled fan-out failed; \
717                         the destination is unchanged"
718                    );
719                }
720            } else {
721                sink.commit_overwrite().await.map_err(|e| {
722                    CliError::Internal(format!(
723                        "overwrite: swapping destination '{}' into place: {e}",
724                        group.dest
725                    ))
726                })?;
727            }
728        }
729
730        // Mark ready nodes done (some may have produced both successes and
731        // failures across their per-parent-record fan-outs — we treat a node
732        // as "failed" overall if any of its invocations failed).
733        for id in ready {
734            remaining.remove(&id);
735            if nodes_with_any_failure.contains(&id) {
736                skipped_subtrees.insert(id.clone());
737                // Cascade to descendants in case we have multi-level chains.
738                if let Some(children) = children_of.get(&id) {
739                    for cid in children {
740                        skipped_subtrees.insert(cid.clone());
741                    }
742                }
743            } else {
744                completed.insert(id);
745            }
746        }
747
748        if had_level_failure && matches!(on_error, OnError::Stop) {
749            tracing::error!("on_error: stop — aborting after first failure");
750            // Any unfinished work surfaces as "skipped"; we just break here.
751            break;
752        }
753    }
754
755    Ok(RunSummary {
756        invocations: outcomes,
757    })
758}
759
760/// One scheduled invocation — a root runs once, a child runs once per parent
761/// record. Built by the level loop, consumed by [`run_unit`].
762struct Unit {
763    node: ExpandedNode,
764    parent_record: Option<Arc<Value>>,
765    state_key: String,
766    parent_record_key: Option<String>,
767    /// Per-tuple interpolation context for a discovery-driven `for_each` row
768    /// (#501): `{dim_id -> { alias: value }}`. `None` for roots/children.
769    product_ctx: Option<HashMap<String, Value>>,
770}
771
772/// Resolve the enumerated dimensions a `for_each`/chained-discovery row fans out
773/// over (#501/#531). Returns `Ok(None)` — meaning "skip this row" — when a
774/// dimension is unavailable (a dependency was skipped/failed) or the cross-product
775/// is empty; `Err` when the product exceeds [`MAX_MATRIX_PRODUCT`]. Each dim is in
776/// the row's `depends_on`, so the missing guard is defensive.
777async fn resolve_product_dims(
778    dims: &[String],
779    discovered: &DiscoveredDims,
780    id: &str,
781) -> CliResult<Option<Vec<crate::discovery_matrix::Dim>>> {
782    let resolved: Vec<crate::discovery_matrix::Dim> = {
783        let dim_map = discovered.lock().await;
784        let mut v = Vec::with_capacity(dims.len());
785        for d in dims {
786            match dim_map.get(d) {
787                Some(dim) => v.push(dim.clone()),
788                None => {
789                    tracing::warn!(row = %id, dimension = %d, "for_each dimension unavailable — row skipped");
790                    return Ok(None);
791                }
792            }
793        }
794        v
795    };
796    let size = crate::discovery_matrix::product_size(&resolved);
797    if size == 0 {
798        tracing::info!(row = %id, "for_each cross-product is empty — row skipped");
799        return Ok(None);
800    }
801    if size > crate::discovery_matrix::MAX_MATRIX_PRODUCT {
802        return Err(CliError::Config(format!(
803            "matrix row '{id}': for_each cross-product is {size} invocations, over the limit of \
804             {} — narrow the discovery dimensions",
805            crate::discovery_matrix::MAX_MATRIX_PRODUCT
806        )));
807    }
808    Ok(Some(resolved))
809}
810
811/// A group of overwrite fan-out invocations sharing one physical destination
812/// (#552). The executor drives `begin_overwrite` once before the group runs and
813/// `commit_overwrite`/`abort_overwrite` once after — so the destination is
814/// truncated and swapped exactly once, retaining every parent's rows rather
815/// than only the last invocation's.
816struct OverwriteGroup {
817    /// Human-readable destination (sink kind + a short config hint) for logs.
818    dest: String,
819    /// The group's sink kind + resolved destination config. A short-lived
820    /// lifecycle sink is built from these for `begin` (before the group runs)
821    /// and again for `commit`/`abort` (after) — never held open across the
822    /// invocations, so it can't contend with the per-invocation sinks on a
823    /// single-writer backend like SQLite. begin/commit/abort address the same
824    /// deterministic staging target the per-invocation sinks write into.
825    kind: String,
826    cfg: Value,
827}
828
829/// Whether a node's sink is configured for `write_mode: overwrite`. The write
830/// mode is `#[serde(flatten)]`'d into the sink config, so it reads off the top
831/// level — mirroring the `write_mode` gate in `expand.rs`.
832fn node_sink_is_overwrite(node: &ExpandedNode) -> bool {
833    node.sink.config.get("write_mode").and_then(Value::as_str) == Some("overwrite")
834}
835
836/// A short destination hint for logs (e.g. `postgres:public.orders`).
837fn describe_sink_dest(kind: &str, cfg: &Value) -> String {
838    for field in [
839        "table",
840        "table_id",
841        "collection",
842        "index",
843        "dataset",
844        "path",
845    ] {
846        if let Some(v) = cfg.get(field).and_then(Value::as_str) {
847            return format!("{kind}:{v}");
848        }
849    }
850    kind.to_string()
851}
852
853/// Resolve a unit's sink destination the same way [`run_one_invocation`] does —
854/// `${now.*}` then the fan-out `${parent.*}` / `${dim.*}` context — so units are
855/// grouped by their *actual* destination (a `${parent.*}`-templated table yields
856/// a distinct destination per parent, and stays a single-member group).
857fn resolved_sink_destination(unit: &Unit, opts: &ExecuteOptions) -> CliResult<Value> {
858    let mut sink_cfg = unit.node.sink.config.clone();
859    resolve_now_inplace(&mut sink_cfg, opts.clock)?;
860    let mut ctx: HashMap<String, Value> = HashMap::new();
861    if let (Some(record), NodeRole::Child { parent_id, .. }) =
862        (unit.parent_record.as_deref(), &unit.node.role)
863    {
864        ctx.insert(parent_id.clone(), record.clone());
865    }
866    if let Some(pc) = unit.product_ctx.as_ref() {
867        for (k, v) in pc {
868            ctx.insert(k.clone(), v.clone());
869        }
870    }
871    if !ctx.is_empty() {
872        resolve_inplace(&mut sink_cfg, &ctx)?;
873    }
874    Ok(sink_cfg)
875}
876
877/// Plan the level's overwrite fan-out groups (#552). Units whose sink is
878/// `write_mode: overwrite` are grouped by their resolved destination; one
879/// lifecycle sink is built per distinct destination. Returns the groups plus a
880/// map from each grouped invocation's `(row_id, parent_key)` to its group index
881/// — used to suppress the per-invocation lifecycle and to attribute a failed
882/// invocation to its group.
883/// Maps a grouped invocation's `(row_id, parent_key)` to its overwrite-group
884/// index (#552).
885type OverwriteTaskGroup = HashMap<(String, Option<String>), usize>;
886
887/// (#552) The planned overwrite groups plus the invocation → group-index map.
888type OverwritePlan = (Vec<OverwriteGroup>, OverwriteTaskGroup);
889
890fn plan_overwrite_groups(units: &[Unit], opts: &ExecuteOptions) -> CliResult<OverwritePlan> {
891    let mut groups: Vec<OverwriteGroup> = Vec::new();
892    let mut task_group: HashMap<(String, Option<String>), usize> = HashMap::new();
893    let mut index_by_key: HashMap<String, usize> = HashMap::new();
894    for unit in units {
895        if !node_sink_is_overwrite(&unit.node) {
896            continue;
897        }
898        let kind = unit.node.sink.kind.clone();
899        let cfg = resolved_sink_destination(unit, opts)?;
900        // Same sink source-object + key-preserving resolution ⇒ stable
901        // serialization, so equal destinations collapse to one group.
902        let key = format!(
903            "{kind}\u{0}{}",
904            serde_json::to_string(&cfg).unwrap_or_default()
905        );
906        let gi = match index_by_key.get(&key) {
907            Some(gi) => *gi,
908            None => {
909                let gi = groups.len();
910                groups.push(OverwriteGroup {
911                    dest: describe_sink_dest(&kind, &cfg),
912                    kind: kind.clone(),
913                    cfg,
914                });
915                index_by_key.insert(key, gi);
916                gi
917            }
918        };
919        task_group.insert((unit.node.id.clone(), unit.parent_record_key.clone()), gi);
920    }
921    Ok((groups, task_group))
922}
923
924#[allow(clippy::too_many_arguments)]
925async fn run_unit(
926    unit: &Unit,
927    capture: Option<Arc<Projection>>,
928    captured: &CapturedRecords,
929    discovered: &DiscoveredDims,
930    collected: &CollectedDims,
931    opts: &ExecuteOptions,
932    cancel: CancellationToken,
933    suppress_overwrite: bool,
934) -> InvocationOutcome {
935    // A discovery row (#501) enumerates a value-set instead of running a
936    // source→sink pipeline: build its source, drain it, project + dedup, and
937    // publish the dimension for dependent `for_each` rows. No sink, no capture.
938    // A chained discovery (#531) additionally resolves its per-tuple `${dim}`
939    // tokens and, with `collect`, publishes one list per tuple.
940    if let NodeRole::Discovery {
941        select,
942        as_alias,
943        collect,
944        dims,
945    } = &unit.node.role
946    {
947        return run_discovery(
948            &unit.node,
949            select,
950            as_alias,
951            *collect,
952            dims,
953            unit.product_ctx.as_ref(),
954            discovered,
955            collected,
956            opts,
957        )
958        .await;
959    }
960    let needs_capture = capture.is_some();
961    let started = std::time::Instant::now();
962    let result = run_one_invocation(
963        &unit.node,
964        unit.parent_record.as_deref(),
965        unit.product_ctx.as_ref(),
966        &unit.state_key,
967        capture,
968        opts,
969        cancel,
970        suppress_overwrite,
971    )
972    .await;
973    let duration_ms = started.elapsed().as_millis() as u64;
974    let row_id = unit.node.id.clone();
975    let parent_record_key = unit.parent_record_key.clone();
976    let base_metrics = || InvocationMetrics {
977        source_kind: unit.node.source.kind.clone(),
978        sink_kind: unit.node.sink.kind.clone(),
979        duration_ms,
980        ..Default::default()
981    };
982    match result {
983        Ok((records, stats)) => {
984            if needs_capture {
985                captured
986                    .lock()
987                    .await
988                    .entry(row_id.clone())
989                    .or_default()
990                    // Move each record into an `Arc` once here; downstream
991                    // per-level / per-unit hand-offs then clone only the pointer.
992                    .extend(records.into_iter().map(Arc::new));
993            }
994            InvocationOutcome {
995                row_id,
996                parent_record_key,
997                records_written: stats.records_written,
998                error: None,
999                metrics: Some(InvocationMetrics {
1000                    records_read: stats.records_read,
1001                    dlq_count: stats.dlq_count,
1002                    bookmark: stats.bookmark,
1003                    ..base_metrics()
1004                }),
1005            }
1006        }
1007        Err(e) => InvocationOutcome {
1008            row_id,
1009            parent_record_key,
1010            records_written: 0,
1011            error: Some(e.to_string()),
1012            metrics: Some(base_metrics()),
1013        },
1014    }
1015}
1016
1017/// Run a discovery row (#501): build its source, drain it, project `select`,
1018/// dedup, and publish the dimension for dependent `for_each` rows. No sink is
1019/// built and nothing is written — the outcome records the enumerated count.
1020#[allow(clippy::too_many_arguments)]
1021async fn run_discovery(
1022    node: &ExpandedNode,
1023    select: &str,
1024    as_alias: &str,
1025    collect: bool,
1026    dims: &[String],
1027    product_ctx: Option<&HashMap<String, Value>>,
1028    discovered: &DiscoveredDims,
1029    collected: &CollectedDims,
1030    opts: &ExecuteOptions,
1031) -> InvocationOutcome {
1032    let started = std::time::Instant::now();
1033    let source_kind = node.source.kind.clone();
1034    let result: CliResult<usize> = async {
1035        let mut cfg = node.source.config.clone();
1036        // `${now.*}` resolves for a discovery source like any other; `${vars}`
1037        // and shared `auth: { ref }` are already handled by the registry build.
1038        resolve_now_inplace(&mut cfg, opts.clock)?;
1039        // Chained discovery (#531): resolve the upstream tuple `${dim}` tokens in
1040        // the source config (e.g. `/crm/v3/properties/${types.name}`).
1041        if let Some(pc) = product_ctx {
1042            resolve_inplace(&mut cfg, pc)?;
1043        }
1044        let source = build_source(
1045            &source_kind,
1046            cfg,
1047            &opts.auth,
1048            opts.resilience.as_ref().map(|r| &r.retry),
1049        )
1050        .await?;
1051        let records = source.fetch_all().await?;
1052        let values = crate::discovery_matrix::project_dedup(&records, select);
1053        let n = values.len();
1054        if collect {
1055            // Publish one list per upstream tuple, keyed by the tuple, so a
1056            // consuming `Product` row injects the whole list into one request.
1057            let pc = product_ctx.cloned().unwrap_or_default();
1058            let key = crate::discovery_matrix::collected_tuple_key(dims, &pc);
1059            let mut cmap = collected.lock().await;
1060            let entry = cmap.entry(node.id.clone()).or_insert_with(|| {
1061                crate::discovery_matrix::CollectedDim {
1062                    id: node.id.clone(),
1063                    alias: as_alias.to_string(),
1064                    dims: dims.to_vec(),
1065                    by_tuple: HashMap::new(),
1066                }
1067            });
1068            entry.by_tuple.insert(key, values);
1069        } else {
1070            discovered.lock().await.insert(
1071                node.id.clone(),
1072                crate::discovery_matrix::Dim {
1073                    id: node.id.clone(),
1074                    alias: as_alias.to_string(),
1075                    values,
1076                },
1077            );
1078        }
1079        Ok(n)
1080    }
1081    .await;
1082    let duration_ms = started.elapsed().as_millis() as u64;
1083    let metrics = |records_read: usize| InvocationMetrics {
1084        source_kind: source_kind.clone(),
1085        sink_kind: String::new(),
1086        duration_ms,
1087        records_read: Some(records_read as u64),
1088        ..Default::default()
1089    };
1090    match result {
1091        Ok(n) => {
1092            tracing::info!(row = %node.id, values = n, "discovery dimension enumerated");
1093            InvocationOutcome {
1094                row_id: node.id.clone(),
1095                parent_record_key: None,
1096                records_written: 0,
1097                error: None,
1098                metrics: Some(metrics(n)),
1099            }
1100        }
1101        Err(e) => InvocationOutcome {
1102            row_id: node.id.clone(),
1103            parent_record_key: None,
1104            records_written: 0,
1105            error: Some(e.to_string()),
1106            metrics: Some(metrics(0)),
1107        },
1108    }
1109}
1110
1111/// Produce `{pipeline_name}::{row_id}` or `{pipeline_name}::{row_id}::{key}`.
1112pub(crate) fn build_state_key(
1113    pipeline_name: &str,
1114    row_id: &str,
1115    parent_key: Option<&str>,
1116) -> String {
1117    match parent_key {
1118        None => format!("{pipeline_name}::{row_id}"),
1119        Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
1120    }
1121}
1122
1123/// Reject an invalid state key up front (at unit construction) when the node
1124/// will use a state store, so a bad pipeline name or parent-key value surfaces
1125/// as a clear [`CliError::InvalidStateKey`] instead of a late mid-run
1126/// `FaucetError::State` after connectors are built and the stream has started.
1127fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
1128    if uses_state {
1129        faucet_core::state::validate_state_key(state_key).map_err(|e| {
1130            CliError::InvalidStateKey {
1131                id: node_id.to_owned(),
1132                state_key: state_key.to_owned(),
1133                reason: e.to_string(),
1134            }
1135        })?;
1136    }
1137    Ok(())
1138}
1139
1140/// Walk the parent record by `parent_key` (a dotted path) and clone the value.
1141fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
1142    let mut cur = record;
1143    for segment in parent_key.split('.') {
1144        cur = match cur {
1145            Value::Object(m) => m.get(segment)?,
1146            Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
1147            _ => return None,
1148        };
1149    }
1150    Some(cur.clone())
1151}
1152
1153/// What to keep from each of a parent's records when capturing for fan-out.
1154/// Projecting to only the fields children reference bounds orchestrator memory
1155/// at O(referenced-fields × N) instead of O(full-record × N) (#160).
1156#[derive(Debug, Clone)]
1157enum Projection {
1158    /// Keep the whole record — a child referenced `${parent}` (the entire record)
1159    /// or used an empty `parent_key`, so nothing can be safely dropped.
1160    Full,
1161    /// Keep only these pre-split, non-overlapping dotted paths.
1162    Paths(Vec<Vec<String>>),
1163}
1164
1165/// Split a dotted path into segments.
1166fn split_path(path: &str) -> Vec<String> {
1167    path.split('.').map(|s| s.to_string()).collect()
1168}
1169
1170/// Reduce a set of segment-paths to a minimal non-overlapping set: drop any path
1171/// that has a (segment-wise prefix) ancestor in the set — `["user"]` covers
1172/// `["user","name"]`. Sorting puts ancestors before their descendants.
1173fn minimal_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
1174    paths.sort();
1175    paths.dedup();
1176    let mut kept: Vec<Vec<String>> = Vec::new();
1177    for p in paths {
1178        let covered = kept
1179            .iter()
1180            .any(|anc| p.len() >= anc.len() && p[..anc.len()] == anc[..]);
1181        if !covered {
1182            kept.push(p);
1183        }
1184    }
1185    kept
1186}
1187
1188/// Resolve a pre-split dotted path against `record`, dispatching on each value's
1189/// type exactly like `resolve_parent_key` / `interpolate::resolve_dotted`.
1190fn walk_value(record: &Value, segments: &[String]) -> Option<Value> {
1191    let mut cur = record;
1192    for seg in segments {
1193        cur = match cur {
1194            Value::Object(m) => m.get(seg)?,
1195            Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
1196            _ => return None,
1197        };
1198    }
1199    Some(cur.clone())
1200}
1201
1202/// Insert `leaf` at `segments` into `out`, creating intermediate `Value::Object`
1203/// nodes keyed by the literal segment string. Callers pass non-overlapping
1204/// `segments` (see `minimal_paths`), so a node that must be an object is never
1205/// already a leaf.
1206fn graft_object(out: &mut Value, segments: &[String], leaf: Value) {
1207    if segments.is_empty() {
1208        return;
1209    }
1210    let mut cur = out;
1211    for seg in &segments[..segments.len() - 1] {
1212        let map = match cur {
1213            Value::Object(m) => m,
1214            _ => return,
1215        };
1216        cur = map
1217            .entry(seg.clone())
1218            .or_insert_with(|| Value::Object(serde_json::Map::new()));
1219    }
1220    if let Value::Object(m) = cur {
1221        m.insert(segments[segments.len() - 1].clone(), leaf);
1222    }
1223}
1224
1225/// Project `record` down to `projection`, building an all-objects reduced tree.
1226/// Because the readers (`resolve_parent_key`, `interpolate_record`) dispatch on
1227/// the reduced value's type, an array-index segment like `0` is stored — and
1228/// later read — as the object key `"0"`, so resolution matches the original.
1229fn project_record(record: &Value, projection: &Projection) -> Value {
1230    match projection {
1231        Projection::Full => record.clone(),
1232        Projection::Paths(paths) => {
1233            let mut out = Value::Object(serde_json::Map::new());
1234            for segs in paths {
1235                if let Some(v) = walk_value(record, segs) {
1236                    graft_object(&mut out, segs, v);
1237                }
1238            }
1239            out
1240        }
1241    }
1242}
1243
1244/// Compute, per parent id, what to keep from each of its records: the union of
1245/// every child's `parent_key` (the state-key path) and every `${parent.path}`
1246/// token the children reference (from their pre-collected `deferred_refs`).
1247/// Reused by the level loop to project captured records (#160).
1248fn build_projections(
1249    nodes_by_id: &HashMap<String, ExpandedNode>,
1250    children_of: &HashMap<String, Vec<String>>,
1251) -> HashMap<String, Arc<Projection>> {
1252    let mut out = HashMap::new();
1253    for (parent_id, child_ids) in children_of {
1254        let mut raw: Vec<Vec<String>> = Vec::new();
1255        let mut full = false;
1256        for cid in child_ids {
1257            let child = &nodes_by_id[cid];
1258            if let NodeRole::Child { parent_key, .. } = &child.role {
1259                if parent_key.is_empty() {
1260                    full = true;
1261                } else {
1262                    raw.push(split_path(parent_key));
1263                }
1264            }
1265            for dref in &child.deferred_refs {
1266                if dref.referenced_id == *parent_id {
1267                    if dref.dotted_path.is_empty() {
1268                        full = true; // `${parent}` — whole record
1269                    } else {
1270                        raw.push(split_path(&dref.dotted_path));
1271                    }
1272                }
1273            }
1274        }
1275        // Defensive: `raw` is empty only when every child had an empty
1276        // parent_key, which already set `full`. The `|| raw.is_empty()` keeps us
1277        // on `Full` even if that ever changes, so we never project to an empty
1278        // tree that would drop the state-key path.
1279        let projection = if full || raw.is_empty() {
1280            Projection::Full
1281        } else {
1282            Projection::Paths(minimal_paths(raw))
1283        };
1284        out.insert(parent_id.clone(), Arc::new(projection));
1285    }
1286    out
1287}
1288
1289/// Run one pipeline invocation. Returns (captured records, records_written).
1290/// Assemble the runtime [`Pipeline`] for one invocation from a node's specs.
1291///
1292/// This is the `with_*` builder chain — state store, DLQ, cancellation, quality,
1293/// contract, masking, schema-drift, adaptive batching, resilience, and delivery
1294/// mode — lifted out of [`run_one_invocation`] so the wiring is testable in
1295/// isolation and a preview-mode / feature-gate mistake (cf. #321 H1) is harder
1296/// to make (#324 C). Behaviour is identical to the previous inline chain; the
1297/// only I/O it performs is building the DLQ sink. `source`/`sink` are borrowed
1298/// for the returned pipeline's lifetime; `state` is moved in.
1299#[allow(clippy::too_many_arguments)]
1300async fn build_pipeline<'a>(
1301    source: &'a dyn Source,
1302    sink: &'a dyn Sink,
1303    node: &ExpandedNode,
1304    opts: &ExecuteOptions,
1305    state: Option<Arc<dyn StateStore>>,
1306    cancel: &CancellationToken,
1307    pipeline_name: &str,
1308    row_id: &str,
1309    run_id: &str,
1310    cleanup_scope: Option<Value>,
1311    suppress_overwrite: bool,
1312) -> CliResult<Pipeline<'a, dyn Source + 'a, dyn Sink + 'a>> {
1313    let mut pipeline = Pipeline::new(source, sink)
1314        .with_name(pipeline_name.to_owned())
1315        .with_row(row_id.to_owned())
1316        .with_run_id(run_id.to_owned());
1317    if let Some(store) = state {
1318        pipeline = pipeline.with_state_store(store);
1319    }
1320    if let Some(ref dlq_spec) = node.dlq {
1321        let dlq_cfg = build_dlq_config(dlq_spec).await?;
1322        pipeline = pipeline.with_dlq(dlq_cfg);
1323    }
1324    // Cooperative cancellation: a cancelled token makes the streaming loop stop
1325    // at the next page boundary and flush the sink (#146 H16). The pipeline takes
1326    // a clone so the caller's lineage terminal-event classification and SLA pass
1327    // can still read `cancel.is_cancelled()` (cheap — the token is an `Arc`).
1328    pipeline = pipeline.with_cancel(cancel.clone());
1329    // Pipeline-level quality checks (v1: no matrix-row override). `expand` already
1330    // validated this spec, but compile again here to obtain the runtime
1331    // `CompiledQuality`; map any error to a config-level failure.
1332    #[cfg(feature = "quality")]
1333    if let Some(ref quality_spec) = node.quality {
1334        let compiled = Arc::new(
1335            faucet_core::CompiledQuality::compile(quality_spec)
1336                .map_err(|e| CliError::Config(format!("quality: {e}")))?,
1337        );
1338        pipeline = pipeline.with_quality(compiled);
1339    }
1340    // Pipeline-level data contract (v1: no matrix-row override). `expand` already
1341    // validated the spec; compile again here to obtain the runtime
1342    // `CompiledContract`.
1343    #[cfg(feature = "contract")]
1344    if let Some(ref contract_spec) = node.contract {
1345        let compiled = Arc::new(
1346            faucet_core::CompiledContract::compile(contract_spec)
1347                .map_err(|e| CliError::Config(format!("contract: {e}")))?,
1348        );
1349        pipeline = pipeline.with_contract(compiled);
1350    }
1351    // Pipeline-level PII masking (v1: no matrix-row override). Compile scoped to
1352    // this node's destination sink — by template name (`sink_ref`) and by
1353    // connector kind — so `applies_to` per-destination rules resolve. When no
1354    // rule applies to this sink the compiled policy is empty and the pass is
1355    // skipped entirely.
1356    #[cfg(feature = "masking")]
1357    if let Some(ref masking_spec) = node.masking {
1358        let sink_ids = [node.sink_ref.as_str(), node.sink.kind.as_str()];
1359        let compiled = faucet_core::CompiledMasking::compile_for_sink(masking_spec, &sink_ids)
1360            .map_err(|e| CliError::Config(format!("masking: {e}")))?;
1361        if !compiled.is_empty() {
1362            pipeline = pipeline.with_masking(Arc::new(compiled));
1363        }
1364    }
1365    // Schema-drift policy (pipeline-level in v1; same for every invocation).
1366    if let Some(ref sd) = node.schema {
1367        pipeline = pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd));
1368    }
1369    // ── Scoped cleanup (#478) ────────────────────────────────────────────────
1370    // Attaching the policy is what authorises a delete, so the coarse gating
1371    // lives here — `run_stream` only adds "the run finished uncancelled".
1372    //
1373    //  - Roots only: a child fans out per parent record, and each invocation
1374    //    claims its own scope, which is exactly right — but a *non-root* here
1375    //    means a child, which is still a root-of-its-own-scope, so the real
1376    //    exclusions are the synthetic runs below.
1377    //  - `--dry-run` replaces the sink with a counter and `--limit` wraps it to
1378    //    drop records: under either, "what this run wrote" is a fiction, so a
1379    //    delete computed from it would remove live rows.
1380    //  - A shard reads a fraction of the source, so its written-key set is
1381    //    incomplete by construction — deleting the difference would delete the
1382    //    other shards' rows.
1383    if let Some(scope) = cleanup_scope {
1384        let synthetic = opts.dry_run || opts.limit.is_some() || opts.shard.is_some();
1385        if synthetic {
1386            tracing::warn!(
1387                row = %row_id,
1388                "scoped cleanup skipped: --dry-run / --limit / shard runs do not write the \
1389                 authoritative record set for the scope, so a delete would remove live rows"
1390            );
1391        } else {
1392            let map: std::collections::BTreeMap<String, Value> = scope
1393                .as_object()
1394                .map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
1395                .unwrap_or_default();
1396            let key: Vec<String> = node
1397                .sink
1398                .config
1399                .get("key")
1400                .and_then(|v| v.as_array())
1401                .map(|a| {
1402                    a.iter()
1403                        .filter_map(|v| v.as_str().map(str::to_owned))
1404                        .collect()
1405                })
1406                .unwrap_or_default();
1407            let policy = faucet_core::CleanupPolicy::new(map, key, faucet_core::DEFAULT_MAX_KEYS)
1408                .map_err(|e| CliError::Config(format!("cleanup: {e}")))?;
1409            pipeline = pipeline.with_cleanup(Arc::new(policy));
1410        }
1411    }
1412    // Execution-level adaptive batch-size controller (shared by all rows).
1413    if let Some(ab) = opts
1414        .execution
1415        .as_ref()
1416        .and_then(|e| e.adaptive_batch_size.clone())
1417    {
1418        ab.validate()
1419            .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
1420        pipeline = pipeline.with_adaptive(ab);
1421    }
1422    // Resilience policy (retry/backoff/circuit-breaker/poison). Top-level in v1,
1423    // so the same policy is attached to every invocation.
1424    if let Some(policy) = opts.resilience.clone() {
1425        pipeline = pipeline.with_resilience(policy);
1426    }
1427    // Delivery guarantee (exactly-once resume/skip when the node opted in; the
1428    // expand gate already verified source/sink/state support). Preview modes
1429    // (`--dry-run`, `--limit`) swap in counting/truncating sinks that cannot
1430    // uphold an atomic watermark — and a token committed for a truncated page
1431    // would corrupt it — so they always run at-least-once.
1432    let effective_delivery = if opts.dry_run || opts.limit.is_some() {
1433        faucet_core::idempotency::DeliveryMode::AtLeastOnce
1434    } else {
1435        node.delivery
1436    };
1437    pipeline = pipeline.with_delivery(effective_delivery);
1438    // #552: when this invocation is one of several fanning out into a shared
1439    // `write_mode: overwrite` destination, the executor owns the truncate/swap
1440    // lifecycle across the whole group — the pipeline must not begin/commit it
1441    // per invocation (which would leave only the last parent's rows).
1442    pipeline = pipeline.with_suppress_overwrite(suppress_overwrite);
1443    Ok(pipeline)
1444}
1445
1446#[allow(clippy::too_many_arguments)]
1447async fn run_one_invocation(
1448    node: &ExpandedNode,
1449    parent_record: Option<&Value>,
1450    product_ctx: Option<&HashMap<String, Value>>,
1451    state_key: &str,
1452    capture: Option<Arc<Projection>>,
1453    opts: &ExecuteOptions,
1454    cancel: CancellationToken,
1455    suppress_overwrite: bool,
1456) -> CliResult<(Vec<Value>, PipelineStats)> {
1457    // Observability identity for this invocation — built once, reused by both
1458    // the Pipeline builder and the transform instrumentation.
1459    let run_id = uuid::Uuid::now_v7().to_string();
1460    // Notification run identity + timing (#480). `run_id` here correlates to the
1461    // *submission* (serve passes the id it returned from `POST /v1/runs`);
1462    // `invocation_id` is this row's own id, so a matrix run's notifications are
1463    // still individually identifiable. Monotonic `Instant` for the duration —
1464    // subtracting the wall-clock stamps would go negative across an NTP step.
1465    #[cfg(feature = "notify")]
1466    let invocation_started = std::time::Instant::now();
1467    #[cfg(feature = "notify")]
1468    let notify_run = crate::notify::RunContext::start(
1469        Some(opts.run_id.clone().unwrap_or_else(|| run_id.clone())),
1470        Some(run_id.clone()),
1471    );
1472    let pipeline_name = opts.pipeline_name.clone();
1473    let row_id = node.id.clone();
1474    #[cfg(feature = "lineage")]
1475    let lineage = opts.lineage.clone();
1476    #[cfg(feature = "lineage")]
1477    let lineage_cfg = opts.lineage_cfg.clone();
1478    let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
1479    // Whether this invocation records into the Data Movement Catalog (#279):
1480    // roots only, and never for dry-run / --limit / shard runs (their volumes
1481    // and datasets are partial or synthetic) — the same scoping as SLA.
1482    #[cfg(feature = "catalog")]
1483    let catalog_active = opts.catalog.is_some()
1484        && matches!(node.role, NodeRole::Root)
1485        && !opts.dry_run
1486        && opts.limit.is_none()
1487        && opts.shard.is_none();
1488    // 1) Resolve `${parent.path}` in the per-row source + sink configs.
1489    let mut source_cfg = node.source.config.clone();
1490    let mut sink_cfg = node.sink.config.clone();
1491
1492    // Resolve `${now.*}` run-clock tokens for every invocation (root + child),
1493    // before the parent-record pass. Leaves all other tokens verbatim.
1494    resolve_now_inplace(&mut source_cfg, opts.clock)?;
1495    resolve_now_inplace(&mut sink_cfg, opts.clock)?;
1496    // `${backfill.*}` tokens are substituted by the `faucet backfill`
1497    // orchestrator before nodes reach the executor. One still present here
1498    // means a window-scoped config is being driven by `run`/`schedule`/
1499    // `serve` — fail loudly instead of handing the literal token to the
1500    // connector (#282).
1501    reject_unresolved_backfill_tokens(&source_cfg, "source")?;
1502    reject_unresolved_backfill_tokens(&sink_cfg, "sink")?;
1503
1504    // Scoped-cleanup claim (#478). Resolved through the *same* token passes as
1505    // the connector configs — the scope is typically `${parent.id}` on a child
1506    // row, so it has to see the parent record exactly like the source URL does.
1507    let mut cleanup_scope: Option<Value> = node
1508        .cleanup_scope
1509        .as_ref()
1510        .map(|m| Value::Object(m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()));
1511    if let Some(scope) = cleanup_scope.as_mut() {
1512        resolve_now_inplace(scope, opts.clock)?;
1513        reject_unresolved_backfill_tokens(scope, "complete_for")?;
1514    }
1515
1516    // Runtime fan-out context. A `parent:` child sees `{parent_id: record}`;
1517    // a discovery-driven `for_each` row (#501) sees `{dim_id: {alias: value}}`
1518    // for each dimension in its product tuple. Both resolve `${id.path}` tokens
1519    // through the same `interpolate_record` path. Kept in function scope so the
1520    // transform-compile step below can resolve the same tokens in a `set`
1521    // transform's values (#568).
1522    let mut fanout_ctx: HashMap<String, Value> = HashMap::new();
1523    if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
1524        fanout_ctx.insert(parent_id.clone(), record.clone());
1525    }
1526    if let Some(pc) = product_ctx {
1527        for (k, v) in pc {
1528            fanout_ctx.insert(k.clone(), v.clone());
1529        }
1530    }
1531    if !fanout_ctx.is_empty() {
1532        resolve_inplace(&mut source_cfg, &fanout_ctx)?;
1533        resolve_inplace(&mut sink_cfg, &fanout_ctx)?;
1534        if let Some(scope) = cleanup_scope.as_mut() {
1535            resolve_inplace(scope, &fanout_ctx)?;
1536        }
1537    }
1538
1539    // 2) Build source + sink. `faucet dlq replay` injects a pre-built source
1540    //    (an envelope-unwrapping DLQ reader) via `source_override`; every
1541    //    config-driven node builds from the connector registry. The override
1542    //    is taken once — replay runs a single invocation.
1543    let source = match node.source_override.as_ref().and_then(|o| o.take()) {
1544        Some(prebuilt) => prebuilt,
1545        None => {
1546            build_source(
1547                &node.source.kind,
1548                source_cfg,
1549                &opts.auth,
1550                opts.resilience.as_ref().map(|r| &r.retry),
1551            )
1552            .await?
1553        }
1554    };
1555
1556    // Catalog identity (#279): read the dataset URIs off the *raw* connectors,
1557    // before any wrapper is layered on.
1558    #[cfg(feature = "catalog")]
1559    let source_dataset_uri = source.dataset_uri();
1560
1561    // Clustered Mode B: narrow the raw source to its assigned shard BEFORE any
1562    // wrapping (the TransformingSource / StateKeyOverride wrappers do not forward
1563    // apply_shard, so it must reach the concrete connector).
1564    if let Some(shard) = &opts.shard {
1565        source
1566            .apply_shard(shard)
1567            .await
1568            .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
1569    }
1570    let raw_sink: Box<dyn Sink> = if opts.dry_run {
1571        Box::new(CountingSink::new())
1572    } else {
1573        build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
1574    };
1575    #[cfg(feature = "catalog")]
1576    let sink_dataset_uri = raw_sink.dataset_uri();
1577    let raw_sink: Box<dyn Sink> = match opts.limit {
1578        Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
1579        None => raw_sink,
1580    };
1581    let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
1582    let sink: Box<dyn Sink> = match &capture {
1583        Some(projection) => Box::new(CapturingSink::wrap(
1584            raw_sink,
1585            Arc::clone(&captured),
1586            Arc::clone(projection),
1587        )),
1588        None => raw_sink,
1589    };
1590
1591    // ── Lineage: sampling wrappers (only for requested facets) ───────────────
1592    // `in_sample` taps the source's pre-transform records (input schema /
1593    // column lineage); `out_sample` taps the sink's written records (output
1594    // schema / RUNNING-heartbeat throughput counter). Both stay `None` when no
1595    // facet/event needs them, so lineage adds zero per-record overhead.
1596    #[cfg(feature = "lineage")]
1597    let (in_sample, out_sample) = {
1598        use std::sync::Arc as StdArc;
1599        let mut want = false;
1600        let mut cap = 0usize;
1601        if let (Some(_), Some(lc)) = (&lineage, &lineage_cfg) {
1602            let want_schema = lc.include_schema_facet || lc.include_column_lineage;
1603            if want_schema {
1604                cap = cap.max(lc.sample_records);
1605            }
1606            want = want_schema || lc.emit_on.running;
1607        }
1608        // The catalog (#279) needs input/output samples for schema inference
1609        // and record counts regardless of any `lineage:` block; take the max
1610        // of both caps when both are active.
1611        #[cfg(feature = "catalog")]
1612        if catalog_active {
1613            want = true;
1614            cap = cap.max(
1615                opts.catalog
1616                    .as_ref()
1617                    .map(|h| h.sample_records)
1618                    .unwrap_or(crate::catalog::DEFAULT_SAMPLE_RECORDS),
1619            );
1620        }
1621        if want {
1622            (
1623                Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1624                Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1625            )
1626        } else {
1627            (None, None)
1628        }
1629    };
1630
1631    // Wrap the raw source so it samples PRE-transform input records — this must
1632    // sit between `build_source` and `TransformingSource`.
1633    #[cfg(feature = "lineage")]
1634    let source: Box<dyn Source> = match &in_sample {
1635        Some(state) => Box::new(faucet_lineage::SamplingSource::new(
1636            source,
1637            std::sync::Arc::clone(state),
1638        )),
1639        None => source,
1640    };
1641
1642    // 3) Compile transforms. Resolve `${now.*}` run-clock tokens and, on a
1643    //    child / discovery fan-out, `${parent.*}` record tokens in each
1644    //    transform's config first — exactly as for source/sink above — so e.g. a
1645    //    `set` transform stamping `${now.date}` or `${company.company_id}` writes
1646    //    the real value instead of the literal token string (#568). Without this
1647    //    the token leaks into every record.
1648    let mut transforms = node.transforms.clone();
1649    for t in &mut transforms {
1650        resolve_now_inplace(&mut t.config, opts.clock)?;
1651        if !fanout_ctx.is_empty() {
1652            resolve_inplace(&mut t.config, &fanout_ctx)?;
1653        }
1654    }
1655    // With `arrow`, compile the columnar batch forms too so an all-columnar
1656    // chain (e.g. `parquet → sql → parquet`) runs on the Arrow fast path; a
1657    // `Value`-only stage in the chain leaves `batch_fns` with a `None` entry,
1658    // which keeps the whole pipeline on the `Value` path (#375).
1659    #[cfg(feature = "arrow")]
1660    let source: Box<dyn Source> = {
1661        let (stages, batch_fns) = crate::transforms::compile_transforms_columnar(&transforms)?;
1662        if stages.is_empty() {
1663            source
1664        } else {
1665            Box::new(faucet_core::TransformingSource::new_with_batches(
1666                source,
1667                stages,
1668                batch_fns,
1669                obs_labels.clone(),
1670            )?)
1671        }
1672    };
1673    #[cfg(not(feature = "arrow"))]
1674    let source: Box<dyn Source> = {
1675        let stages = crate::transforms::compile_transforms(&transforms)?;
1676        if stages.is_empty() {
1677            source
1678        } else {
1679            Box::new(faucet_core::TransformingSource::new(
1680                source,
1681                stages,
1682                obs_labels.clone(),
1683            )?)
1684        }
1685    };
1686
1687    // 4) Build state store. If the source opts into state, wrap it so the
1688    //    executor's per-row state key is used instead of the source's natural
1689    //    one (which is shared across all matrix rows of the same kind).
1690    let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
1691    // Preview modes must not persist bookmarks: the counting/truncating sinks
1692    // return `Ok` without a real write, so a persisted (advanced) bookmark would
1693    // make the next real run skip unwritten records (#321 H1). Wrap the store so
1694    // reads still resume faithfully but writes are dropped.
1695    let state: Option<Arc<dyn StateStore>> = match state {
1696        Some(inner) if opts.dry_run || opts.limit.is_some() => {
1697            Some(Arc::new(ReadOnlyStateStore { inner }))
1698        }
1699        other => other,
1700    };
1701    // Keep a handle for the post-run SLA pass (#202) — `state` itself is moved
1702    // into the pipeline below.
1703    let sla_store = state.clone();
1704    // Per-shard bookmark: suffix the state key with the shard id so a reassigned
1705    // shard resumes where its dead owner left off, independent of sibling shards.
1706    let effective_state_key = match &opts.shard {
1707        Some(shard) => format!("{state_key}::{}", shard.id),
1708        None => state_key.to_owned(),
1709    };
1710    let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
1711        Box::new(StateKeyOverride {
1712            inner: source,
1713            key: effective_state_key,
1714        })
1715    } else {
1716        source
1717    };
1718
1719    // #510: stamp `_faucet_*` run/lineage metadata columns closest to the real
1720    // sink (so `loaded_at` ≈ the actual write), and inside the lineage sampler
1721    // below so samples reflect the source records, not the added columns.
1722    let sink: Box<dyn Sink> = match &node.metadata_columns {
1723        Some(spec) => match faucet_core::CompiledMetadata::compile(spec)
1724            .map_err(|e| CliError::Config(format!("metadata_columns: {e}")))?
1725        {
1726            Some(meta) => Box::new(faucet_core::MetadataSink::new(
1727                sink,
1728                meta,
1729                faucet_core::MetadataContext {
1730                    run_id: run_id.clone(),
1731                    source: node.source.kind.clone(),
1732                },
1733            )),
1734            None => sink,
1735        },
1736        None => sink,
1737    };
1738
1739    // Wrap the sink so it samples written records — outermost, immediately
1740    // before the pipeline is constructed (after capture/limit wrappers).
1741    #[cfg(feature = "lineage")]
1742    let sink: Box<dyn Sink> = match &out_sample {
1743        Some(state) => Box::new(faucet_lineage::SamplingSink::new(
1744            sink,
1745            std::sync::Arc::clone(state),
1746        )),
1747        None => sink,
1748    };
1749
1750    // 5) Assemble the runtime pipeline. The whole `with_*` builder chain (state,
1751    //    DLQ, cancellation, quality, contract, masking, schema-drift, adaptive
1752    //    batching, resilience, delivery) lives in `build_pipeline` so the wiring
1753    //    is testable in isolation and a preview-mode / feature-gate mistake
1754    //    (cf. #321 H1) is harder to make (#324 C). The identity strings are
1755    //    borrowed — the lineage START/terminal lifecycle below still needs
1756    //    `pipeline_name` / `row_id` / `run_id`.
1757    let pipeline = build_pipeline(
1758        source.as_ref(),
1759        sink.as_ref(),
1760        node,
1761        opts,
1762        state,
1763        &cancel,
1764        &pipeline_name,
1765        &row_id,
1766        &run_id,
1767        cleanup_scope,
1768        suppress_overwrite,
1769    )
1770    .await?;
1771    // ── Lineage: START + heartbeat + terminal ────────────────────────────────
1772    #[cfg(feature = "lineage")]
1773    let lineage_ctx = match (&lineage, &lineage_cfg) {
1774        (Some(em), Some(lc)) => {
1775            let job_name =
1776                crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
1777            let mut ctx = faucet_lineage::RunLifecycle {
1778                job_namespace: lc.namespace.clone(),
1779                job_name,
1780                run_id: run_id.clone(),
1781                parent: lc.parent_job.clone(),
1782                inputs: vec![faucet_lineage::DatasetRef {
1783                    namespace: lc.namespace.clone(),
1784                    name: source.dataset_uri(),
1785                }],
1786                output: faucet_lineage::DatasetRef {
1787                    namespace: lc.namespace.clone(),
1788                    name: sink.dataset_uri(),
1789                },
1790                started_at: chrono::Utc::now(),
1791                finished_at: None,
1792                records: 0,
1793                error: None,
1794                input_schemas: Vec::new(),
1795                output_schema: None,
1796                column_lineage: None,
1797                source_code: None,
1798            };
1799            em.emit(faucet_lineage::EventType::Start, &ctx).await;
1800            // Heartbeat task — periodic RUNNING events with the live throughput
1801            // count read off the output sampler.
1802            let hb_handle = if lc.emit_on.running {
1803                let em2 = std::sync::Arc::clone(em);
1804                let interval = lc.heartbeat_interval;
1805                let mut beat_ctx = ctx.clone();
1806                let counter = out_sample.clone();
1807                Some(tokio::spawn(async move {
1808                    let mut tick = tokio::time::interval(interval);
1809                    tick.tick().await; // skip the immediate first tick
1810                    loop {
1811                        tick.tick().await;
1812                        if let Some(c) = &counter {
1813                            beat_ctx.records = c.count();
1814                        }
1815                        em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
1816                            .await;
1817                    }
1818                }))
1819            } else {
1820                None
1821            };
1822            ctx.source_code = if lc.include_source_code_facet {
1823                Some(serde_json::to_string(&node.source.config).unwrap_or_default())
1824            } else {
1825                None
1826            };
1827            Some((std::sync::Arc::clone(em), ctx, hb_handle))
1828        }
1829        _ => None,
1830    };
1831
1832    // Combine run + final flush into one outcome BEFORE emitting the terminal
1833    // lineage event, preserving the original semantics (run error → skip flush;
1834    // run ok but flush error → overall error). The terminal event is classified
1835    // from this combined `result`, then `?`-propagated below — restoring the
1836    // original early-return behaviour while still firing the terminal event on
1837    // both success and error.
1838    let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1839        Ok(r) => sink.flush().await.map(|_| r),
1840        Err(e) => Err(e),
1841    };
1842
1843    // ── Completeness reconciliation (#502) ───────────────────────────────────
1844    // On a successful root run, compare rows written against an authoritative
1845    // count probe; a shortfall beyond tolerance turns the run into an error
1846    // (silent-truncation guard). Runs BEFORE the lineage/SLA/notify passes so
1847    // they all observe the failure. Same root/real-run scoping as SLA: skipped
1848    // for dry-run / --limit / shard / cancelled runs.
1849    let result = match (&result, &opts.reconcile) {
1850        (Ok(r), Some(spec))
1851            if matches!(node.role, NodeRole::Root)
1852                && !opts.dry_run
1853                && opts.limit.is_none()
1854                && opts.shard.is_none()
1855                && !cancel.is_cancelled() =>
1856        {
1857            let written = r.records_written as u64;
1858            match crate::reconcile::run(spec, &opts.auth, written).await {
1859                Ok(()) => result,
1860                Err(e) => Err(e),
1861            }
1862        }
1863        _ => result,
1864    };
1865
1866    #[cfg(feature = "lineage")]
1867    if let Some((em, mut ctx, hb)) = lineage_ctx {
1868        if let Some(h) = hb {
1869            h.abort();
1870        }
1871        ctx.finished_at = Some(chrono::Utc::now());
1872        if let Some(state) = &out_sample {
1873            ctx.records = state.count();
1874            if lineage_cfg
1875                .as_ref()
1876                .map(|l| l.include_schema_facet)
1877                .unwrap_or(false)
1878            {
1879                ctx.output_schema = Some(state.inferred_schema());
1880            }
1881        }
1882        if let Some(state) = &in_sample
1883            && lineage_cfg
1884                .as_ref()
1885                .map(|l| l.include_schema_facet || l.include_column_lineage)
1886                .unwrap_or(false)
1887        {
1888            let in_schema = state.inferred_schema();
1889            if lineage_cfg
1890                .as_ref()
1891                .map(|l| l.include_column_lineage)
1892                .unwrap_or(false)
1893            {
1894                let input_fields: Vec<String> =
1895                    in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1896                #[cfg(feature = "masking")]
1897                let has_masking = node.masking.is_some();
1898                #[cfg(not(feature = "masking"))]
1899                let has_masking = false;
1900                let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1901                ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1902            }
1903            if lineage_cfg
1904                .as_ref()
1905                .map(|l| l.include_schema_facet)
1906                .unwrap_or(false)
1907            {
1908                ctx.input_schemas = vec![Some(in_schema)];
1909            }
1910        }
1911        let ev = match &result {
1912            Err(e) => {
1913                ctx.error = Some(e.to_string());
1914                faucet_lineage::EventType::Fail
1915            }
1916            Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1917            Ok(_) => faucet_lineage::EventType::Complete,
1918        };
1919        em.emit(ev, &ctx).await;
1920    }
1921
1922    // ── SLA post-run evaluation (#202) ───────────────────────────────────────
1923    // Roots only: children fan out per parent record, so their volumes are not
1924    // a stable series to baseline (same scoping as `faucet doctor`). Skipped
1925    // for dry-run / --limit (synthetic volumes would poison the baseline), for
1926    // shard executions (a shard's volume is a fraction of the row's, and shard
1927    // counts change run to run), and for cancelled runs (a partial volume is
1928    // not a signal). Monitoring never fails the run — see `evaluate_post_run`.
1929    // Roots only, and never for dry-run / --limit / shard / cancelled runs —
1930    // the same scoping the notification pass below reuses.
1931    let is_notifiable_root = matches!(node.role, NodeRole::Root)
1932        && !opts.dry_run
1933        && opts.limit.is_none()
1934        && opts.shard.is_none()
1935        && !cancel.is_cancelled();
1936
1937    #[cfg_attr(not(feature = "notify"), allow(unused_variables))]
1938    let sla_violations = if let Some(spec) = &opts.sla
1939        && is_notifiable_root
1940    {
1941        let outcome = match &result {
1942            Ok(r) => crate::sla::RunOutcome::Success {
1943                rows: r.records_written as u64,
1944            },
1945            Err(_) => crate::sla::RunOutcome::Failure,
1946        };
1947        crate::sla::evaluate_post_run(
1948            spec,
1949            sla_store.as_ref(),
1950            state_key,
1951            &obs_labels.pipeline,
1952            &obs_labels.row,
1953            outcome,
1954            chrono::Utc::now().timestamp(),
1955        )
1956        .await
1957    } else {
1958        Vec::new()
1959    };
1960
1961    // ── Notifications (#280) ─────────────────────────────────────────────────
1962    // Fan run success/failure, SLA breach, circuit-open, contract-abort, and
1963    // DLQ-threshold out to the configured channels. Same root/real-run scoping
1964    // as SLA; delivery is fire-and-forget and never fails the run.
1965    #[cfg(feature = "notify")]
1966    if let Some(notifier) = &opts.notifier
1967        && is_notifiable_root
1968    {
1969        use crate::notify::NotifyEvent;
1970        let pipeline = obs_labels.pipeline.to_string();
1971        let row = obs_labels.row.to_string();
1972        // Closed once here, so every event from this invocation reports the same
1973        // terminal timestamp and duration.
1974        let run_ctx = notify_run.clone().finish(invocation_started);
1975        match &result {
1976            Ok(r) => {
1977                notifier
1978                    .emit(
1979                        NotifyEvent::run_success(
1980                            pipeline.clone(),
1981                            row.clone(),
1982                            r.records_written as u64,
1983                        )
1984                        .with_run(run_ctx.clone()),
1985                    )
1986                    .await;
1987                if let Some(dlq) = &r.dlq
1988                    && dlq.records_dlq > 0
1989                {
1990                    notifier
1991                        .emit(
1992                            NotifyEvent::dlq_threshold(
1993                                pipeline.clone(),
1994                                row.clone(),
1995                                dlq.records_dlq as u64,
1996                            )
1997                            .with_run(run_ctx.clone()),
1998                        )
1999                        .await;
2000                }
2001            }
2002            Err(e) => {
2003                notifier
2004                    .emit(error_event(&pipeline, &row, e).with_run(run_ctx.clone()))
2005                    .await;
2006            }
2007        }
2008        for v in &sla_violations {
2009            notifier
2010                .emit(
2011                    NotifyEvent::sla_breach(pipeline.clone(), row.clone(), v.kind(), v.to_string())
2012                        .with_run(run_ctx.clone()),
2013                )
2014                .await;
2015        }
2016    }
2017
2018    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
2019    // Fold this run's dataset observations + lineage edge into the catalog.
2020    // Successful, complete root runs only (a cancelled run's partial volume is
2021    // not a signal). Recording never fails the run — see `catalog::record`.
2022    #[cfg(feature = "catalog")]
2023    if let Some(handle) = &opts.catalog
2024        && catalog_active
2025        && !cancel.is_cancelled()
2026        && let Ok(pipeline_result) = &result
2027    {
2028        use crate::catalog::model::{canonicalize_uri, schema_from_samples};
2029        use crate::serve::history::catalog::{CatalogUpdate, DatasetObservation, DatasetRole};
2030
2031        let records_written = pipeline_result.records_written as u64;
2032        let source_schema = in_sample
2033            .as_ref()
2034            .and_then(|s| schema_from_samples(&s.samples()));
2035        let sink_schema = out_sample
2036            .as_ref()
2037            .and_then(|s| schema_from_samples(&s.samples()));
2038        // The samplers are always installed while the catalog is active, so the
2039        // unwrap_or arms are defensive only.
2040        let records_read = in_sample
2041            .as_ref()
2042            .map(|s| s.count())
2043            .unwrap_or(records_written);
2044        let records_out = out_sample
2045            .as_ref()
2046            .map(|s| s.count())
2047            .unwrap_or(records_written);
2048
2049        // Column lineage for the edge — the same derivation `faucet-lineage`
2050        // emits, so the catalog's edges match the OpenLineage output.
2051        let column_lineage = in_sample.as_ref().and_then(|s| {
2052            let input_fields: Vec<String> = s
2053                .inferred_schema()
2054                .fields
2055                .iter()
2056                .map(|(n, _)| n.clone())
2057                .collect();
2058            #[cfg(feature = "masking")]
2059            let has_masking = node.masking.is_some();
2060            #[cfg(not(feature = "masking"))]
2061            let has_masking = false;
2062            let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
2063            faucet_lineage::derive_column_lineage(&input_fields, &ops).map(|cl| {
2064                // `ColumnLineage` is not `Serialize` (IndexMap); render the
2065                // stable `{"fields": {out: [in, …]}}` shape by hand.
2066                let fields: serde_json::Map<String, Value> = cl
2067                    .edges
2068                    .iter()
2069                    .map(|(out, ins)| {
2070                        (
2071                            out.clone(),
2072                            Value::Array(ins.iter().map(|s| Value::String(s.clone())).collect()),
2073                        )
2074                    })
2075                    .collect();
2076                serde_json::json!({ "fields": fields })
2077            })
2078        });
2079
2080        let update = CatalogUpdate {
2081            run_id: handle.run_id.clone().unwrap_or_else(|| run_id.clone()),
2082            pipeline: obs_labels.pipeline.to_string(),
2083            row: obs_labels.row.to_string(),
2084            recorded_at: chrono::Utc::now(),
2085            // A matrix invocation is always one source → one sink; the vector
2086            // shape exists for topology graphs, where a sink can be fed by several
2087            // (#459).
2088            sources: vec![DatasetObservation {
2089                uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
2090                kind: node.source.kind.clone(),
2091                role: DatasetRole::Source,
2092                schema: source_schema,
2093                records: records_read,
2094            }],
2095            sink: DatasetObservation {
2096                uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
2097                kind: node.sink.kind.clone(),
2098                role: DatasetRole::Sink,
2099                schema: sink_schema,
2100                records: records_out,
2101            },
2102            column_lineage,
2103        };
2104        crate::catalog::record(handle, &update).await;
2105    }
2106
2107    let result = result?;
2108
2109    // Per-invocation stats for `faucet run --output json` (#390). `records_read`
2110    // is only known when the source sampler was installed (a `lineage:` or
2111    // `catalog:` block); otherwise it stays `None` rather than guessing.
2112    #[cfg(feature = "lineage")]
2113    let records_read = in_sample.as_ref().map(|s| s.count());
2114    #[cfg(not(feature = "lineage"))]
2115    let records_read: Option<u64> = None;
2116    let stats = PipelineStats {
2117        records_written: result.records_written,
2118        records_read,
2119        dlq_count: result
2120            .dlq
2121            .as_ref()
2122            .map(|d| d.records_dlq as u64)
2123            .unwrap_or(0),
2124        bookmark: result.bookmark.clone(),
2125    };
2126
2127    let captured = if capture.is_some() {
2128        std::mem::take(&mut *captured.lock().await)
2129    } else {
2130        Vec::new()
2131    };
2132    Ok((captured, stats))
2133}
2134
2135async fn build_state_for_node(
2136    node: &ExpandedNode,
2137    state_path_override: Option<&Path>,
2138) -> CliResult<Option<Arc<dyn StateStore>>> {
2139    match (&node.state, state_path_override) {
2140        (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
2141        (None, Some(path)) => Ok(Some(state_from_override(path))),
2142        (Some(spec), Some(path)) => {
2143            if spec.kind == "file" {
2144                Ok(Some(state_from_override(path)))
2145            } else {
2146                tracing::warn!(
2147                    state = %spec.kind,
2148                    "--state-path is only meaningful for the 'file' backend; ignoring override"
2149                );
2150                Ok(Some(build_state_store(spec).await?))
2151            }
2152        }
2153        (None, None) => Ok(None),
2154    }
2155}
2156
2157fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
2158    Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
2159}
2160
2161/// Translate a [`crate::config::DlqSpec`] from the YAML/JSON config into a
2162/// runtime [`DlqConfig`] ready to attach to a [`Pipeline`].
2163pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
2164    // DLQ sinks resolve against an empty catalog — shared `auth: { ref }` on a
2165    // DLQ sink is out of scope (DLQ targets are typically local jsonl/stdout).
2166    let sink = build_sink(
2167        &spec.sink.kind,
2168        spec.sink.config.clone(),
2169        &AuthCatalog::new(),
2170    )
2171    .await?;
2172    Ok(DlqConfig {
2173        sink: Arc::from(sink),
2174        on_batch_error: match spec.on_batch_error {
2175            crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
2176            crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
2177        },
2178        max_failures_per_page: spec.max_failures_per_page,
2179        max_failures_total: spec.max_failures_total,
2180        include_original_payload: spec.include_original_payload,
2181    })
2182}
2183
2184/// Classify a pipeline error into a notification event (#280). A circuit-breaker
2185/// trip and a contract-abort breach get their own event kinds; everything else
2186/// is a generic `run_failure` carrying a short error-kind label.
2187#[cfg(feature = "notify")]
2188fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
2189    use crate::notify::NotifyEvent;
2190    match err {
2191        FaucetError::CircuitOpen { failures, cooldown } => {
2192            NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
2193        }
2194        FaucetError::ContractViolation { message, .. } => {
2195            NotifyEvent::contract_abort(pipeline, row, message.clone())
2196        }
2197        other => {
2198            NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
2199        }
2200    }
2201}
2202
2203/// Short, stable label for a `FaucetError` variant used as the `error_kind`
2204/// detail on a `run_failure` notification. (`faucet-core`'s own `error_kind`
2205/// helper is `pub(crate)`, so we keep a small CLI-side mapping.)
2206#[cfg(feature = "notify")]
2207fn faucet_error_kind(err: &FaucetError) -> &'static str {
2208    match err {
2209        FaucetError::Config(_) => "config",
2210        FaucetError::Source(_) => "source",
2211        FaucetError::Sink(_) => "sink",
2212        FaucetError::State(_) => "state",
2213        FaucetError::QualityFailure { .. } => "quality",
2214        FaucetError::SchemaDrift { .. } => "schema_drift",
2215        _ => "error",
2216    }
2217}
2218
2219/// In-place `${now.*}` resolution against the run clock. Walks every string
2220/// leaf and rewrites `${now.<token>}`; all other `${...}` tokens are untouched.
2221/// Shared with `faucet test`, which applies the same pre-pass to transform
2222/// configs under the case clock.
2223/// Error on a leftover `${backfill.*}` token: those resolve only inside
2224/// `faucet backfill`, which substitutes them per window unit before the
2225/// executor runs. Reaching here means another runtime picked up a
2226/// window-scoped config.
2227pub(crate) fn reject_unresolved_backfill_tokens(value: &Value, owner: &str) -> CliResult<()> {
2228    fn walk(value: &Value, owner: &str) -> CliResult<()> {
2229        match value {
2230            Value::String(s) if s.contains("${backfill.") => Err(CliError::Config(format!(
2231                "the {owner} config references a `${{backfill.*}}` token, which only                  `faucet backfill` resolves — run this config via `faucet backfill                  --from … --to …`, or remove the token"
2232            ))),
2233            Value::Array(a) => a.iter().try_for_each(|v| walk(v, owner)),
2234            Value::Object(m) => m.values().try_for_each(|v| walk(v, owner)),
2235            _ => Ok(()),
2236        }
2237    }
2238    walk(value, owner)
2239}
2240
2241pub(crate) fn resolve_now_inplace(
2242    value: &mut Value,
2243    clock: DateTime<FixedOffset>,
2244) -> CliResult<()> {
2245    match value {
2246        Value::String(s) => {
2247            *s = crate::interpolate::resolve_now(s, clock)?;
2248            Ok(())
2249        }
2250        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
2251        Value::Object(m) => m
2252            .values_mut()
2253            .try_for_each(|v| resolve_now_inplace(v, clock)),
2254        _ => Ok(()),
2255    }
2256}
2257
2258/// In-place runtime interpolation against a parent-record context. Walks every
2259/// string leaf in `value` and replaces `${id.path}` tokens with stringified
2260/// values from `ctx`.
2261fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
2262    match value {
2263        Value::String(s) => {
2264            let resolved = interpolate_record(s, ctx)?;
2265            *s = resolved;
2266            Ok(())
2267        }
2268        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
2269        Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
2270        _ => Ok(()),
2271    }
2272}
2273
2274// ── Adapter sinks/sources ───────────────────────────────────────────────────
2275
2276/// Wraps a [`StateStore`] so reads pass through but writes/deletes are dropped.
2277///
2278/// Attached under `--dry-run` / `--limit`: those modes swap in counting /
2279/// truncating sinks that return `Ok` without a real durable write, and
2280/// `run_stream` persists a page's bookmark after any `Ok`. Persisting an
2281/// advanced bookmark from a preview would make the next *real* run resume past
2282/// records that were never written — a `--dry-run` silently causing data loss
2283/// (audit #321 H1; for postgres-cdc it also lets Postgres recycle WAL for
2284/// undelivered changes). Reads still pass through so the preview faithfully
2285/// resumes from the existing bookmark.
2286pub(crate) struct ReadOnlyStateStore {
2287    pub(crate) inner: Arc<dyn StateStore>,
2288}
2289
2290#[async_trait]
2291impl StateStore for ReadOnlyStateStore {
2292    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
2293        self.inner.get(key).await
2294    }
2295    async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
2296        Ok(())
2297    }
2298    async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
2299        Ok(())
2300    }
2301}
2302
2303/// Wraps a source so its `state_key()` returns the executor-provided value
2304/// instead of the source's natural one. Lets every matrix invocation use a
2305/// distinct state-store entry even when the underlying source kind is shared.
2306struct StateKeyOverride {
2307    inner: Box<dyn Source>,
2308    key: String,
2309}
2310
2311#[async_trait]
2312impl Source for StateKeyOverride {
2313    async fn fetch_with_context(
2314        &self,
2315        ctx: &HashMap<String, Value>,
2316    ) -> Result<Vec<Value>, FaucetError> {
2317        self.inner.fetch_with_context(ctx).await
2318    }
2319    async fn fetch_with_context_incremental(
2320        &self,
2321        ctx: &HashMap<String, Value>,
2322    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
2323        self.inner.fetch_with_context_incremental(ctx).await
2324    }
2325    // Forward `stream_pages` so the wrapped connector's *native* page stream
2326    // survives the wrap. Without this the trait's buffering default kicks in
2327    // for every stateful run — losing per-page bookmarks (CDC per-transaction
2328    // durability, exactly-once per-page tokens) and the O(batch_size) memory
2329    // bound.
2330    fn stream_pages<'a>(
2331        &'a self,
2332        ctx: &'a HashMap<String, Value>,
2333        batch_size: usize,
2334    ) -> std::pin::Pin<
2335        Box<
2336            dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
2337                + Send
2338                + 'a,
2339        >,
2340    > {
2341        self.inner.stream_pages(ctx, batch_size)
2342    }
2343    fn connector_name(&self) -> &'static str {
2344        self.inner.connector_name()
2345    }
2346    fn dataset_uri(&self) -> String {
2347        self.inner.dataset_uri()
2348    }
2349    fn state_key(&self) -> Option<String> {
2350        Some(self.key.clone())
2351    }
2352    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
2353        self.inner.apply_start_bookmark(bookmark).await
2354    }
2355    fn supports_exactly_once(&self) -> bool {
2356        self.inner.supports_exactly_once()
2357    }
2358    fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
2359        self.inner.replay_guarantee()
2360    }
2361    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
2362        self.inner.capture_resume_position().await
2363    }
2364}
2365
2366/// Forwards each record to an inner sink while also capturing a **projected**
2367/// copy into a shared buffer for descendant rows to consume. Projecting to only
2368/// the fields children reference bounds orchestrator memory (#160).
2369struct CapturingSink {
2370    inner: Box<dyn Sink>,
2371    captured: Arc<Mutex<Vec<Value>>>,
2372    projection: Arc<Projection>,
2373}
2374
2375impl CapturingSink {
2376    fn wrap(
2377        inner: Box<dyn Sink>,
2378        captured: Arc<Mutex<Vec<Value>>>,
2379        projection: Arc<Projection>,
2380    ) -> Self {
2381        Self {
2382            inner,
2383            captured,
2384            projection,
2385        }
2386    }
2387}
2388
2389#[async_trait]
2390impl Sink for CapturingSink {
2391    fn connector_name(&self) -> &'static str {
2392        self.inner.connector_name()
2393    }
2394    fn dataset_uri(&self) -> String {
2395        self.inner.dataset_uri()
2396    }
2397    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2398        let written = self.inner.write_batch(records).await?;
2399        // Capture only what actually landed (LimitedSink may have dropped some),
2400        // projected to the fields children reference (#160).
2401        let n = written.min(records.len());
2402        let mut buf = self.captured.lock().await;
2403        buf.extend(
2404            records
2405                .iter()
2406                .take(n)
2407                .map(|r| project_record(r, &self.projection)),
2408        );
2409        Ok(written)
2410    }
2411    async fn flush(&self) -> Result<(), FaucetError> {
2412        self.inner.flush().await
2413    }
2414    // Capability + exactly-once passthroughs, so a parent row that fans out to
2415    // children (which is what this wrapper serves) keeps the inner sink's
2416    // delivery semantics instead of being masked down to the trait defaults.
2417    fn supports_idempotent_writes(&self) -> bool {
2418        self.inner.supports_idempotent_writes()
2419    }
2420    fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
2421        self.inner.sink_guarantee()
2422    }
2423    fn dedups_by_key(&self) -> bool {
2424        self.inner.dedups_by_key()
2425    }
2426    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
2427        self.inner.supported_write_modes()
2428    }
2429    async fn write_batch_idempotent(
2430        &self,
2431        records: &[Value],
2432        scope: &str,
2433        token: &str,
2434    ) -> Result<usize, FaucetError> {
2435        let written = self
2436            .inner
2437            .write_batch_idempotent(records, scope, token)
2438            .await?;
2439        let n = written.min(records.len());
2440        let mut buf = self.captured.lock().await;
2441        buf.extend(
2442            records
2443                .iter()
2444                .take(n)
2445                .map(|r| project_record(r, &self.projection)),
2446        );
2447        Ok(written)
2448    }
2449    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
2450        self.inner.last_committed_token(scope).await
2451    }
2452    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
2453        self.inner.current_schema().await
2454    }
2455    fn supports_schema_evolution(&self) -> bool {
2456        self.inner.supports_schema_evolution()
2457    }
2458    async fn evolve_schema(
2459        &self,
2460        evolution: &faucet_core::SchemaEvolution,
2461    ) -> Result<(), FaucetError> {
2462        self.inner.evolve_schema(evolution).await
2463    }
2464}
2465
2466/// Cap on records written. Each `write_batch` call truncates `records` to the
2467/// remaining budget before delegating.
2468pub(crate) struct LimitedSink {
2469    inner: Box<dyn Sink>,
2470    remaining: AtomicUsize,
2471}
2472
2473impl LimitedSink {
2474    pub(crate) fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
2475        Self {
2476            inner,
2477            remaining: AtomicUsize::new(cap),
2478        }
2479    }
2480}
2481
2482#[async_trait]
2483impl Sink for LimitedSink {
2484    fn connector_name(&self) -> &'static str {
2485        self.inner.connector_name()
2486    }
2487    fn dataset_uri(&self) -> String {
2488        self.inner.dataset_uri()
2489    }
2490    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2491        let remaining = self.remaining.load(Ordering::Relaxed);
2492        if remaining == 0 {
2493            return Ok(0);
2494        }
2495        let take = remaining.min(records.len());
2496        let slice = &records[..take];
2497        let written = self.inner.write_batch(slice).await?;
2498        self.remaining
2499            .fetch_sub(written.min(remaining), Ordering::Relaxed);
2500        Ok(written)
2501    }
2502    async fn flush(&self) -> Result<(), FaucetError> {
2503        self.inner.flush().await
2504    }
2505}
2506
2507/// No-op sink used in `--dry-run`. Counts records seen so the rest of the
2508/// pipeline (transforms, source) still runs.
2509pub(crate) struct CountingSink {
2510    seen: AtomicUsize,
2511}
2512
2513impl CountingSink {
2514    pub(crate) fn new() -> Self {
2515        Self {
2516            seen: AtomicUsize::new(0),
2517        }
2518    }
2519}
2520
2521#[async_trait]
2522impl Sink for CountingSink {
2523    fn connector_name(&self) -> &'static str {
2524        "dry-run"
2525    }
2526    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2527        self.seen.fetch_add(records.len(), Ordering::Relaxed);
2528        Ok(records.len())
2529    }
2530}
2531
2532/// Render a JSON value compactly for use as a state-key suffix or log line.
2533/// Strings pass through unquoted; numbers/bools/null/composites use to_string.
2534fn value_to_string_brief(v: &Value) -> String {
2535    match v {
2536        Value::String(s) => s.clone(),
2537        other => other.to_string(),
2538    }
2539}
2540
2541#[cfg(test)]
2542mod tests {
2543    use super::*;
2544    use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
2545    use crate::expand::expand;
2546    use serde_json::json;
2547
2548    #[tokio::test]
2549    async fn resolve_product_dims_skips_unavailable_and_rejects_oversized() {
2550        // An unavailable upstream dimension → `Ok(None)` (the row is skipped).
2551        let empty: DiscoveredDims =
2552            std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
2553        let skipped = resolve_product_dims(&["missing".to_string()], &empty, "row")
2554            .await
2555            .unwrap();
2556        assert!(skipped.is_none());
2557
2558        // A cross-product past `MAX_MATRIX_PRODUCT` (101 × 100 = 10_100) → `Err`.
2559        let dim = |id: &str, n: i64| crate::discovery_matrix::Dim {
2560            id: id.to_string(),
2561            alias: id.to_string(),
2562            values: (0..n).map(|i| json!(i)).collect(),
2563        };
2564        let dd: DiscoveredDims =
2565            std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::from([
2566                ("a".to_string(), dim("a", 101)),
2567                ("b".to_string(), dim("b", 100)),
2568            ])));
2569        let err = resolve_product_dims(&["a".to_string(), "b".to_string()], &dd, "row")
2570            .await
2571            .unwrap_err();
2572        assert!(matches!(err, CliError::Config(m) if m.contains("over the limit")));
2573    }
2574
2575    fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
2576        PipelineConfig {
2577            version: 1,
2578            name: Some("test".into()),
2579            vars: None,
2580            params: Default::default(),
2581            auth: None,
2582            pipeline: PipelineSpec {
2583                source: Some(ConnectorSpec {
2584                    kind: "csv".into(),
2585                    config: json!({"path": input.to_str().unwrap()}),
2586                    transforms: None,
2587                    inherit_transforms: true,
2588                    status: None,
2589                    tags: Vec::new(),
2590                    complete_for: None,
2591                }),
2592                sink: Some(ConnectorSpec {
2593                    kind: "jsonl".into(),
2594                    config: json!({"path": output.to_str().unwrap()}),
2595                    transforms: None,
2596                    inherit_transforms: true,
2597                    status: None,
2598                    tags: Vec::new(),
2599                    complete_for: None,
2600                }),
2601                sources: Default::default(),
2602                sinks: Default::default(),
2603                transforms: Vec::new(),
2604                state: None,
2605                dlq: None,
2606                #[cfg(feature = "quality")]
2607                quality: None,
2608                #[cfg(feature = "contract")]
2609                contract: None,
2610                #[cfg(feature = "masking")]
2611                masking: None,
2612                schema: None,
2613                nodes: std::collections::HashMap::new(),
2614                edges: Vec::new(),
2615            },
2616            matrix: Vec::new(),
2617            execution: None,
2618            metadata_columns: None,
2619            selection: None,
2620            observability: None,
2621            delivery: faucet_core::DeliveryMode::default(),
2622            resilience: None,
2623            sla: None,
2624            reconcile: None,
2625            shard: None,
2626            replication: None,
2627            backfill: None,
2628            partition: None,
2629            #[cfg(feature = "schedule")]
2630            schedule: None,
2631            #[cfg(feature = "lineage")]
2632            lineage: None,
2633            #[cfg(feature = "catalog")]
2634            catalog: None,
2635            #[cfg(feature = "notify")]
2636            notifications: Vec::new(),
2637        }
2638    }
2639
2640    #[tokio::test]
2641    async fn empty_matrix_runs_pipeline_once() {
2642        let dir = tempfile::tempdir().unwrap();
2643        let input = dir.path().join("in.csv");
2644        let output = dir.path().join("out.jsonl");
2645        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2646        let cfg = cfg_csv_to_jsonl(&input, &output);
2647        let nodes = expand(&cfg).unwrap();
2648        let summary = run_expanded(
2649            nodes,
2650            ExecuteOptions {
2651                pipeline_name: "t".into(),
2652                run_id: None,
2653                execution: None,
2654                dry_run: false,
2655                limit: None,
2656                state_path_override: None,
2657                shard: None,
2658                auth: Default::default(),
2659                clock: chrono::Utc::now().fixed_offset(),
2660                cancel: None,
2661                resilience: None,
2662                sla: None,
2663                reconcile: None,
2664                #[cfg(feature = "lineage")]
2665                lineage: None,
2666                #[cfg(feature = "lineage")]
2667                lineage_cfg: None,
2668                #[cfg(feature = "notify")]
2669                notifier: None,
2670                #[cfg(feature = "catalog")]
2671                catalog: None,
2672            },
2673        )
2674        .await
2675        .unwrap();
2676        assert_eq!(summary.invocations.len(), 1);
2677        assert_eq!(summary.invocations[0].records_written, 2);
2678        assert!(!summary.had_failures());
2679        let body = std::fs::read_to_string(&output).unwrap();
2680        assert_eq!(body.lines().count(), 2);
2681    }
2682
2683    // ── #552: child fan-out into write_mode: overwrite ──────────────────────
2684    // These exercise the executor-owned overwrite lifecycle end to end against
2685    // a real sqlite sink (the only in-process overwrite sink). Before the fix,
2686    // each per-parent invocation ran its own begin→commit, so the destination
2687    // ended with only the LAST parent's rows (silent data loss).
2688
2689    fn exec_opts(name: &str) -> ExecuteOptions {
2690        ExecuteOptions {
2691            pipeline_name: name.into(),
2692            run_id: None,
2693            execution: None,
2694            dry_run: false,
2695            limit: None,
2696            state_path_override: None,
2697            shard: None,
2698            auth: Default::default(),
2699            clock: chrono::Utc::now().fixed_offset(),
2700            cancel: None,
2701            resilience: None,
2702            sla: None,
2703            reconcile: None,
2704            #[cfg(feature = "lineage")]
2705            lineage: None,
2706            #[cfg(feature = "lineage")]
2707            lineage_cfg: None,
2708            #[cfg(feature = "notify")]
2709            notifier: None,
2710            #[cfg(feature = "catalog")]
2711            catalog: None,
2712        }
2713    }
2714
2715    async fn run_yaml(yaml: &str, path: &Path) -> RunSummary {
2716        let cfg = PipelineConfig::from_text(yaml, path).expect("config parses");
2717        let nodes = expand(&cfg).expect("config expands");
2718        run_expanded(nodes, exec_opts("ow")).await.expect("run ok")
2719    }
2720
2721    // Pre-create table `t` with a seed row (99, OLD). sqlite `auto_map` requires
2722    // the table to already exist (overwrite replaces an *existing* table), so we
2723    // create it directly — mirroring a real destination. The seed row also lets
2724    // the abort test prove the prior data survives a failed refresh.
2725    async fn seed_table(db_url: &str) {
2726        let pool = sqlx::sqlite::SqlitePoolOptions::new()
2727            .connect(&format!("{db_url}?mode=rwc"))
2728            .await
2729            .expect("open sqlite");
2730        sqlx::query("CREATE TABLE t (id TEXT, v TEXT)")
2731            .execute(&pool)
2732            .await
2733            .expect("create table");
2734        sqlx::query("INSERT INTO t (id, v) VALUES ('99', 'OLD')")
2735            .execute(&pool)
2736            .await
2737            .expect("seed row");
2738        pool.close().await;
2739    }
2740
2741    /// Read the `v` column of every row of table `t`, sorted, via a fresh
2742    /// connection opened after the run (so the sink's pool has been dropped and
2743    /// its WAL checkpointed).
2744    async fn read_table_vs(db_url: &str) -> Vec<String> {
2745        let pool = sqlx::sqlite::SqlitePoolOptions::new()
2746            .connect(db_url)
2747            .await
2748            .expect("open sqlite");
2749        let rows: Vec<(String,)> = sqlx::query_as("SELECT v FROM t ORDER BY v")
2750            .fetch_all(&pool)
2751            .await
2752            .expect("read back");
2753        pool.close().await;
2754        rows.into_iter().map(|(v,)| v).collect()
2755    }
2756
2757    fn overwrite_fanout_yaml(dir: &Path, db_url: &str) -> String {
2758        // execution.max_concurrent: 1 keeps the two child invocations
2759        // sequential — sqlite is single-writer, so this avoids SQLITE_BUSY
2760        // flakiness while still proving begin-once / commit-once.
2761        format!(
2762            "version: 1\nname: ow\nexecution:\n  max_concurrent: 1\npipeline:\n  sources:\n    parents:\n      type: csv\n      config: {{ path: \"{parents}\" }}\n    child:\n      type: csv\n      config: {{ path: \"{child}\" }}\n  sinks:\n    trash:\n      type: jsonl\n      config: {{ path: \"{trash}\", append: false }}\n    t:\n      type: sqlite\n      config:\n        database_url: \"{db_url}\"\n        table_name: t\n        column_mapping: auto_map\n        write_mode: overwrite\nmatrix:\n  - id: p\n    source: {{ ref: parents }}\n    sink: {{ ref: trash }}\n  - id: c\n    parent: p\n    parent_key: id\n    source: {{ ref: child }}\n    sink: {{ ref: t }}\n",
2763            parents = dir.join("parents.csv").display(),
2764            child = dir.join("child_${p.id}.csv").display(),
2765            trash = dir.join("trash.jsonl").display(),
2766        )
2767    }
2768
2769    /// Expand a parent→child(overwrite sink) matrix and return the child node,
2770    /// with the child's destination table set to `child_table` (which may carry
2771    /// a `${p.id}` token). Pure config expansion — no connector I/O.
2772    fn overwrite_child_node(dir: &Path, child_table: &str) -> ExpandedNode {
2773        let yaml = format!(
2774            "version: 1\nname: t\nexecution:\n  max_concurrent: 1\npipeline:\n  sources:\n    parents: {{ type: csv, config: {{ path: \"{p}\" }} }}\n    child: {{ type: csv, config: {{ path: \"{c}\" }} }}\n  sinks:\n    trash: {{ type: jsonl, config: {{ path: \"{t}\" }} }}\n    dst: {{ type: sqlite, config: {{ database_url: \"sqlite:{db}\", table_name: \"{child_table}\", column_mapping: auto_map, write_mode: overwrite }} }}\nmatrix:\n  - id: p\n    source: {{ ref: parents }}\n    sink: {{ ref: trash }}\n  - id: c\n    parent: p\n    parent_key: id\n    source: {{ ref: child }}\n    sink: {{ ref: dst }}\n",
2775            p = dir.join("parents.csv").display(),
2776            c = dir.join("child.csv").display(),
2777            t = dir.join("trash.jsonl").display(),
2778            db = dir.join("t.db").display(),
2779        );
2780        let cfg = PipelineConfig::from_text(&yaml, &dir.join("c.yaml")).expect("config parses");
2781        expand(&cfg)
2782            .expect("config expands")
2783            .into_iter()
2784            .find(|n| n.id == "c")
2785            .expect("child node")
2786    }
2787
2788    // #552: the executor collapses overwrite fan-out to ONE lifecycle per
2789    // resolved destination. This unit-tests the pure grouping (`plan_overwrite_groups`)
2790    // — the heart of the fix — deterministically, without the SQLite write
2791    // contention that makes a full-executor overwrite E2E flaky in CI. Combined
2792    // with the core `suppressed_overwrite_writes_but_skips_the_lifecycle` test
2793    // (each invocation appends, never begins/commits itself) and the abort E2E
2794    // below, this proves begin-once → append-all → commit-once retains every
2795    // parent's rows.
2796    #[tokio::test]
2797    async fn overwrite_groups_are_one_per_resolved_destination() {
2798        let dir = tempfile::tempdir().unwrap();
2799        let opts = exec_opts("t");
2800        let mk = |node: &ExpandedNode, pid: &str, id: i64| Unit {
2801            node: node.clone(),
2802            parent_record: Some(std::sync::Arc::new(json!({ "id": id }))),
2803            state_key: format!("t::c::{pid}"),
2804            parent_record_key: Some(pid.to_string()),
2805            product_ctx: None,
2806        };
2807
2808        // Shared destination (fixed table) → ONE group across both parents, so
2809        // begin/commit run once — the fix for the last-writer-wins bug.
2810        let shared = overwrite_child_node(dir.path(), "orders");
2811        let units = vec![mk(&shared, "1", 1), mk(&shared, "2", 2)];
2812        let (groups, task_group) = plan_overwrite_groups(&units, &opts).unwrap();
2813        assert_eq!(
2814            groups.len(),
2815            1,
2816            "a shared destination collapses to one overwrite group"
2817        );
2818        assert_eq!(
2819            task_group.len(),
2820            2,
2821            "both invocations attach to the group (their per-invocation lifecycle is suppressed)"
2822        );
2823
2824        // Per-parent destination (`${p.id}` in the table) → a single-member
2825        // group each, exactly as before — the out-of-scope case stays unchanged.
2826        let per = overwrite_child_node(dir.path(), "orders_${p.id}");
2827        let units2 = vec![mk(&per, "1", 1), mk(&per, "2", 2)];
2828        let (groups2, task_group2) = plan_overwrite_groups(&units2, &opts).unwrap();
2829        assert_eq!(
2830            groups2.len(),
2831            2,
2832            "per-parent tables stay separate single-member groups"
2833        );
2834        assert_eq!(task_group2.len(), 2);
2835    }
2836
2837    #[tokio::test]
2838    async fn child_fanout_overwrite_aborts_on_failure_leaving_destination_intact() {
2839        let dir = tempfile::tempdir().unwrap();
2840        let db_url = format!("sqlite:{}", dir.path().join("ow.db").display());
2841        seed_table(&db_url).await;
2842        std::fs::write(dir.path().join("parents.csv"), "id\n1\n2\n").unwrap();
2843        std::fs::write(dir.path().join("child_1.csv"), "id,v\n1,A\n").unwrap();
2844        // child_2.csv is intentionally absent → the parent-2 child invocation
2845        // fails, so the whole overwrite group must abort (never commit partial).
2846
2847        let yaml = overwrite_fanout_yaml(dir.path(), &db_url);
2848        let summary = run_yaml(&yaml, &dir.path().join("ow.yaml")).await;
2849        assert!(
2850            summary.had_failures(),
2851            "a missing child source must fail a unit"
2852        );
2853
2854        // The prior destination is untouched: still exactly the seed row.
2855        let vs = read_table_vs(&db_url).await;
2856        assert_eq!(
2857            vs,
2858            vec!["OLD".to_string()],
2859            "destination must be unchanged: {vs:?}"
2860        );
2861    }
2862
2863    #[tokio::test]
2864    async fn metadata_columns_are_stamped_onto_output() {
2865        let dir = tempfile::tempdir().unwrap();
2866        let input = dir.path().join("in.csv");
2867        let output = dir.path().join("out.jsonl");
2868        std::fs::write(&input, "name\nalice\n").unwrap();
2869        let mut cfg = cfg_csv_to_jsonl(&input, &output);
2870        cfg.metadata_columns = Some(faucet_core::MetadataColumnsSpec {
2871            enabled: true,
2872            prefix: "_faucet".into(),
2873            columns: vec![
2874                faucet_core::MetadataColumn::RunId,
2875                faucet_core::MetadataColumn::Source,
2876                faucet_core::MetadataColumn::LoadedAt,
2877            ],
2878        });
2879        let nodes = expand(&cfg).unwrap();
2880        run_expanded(nodes, opts("t")).await.unwrap();
2881        let body = std::fs::read_to_string(&output).unwrap();
2882        let row: serde_json::Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
2883        assert_eq!(row["name"], "alice");
2884        assert_eq!(row["_faucet_source"], "csv");
2885        assert!(row["_faucet_run_id"].is_string());
2886        assert!(row["_faucet_loaded_at"].is_string());
2887    }
2888
2889    #[tokio::test]
2890    async fn metadata_columns_disabled_stamps_nothing() {
2891        let dir = tempfile::tempdir().unwrap();
2892        let input = dir.path().join("in.csv");
2893        let output = dir.path().join("out.jsonl");
2894        std::fs::write(&input, "name\nalice\n").unwrap();
2895        let mut cfg = cfg_csv_to_jsonl(&input, &output);
2896        cfg.metadata_columns = Some(faucet_core::MetadataColumnsSpec {
2897            enabled: false,
2898            ..Default::default()
2899        });
2900        let nodes = expand(&cfg).unwrap();
2901        run_expanded(nodes, opts("t")).await.unwrap();
2902        let body = std::fs::read_to_string(&output).unwrap();
2903        let row: serde_json::Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
2904        assert_eq!(row["name"], "alice");
2905        assert!(row.get("_faucet_run_id").is_none());
2906    }
2907
2908    /// Minimal options with a catalog handle attached.
2909    #[cfg(feature = "catalog")]
2910    fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
2911        let mut o = opts(name);
2912        o.catalog = Some(handle);
2913        o
2914    }
2915
2916    #[cfg(feature = "catalog")]
2917    #[tokio::test]
2918    async fn catalog_records_schema_timeline_across_two_runs() {
2919        // Acceptance (#279): running the same pipeline twice with a schema
2920        // change in between produces exactly two schema-timeline entries for
2921        // the dataset, the second carrying a computed diff.
2922        use crate::catalog::CatalogHandle;
2923        use crate::serve::history::RunHistory as _;
2924        use crate::serve::history::catalog::{self, CatalogListFilter};
2925        use crate::serve::history::memory::MemoryHistory;
2926
2927        let dir = tempfile::tempdir().unwrap();
2928        let input = dir.path().join("in.csv");
2929        let output = dir.path().join("out.jsonl");
2930        let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
2931        let handle = CatalogHandle {
2932            store: store.clone(),
2933            run_id: None,
2934            sample_records: 10,
2935        };
2936
2937        std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
2938        let cfg = cfg_csv_to_jsonl(&input, &output);
2939        let nodes = expand(&cfg).unwrap();
2940        let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
2941            .await
2942            .unwrap();
2943        assert!(!summary.had_failures());
2944
2945        // Second run: same pipeline, schema gains an `email` column.
2946        std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
2947        let nodes = expand(&cfg).unwrap();
2948        let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
2949            .await
2950            .unwrap();
2951        assert!(!summary.had_failures());
2952
2953        // Two datasets (source + sink), each with a 2-entry deduped timeline.
2954        let page = store
2955            .catalog_list_datasets(&CatalogListFilter {
2956                limit: 10,
2957                ..Default::default()
2958            })
2959            .await
2960            .unwrap();
2961        assert_eq!(page.datasets.len(), 2, "source + sink datasets");
2962        for ds in &page.datasets {
2963            let detail = store
2964                .catalog_get_dataset(&ds.id)
2965                .await
2966                .unwrap()
2967                .expect("dataset detail");
2968            assert_eq!(detail.dataset.runs, 2);
2969            assert_eq!(
2970                detail.schema_timeline.len(),
2971                2,
2972                "exactly two timeline entries for {}",
2973                ds.uri
2974            );
2975            assert!(detail.schema_timeline[0].diff.is_none());
2976            let diff = detail.schema_timeline[1]
2977                .diff
2978                .as_ref()
2979                .expect("second version carries a diff");
2980            assert!(
2981                diff["added"]
2982                    .as_array()
2983                    .unwrap()
2984                    .iter()
2985                    .any(|c| c["column"] == "email"),
2986                "diff must show the added email column: {diff}"
2987            );
2988            assert_eq!(detail.stats.len(), 2, "one volume point per run");
2989        }
2990        // One lineage edge, csv → jsonl, traversed twice.
2991        let edges = store.catalog_lineage(None, 5).await.unwrap();
2992        assert_eq!(edges.len(), 1);
2993        assert_eq!(edges[0].runs, 2);
2994        assert_eq!(edges[0].last_records, 2);
2995        assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
2996    }
2997
2998    /// A catalog store whose writes always fail — drives the never-fail-the-run
2999    /// contract.
3000    #[cfg(feature = "catalog")]
3001    struct FailingCatalogStore;
3002
3003    #[cfg(feature = "catalog")]
3004    #[async_trait]
3005    impl crate::serve::history::RunHistory for FailingCatalogStore {
3006        async fn claim_idempotency(
3007            &self,
3008            _: &str,
3009            _: &str,
3010            _: &str,
3011            _: std::time::Duration,
3012        ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
3013            Err(crate::serve::history::HistoryError::Backend("down".into()))
3014        }
3015        async fn upsert(
3016            &self,
3017            _: &crate::serve::history::RunRecord,
3018        ) -> Result<(), crate::serve::history::HistoryError> {
3019            Err(crate::serve::history::HistoryError::Backend("down".into()))
3020        }
3021        async fn get(
3022            &self,
3023            _: &str,
3024        ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
3025        {
3026            Err(crate::serve::history::HistoryError::Backend("down".into()))
3027        }
3028        async fn list(
3029            &self,
3030            _: &crate::serve::history::ListFilter,
3031        ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
3032            Err(crate::serve::history::HistoryError::Backend("down".into()))
3033        }
3034        async fn delete(
3035            &self,
3036            _: &str,
3037        ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
3038        {
3039            Err(crate::serve::history::HistoryError::Backend("down".into()))
3040        }
3041        async fn purge_expired(
3042            &self,
3043            _: std::time::Duration,
3044        ) -> Result<usize, crate::serve::history::HistoryError> {
3045            Err(crate::serve::history::HistoryError::Backend("down".into()))
3046        }
3047        async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
3048            Err(crate::serve::history::HistoryError::Backend("down".into()))
3049        }
3050        async fn catalog_record(
3051            &self,
3052            _: &crate::serve::history::catalog::CatalogUpdate,
3053        ) -> Result<(), crate::serve::history::HistoryError> {
3054            Err(crate::serve::history::HistoryError::Backend(
3055                "catalog write refused".into(),
3056            ))
3057        }
3058        fn degraded(&self) -> bool {
3059            false
3060        }
3061    }
3062
3063    #[cfg(feature = "catalog")]
3064    #[tokio::test]
3065    async fn catalog_write_failure_never_fails_the_run() {
3066        // Acceptance (#279): a forced catalog-backend error degrades (logged)
3067        // while the pipeline still succeeds and writes its output.
3068        use crate::catalog::CatalogHandle;
3069        let dir = tempfile::tempdir().unwrap();
3070        let input = dir.path().join("in.csv");
3071        let output = dir.path().join("out.jsonl");
3072        std::fs::write(&input, "name\nalice\n").unwrap();
3073        let cfg = cfg_csv_to_jsonl(&input, &output);
3074        let nodes = expand(&cfg).unwrap();
3075        let handle = CatalogHandle {
3076            store: Arc::new(FailingCatalogStore),
3077            run_id: None,
3078            sample_records: 10,
3079        };
3080        let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
3081            .await
3082            .unwrap();
3083        assert!(
3084            !summary.had_failures(),
3085            "catalog failure must not fail the run"
3086        );
3087        assert_eq!(summary.invocations[0].records_written, 1);
3088        assert_eq!(
3089            std::fs::read_to_string(&output).unwrap().lines().count(),
3090            1,
3091            "sink output written despite the catalog error"
3092        );
3093    }
3094
3095    #[tokio::test]
3096    async fn matrix_two_independent_roots_both_run() {
3097        // Two roots: one writes alice, the other writes bob — to two separate files.
3098        let dir = tempfile::tempdir().unwrap();
3099        let csv_a = dir.path().join("a.csv");
3100        let csv_b = dir.path().join("b.csv");
3101        let out_a = dir.path().join("a.jsonl");
3102        let out_b = dir.path().join("b.jsonl");
3103        std::fs::write(&csv_a, "name\nalice\n").unwrap();
3104        std::fs::write(&csv_b, "name\nbob\n").unwrap();
3105
3106        let yaml = format!(
3107            r#"version: 1
3108pipeline:
3109  source: {{ type: csv, config: {{ path: {a} }} }}
3110  sink:   {{ type: jsonl, config: {{ path: {out_a} }} }}
3111matrix:
3112  - id: rowA
3113  - id: rowB
3114    source: {{ config: {{ path: {b} }} }}
3115    sink:   {{ config: {{ path: {out_b} }} }}
3116"#,
3117            a = csv_a.display(),
3118            b = csv_b.display(),
3119            out_a = out_a.display(),
3120            out_b = out_b.display(),
3121        );
3122        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3123        let nodes = expand(&cfg).unwrap();
3124        let summary = run_expanded(
3125            nodes,
3126            ExecuteOptions {
3127                pipeline_name: "matrix".into(),
3128                run_id: None,
3129                execution: None,
3130                dry_run: false,
3131                limit: None,
3132                state_path_override: None,
3133                shard: None,
3134                auth: Default::default(),
3135                clock: chrono::Utc::now().fixed_offset(),
3136                cancel: None,
3137                resilience: None,
3138                sla: None,
3139                reconcile: None,
3140                #[cfg(feature = "lineage")]
3141                lineage: None,
3142                #[cfg(feature = "lineage")]
3143                lineage_cfg: None,
3144                #[cfg(feature = "notify")]
3145                notifier: None,
3146                #[cfg(feature = "catalog")]
3147                catalog: None,
3148            },
3149        )
3150        .await
3151        .unwrap();
3152        assert_eq!(summary.invocations.len(), 2);
3153        assert!(out_a.exists());
3154        assert!(out_b.exists());
3155    }
3156
3157    #[tokio::test]
3158    async fn dag_child_fans_out_per_parent_record() {
3159        // Parent: CSV with two records (id=1, id=2).
3160        // Child: writes one JSONL file per parent id, using ${parent.id} in the path.
3161        let dir = tempfile::tempdir().unwrap();
3162        let parent_csv = dir.path().join("parents.csv");
3163        let child_csv = dir.path().join("child.csv");
3164        std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
3165        std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
3166        let parent_out = dir.path().join("parents.jsonl");
3167        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3168
3169        let yaml = format!(
3170            r#"version: 1
3171pipeline:
3172  source: {{ type: csv, config: {{ path: {parent} }} }}
3173  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
3174matrix:
3175  - id: parents
3176  - id: child
3177    parent: parents
3178    source: {{ config: {{ path: {child} }} }}
3179    sink:   {{ config: {{ path: "{child_out}" }} }}
3180"#,
3181            parent = parent_csv.display(),
3182            parent_out = parent_out.display(),
3183            child = child_csv.display(),
3184            child_out = child_out_pattern.display(),
3185        );
3186        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3187        let nodes = expand(&cfg).unwrap();
3188        let summary = run_expanded(
3189            nodes,
3190            ExecuteOptions {
3191                pipeline_name: "dagtest".into(),
3192                run_id: None,
3193                execution: None,
3194                dry_run: false,
3195                limit: None,
3196                state_path_override: None,
3197                shard: None,
3198                auth: Default::default(),
3199                clock: chrono::Utc::now().fixed_offset(),
3200                cancel: None,
3201                resilience: None,
3202                sla: None,
3203                reconcile: None,
3204                #[cfg(feature = "lineage")]
3205                lineage: None,
3206                #[cfg(feature = "lineage")]
3207                lineage_cfg: None,
3208                #[cfg(feature = "notify")]
3209                notifier: None,
3210                #[cfg(feature = "catalog")]
3211                catalog: None,
3212            },
3213        )
3214        .await
3215        .unwrap();
3216
3217        // 1 parent invocation + 2 child invocations.
3218        assert_eq!(summary.invocations.len(), 3);
3219        assert!(!summary.had_failures(), "{:?}", summary);
3220        assert!(dir.path().join("child-1.jsonl").exists());
3221        assert!(dir.path().join("child-2.jsonl").exists());
3222    }
3223
3224    #[tokio::test]
3225    async fn depends_on_root_runs_after_dependency() {
3226        // `stage` writes a CSV that `load` reads — `load` can only succeed if
3227        // it genuinely starts after `stage` finishes (pure ordering, no
3228        // record hand-off).
3229        let dir = tempfile::tempdir().unwrap();
3230        let input = dir.path().join("in.csv");
3231        let mid = dir.path().join("mid.csv");
3232        let out = dir.path().join("out.jsonl");
3233        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3234
3235        let yaml = format!(
3236            r#"version: 1
3237pipeline:
3238  source: {{ type: csv, config: {{ path: {input} }} }}
3239  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
3240matrix:
3241  - id: stage
3242    sink: {{ type: csv, config: {{ path: {mid} }} }}
3243  - id: load
3244    depends_on: [stage]
3245    source: {{ config: {{ path: {mid} }} }}
3246"#,
3247            input = input.display(),
3248            mid = mid.display(),
3249            out = out.display(),
3250        );
3251        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3252        let nodes = expand(&cfg).unwrap();
3253        let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
3254        assert_eq!(summary.invocations.len(), 2, "{summary:?}");
3255        assert!(!summary.had_failures(), "{summary:?}");
3256        let load = summary
3257            .invocations
3258            .iter()
3259            .find(|i| i.row_id == "load")
3260            .unwrap();
3261        assert_eq!(load.records_written, 2);
3262        let written = std::fs::read_to_string(&out).unwrap();
3263        assert_eq!(written.lines().count(), 2);
3264    }
3265
3266    #[tokio::test]
3267    async fn diamond_dependency_waits_for_all_prerequisites() {
3268        // c waits on both a and b (a diamond join): readiness must require
3269        // *every* dependency to be terminal, not just the first.
3270        let dir = tempfile::tempdir().unwrap();
3271        let input = dir.path().join("in.csv");
3272        let mid_a = dir.path().join("mid_a.csv");
3273        let mid_b = dir.path().join("mid_b.csv");
3274        let out = dir.path().join("out.jsonl");
3275        std::fs::write(&input, "name\nalice\n").unwrap();
3276
3277        let yaml = format!(
3278            r#"version: 1
3279pipeline:
3280  source: {{ type: csv, config: {{ path: {input} }} }}
3281  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
3282matrix:
3283  - id: a
3284    sink: {{ type: csv, config: {{ path: {mid_a} }} }}
3285  - id: b
3286    sink: {{ type: csv, config: {{ path: {mid_b} }} }}
3287  - id: c
3288    depends_on: [a, b]
3289    source: {{ config: {{ path: {mid_a} }} }}
3290"#,
3291            input = input.display(),
3292            mid_a = mid_a.display(),
3293            mid_b = mid_b.display(),
3294            out = out.display(),
3295        );
3296        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3297        let nodes = expand(&cfg).unwrap();
3298        let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
3299        assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3300        assert!(!summary.had_failures(), "{summary:?}");
3301        assert!(mid_b.exists(), "b must have run before c became ready");
3302        assert!(out.exists());
3303    }
3304
3305    #[tokio::test]
3306    async fn failed_dependency_skips_dependent() {
3307        // `stage` fails (missing input file); `load` depends on it and must be
3308        // skipped — no invocation outcome, no output file.
3309        let dir = tempfile::tempdir().unwrap();
3310        let good_input = dir.path().join("good.csv");
3311        let out = dir.path().join("out.jsonl");
3312        std::fs::write(&good_input, "name\nalice\n").unwrap();
3313
3314        let yaml = format!(
3315            r#"version: 1
3316pipeline:
3317  source: {{ type: csv, config: {{ path: {good} }} }}
3318  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
3319matrix:
3320  - id: stage
3321    source: {{ config: {{ path: {missing} }} }}
3322  - id: load
3323    depends_on: [stage]
3324"#,
3325            good = good_input.display(),
3326            missing = dir.path().join("nonexistent.csv").display(),
3327            out = out.display(),
3328        );
3329        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3330        let nodes = expand(&cfg).unwrap();
3331        let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
3332        assert_eq!(summary.invocations.len(), 1, "{summary:?}");
3333        assert_eq!(summary.invocations[0].row_id, "stage");
3334        assert!(summary.invocations[0].error.is_some());
3335        assert!(
3336            !out.exists(),
3337            "dependent row must not run after its dependency failed"
3338        );
3339    }
3340
3341    #[tokio::test]
3342    async fn dependency_on_skipped_row_cascades() {
3343        // p fails → its child c is skipped → q (which depends on c) must be
3344        // skipped too, even though c itself never *failed*.
3345        let dir = tempfile::tempdir().unwrap();
3346        let good_input = dir.path().join("good.csv");
3347        let out = dir.path().join("q.jsonl");
3348        std::fs::write(&good_input, "id\n1\n").unwrap();
3349
3350        let yaml = format!(
3351            r#"version: 1
3352pipeline:
3353  source: {{ type: csv, config: {{ path: {good} }} }}
3354  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
3355matrix:
3356  - id: p
3357    source: {{ config: {{ path: {missing} }} }}
3358  - id: c
3359    parent: p
3360  - id: q
3361    depends_on: [c]
3362"#,
3363            good = good_input.display(),
3364            missing = dir.path().join("nonexistent.csv").display(),
3365            out = out.display(),
3366        );
3367        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3368        let nodes = expand(&cfg).unwrap();
3369        let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
3370        assert_eq!(summary.invocations.len(), 1, "{summary:?}");
3371        assert_eq!(summary.invocations[0].row_id, "p");
3372        assert!(summary.invocations[0].error.is_some());
3373        assert!(
3374            !out.exists(),
3375            "q must be skipped when its dependency was skipped"
3376        );
3377    }
3378
3379    #[tokio::test]
3380    async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
3381        // First root writes to an invalid sink path and fails. The second
3382        // ("good") root would succeed. Under `on_error: stop` the executor
3383        // calls `abort_all()` on the first failure, which cancels pending /
3384        // in-flight tasks at their next await point — but that is
3385        // best-effort: with `max_concurrent: 1` the two roots race for the
3386        // single permit, so "good" may already have completed before "bad"
3387        // fails. We therefore assert the guarantees that hold under *any*
3388        // scheduling rather than an exact invocation count (which was racy,
3389        // see issue #78 finding #24). The deterministic "stop actually
3390        // cancels in-flight work" path is covered by
3391        // `on_error_stop_under_parallelism_aborts_other_in_flight`.
3392        let dir = tempfile::tempdir().unwrap();
3393        let good_csv = dir.path().join("good.csv");
3394        std::fs::write(&good_csv, "x\n1\n").unwrap();
3395        let good_out = dir.path().join("good.jsonl");
3396        let bad_sink_dir = dir.path().to_path_buf();
3397
3398        let yaml = format!(
3399            r#"version: 1
3400pipeline:
3401  source: {{ type: csv, config: {{ path: {good_csv} }} }}
3402  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
3403matrix:
3404  - id: bad
3405    sink: {{ config: {{ path: {bad_dir} }} }}
3406  - id: good
3407execution:
3408  max_concurrent: 1
3409  on_error: stop
3410"#,
3411            good_csv = good_csv.display(),
3412            good_out = good_out.display(),
3413            bad_dir = bad_sink_dir.display(),
3414        );
3415        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3416        let nodes = expand(&cfg).unwrap();
3417        let summary = run_expanded(
3418            nodes,
3419            ExecuteOptions {
3420                pipeline_name: "stoptest".into(),
3421                run_id: None,
3422                execution: cfg.execution.clone(),
3423                dry_run: false,
3424                limit: None,
3425                state_path_override: None,
3426                shard: None,
3427                auth: Default::default(),
3428                clock: chrono::Utc::now().fixed_offset(),
3429                cancel: None,
3430                resilience: None,
3431                sla: None,
3432                reconcile: None,
3433                #[cfg(feature = "lineage")]
3434                lineage: None,
3435                #[cfg(feature = "lineage")]
3436                lineage_cfg: None,
3437                #[cfg(feature = "notify")]
3438                notifier: None,
3439                #[cfg(feature = "catalog")]
3440                catalog: None,
3441            },
3442        )
3443        .await
3444        .unwrap();
3445
3446        // Invariants that hold regardless of which root won the permit race:
3447        assert!(summary.had_failures(), "the failing root must be reported");
3448
3449        // "bad" ran exactly once and is recorded as a failure.
3450        let bad: Vec<_> = summary
3451            .invocations
3452            .iter()
3453            .filter(|o| o.row_id == "bad")
3454            .collect();
3455        assert_eq!(bad.len(), 1, "bad must run exactly once");
3456        assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
3457
3458        // No duplicate / extra invocations beyond the two work units.
3459        assert!(
3460            summary.invocations.len() <= 2,
3461            "at most the two roots may run, got {:?}",
3462            summary.invocations
3463        );
3464
3465        // "good" may: (a) win the permit first and run fully (writes its row,
3466        // file exists); (b) lose the race, acquire the permit after "bad" fails,
3467        // observe the cooperative stop-cancel at its first page boundary, and
3468        // return a 0-record success (no file); or (c) never appear if it was
3469        // still pending when the level finished. So the only invariant is: a
3470        // "good" that actually WROTE records must have produced its file.
3471        let good_wrote = summary
3472            .invocations
3473            .iter()
3474            .find(|o| o.row_id == "good" && o.error.is_none())
3475            .map(|o| o.records_written)
3476            .unwrap_or(0);
3477        if good_wrote > 0 {
3478            assert!(
3479                good_out.exists(),
3480                "a good that wrote records must have produced its output file"
3481            );
3482        }
3483    }
3484
3485    #[tokio::test]
3486    async fn invalid_pipeline_name_with_state_errors_up_front() {
3487        // A pipeline name that can't form a valid state key must fail up front
3488        // (at unit construction) when state is configured — not deep mid-run
3489        // as a `FaucetError::State`.
3490        let dir = tempfile::tempdir().unwrap();
3491        let input = dir.path().join("in.csv");
3492        let output = dir.path().join("out.jsonl");
3493        std::fs::write(&input, "name\nalice\n").unwrap();
3494        let yaml = format!(
3495            r#"version: 1
3496pipeline:
3497  source: {{ type: csv, config: {{ path: {input} }} }}
3498  sink:   {{ type: jsonl, config: {{ path: {output} }} }}
3499  state:  {{ type: memory }}
3500"#,
3501            input = input.display(),
3502            output = output.display(),
3503        );
3504        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3505        let nodes = expand(&cfg).unwrap();
3506        let err = run_expanded(
3507            nodes,
3508            ExecuteOptions {
3509                pipeline_name: "bad name".into(), // space is illegal in a state key
3510                run_id: None,
3511                execution: None,
3512                dry_run: false,
3513                limit: None,
3514                state_path_override: None,
3515                shard: None,
3516                auth: Default::default(),
3517                clock: chrono::Utc::now().fixed_offset(),
3518                cancel: None,
3519                resilience: None,
3520                sla: None,
3521                reconcile: None,
3522                #[cfg(feature = "lineage")]
3523                lineage: None,
3524                #[cfg(feature = "lineage")]
3525                lineage_cfg: None,
3526                #[cfg(feature = "notify")]
3527                notifier: None,
3528                #[cfg(feature = "catalog")]
3529                catalog: None,
3530            },
3531        )
3532        .await
3533        .expect_err("an invalid pipeline name must be rejected up front when state is configured");
3534        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
3535    }
3536
3537    #[tokio::test]
3538    async fn invalid_parent_key_value_with_state_errors_up_front() {
3539        // A parent-record value that yields an illegal state-key suffix must
3540        // fail up front at the child's unit construction, not mid-run.
3541        let dir = tempfile::tempdir().unwrap();
3542        let parent_csv = dir.path().join("parents.csv");
3543        let child_csv = dir.path().join("child.csv");
3544        // The parent `id` value contains a space — illegal in a state key.
3545        std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
3546        std::fs::write(&child_csv, "x\nA\n").unwrap();
3547        let parent_out = dir.path().join("parents.jsonl");
3548        let child_out = dir.path().join("child.jsonl");
3549        let yaml = format!(
3550            r#"version: 1
3551pipeline:
3552  source: {{ type: csv, config: {{ path: {parent} }} }}
3553  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
3554  state:  {{ type: memory }}
3555matrix:
3556  - id: parents
3557  - id: child
3558    parent: parents
3559    source: {{ config: {{ path: {child} }} }}
3560    sink:   {{ config: {{ path: {child_out} }} }}
3561"#,
3562            parent = parent_csv.display(),
3563            parent_out = parent_out.display(),
3564            child = child_csv.display(),
3565            child_out = child_out.display(),
3566        );
3567        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3568        let nodes = expand(&cfg).unwrap();
3569        let err = run_expanded(
3570            nodes,
3571            ExecuteOptions {
3572                pipeline_name: "ok".into(),
3573                run_id: None,
3574                execution: None,
3575                dry_run: false,
3576                limit: None,
3577                state_path_override: None,
3578                shard: None,
3579                auth: Default::default(),
3580                clock: chrono::Utc::now().fixed_offset(),
3581                cancel: None,
3582                resilience: None,
3583                sla: None,
3584                reconcile: None,
3585                #[cfg(feature = "lineage")]
3586                lineage: None,
3587                #[cfg(feature = "lineage")]
3588                lineage_cfg: None,
3589                #[cfg(feature = "notify")]
3590                notifier: None,
3591                #[cfg(feature = "catalog")]
3592                catalog: None,
3593            },
3594        )
3595        .await
3596        .expect_err(
3597            "an illegal parent-key value must be rejected up front when state is configured",
3598        );
3599        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
3600    }
3601
3602    #[tokio::test]
3603    async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
3604        // Three roots running with `max_concurrent: 3`. The bad row points
3605        // its sink at a directory (open fails fast). The other two point at
3606        // sinks that block forever on the writer end of a pipe — stuck *inside*
3607        // the sink write, they never reach a page boundary to observe the
3608        // cooperative stop-cancel, so the only way they can complete is the
3609        // hard-abort backstop that fires after the flush grace (#146 H16). The
3610        // test would hang if `on_error: stop` never aborted them, so a passing
3611        // run is itself the assertion.
3612        let dir = tempfile::tempdir().unwrap();
3613        let bad_sink_dir = dir.path().to_path_buf();
3614        // A real csv source with one row — small enough that the pipeline
3615        // proceeds straight to the sink phase.
3616        let good_csv = dir.path().join("good.csv");
3617        std::fs::write(&good_csv, "x\n1\n").unwrap();
3618        // The two "would never finish" sinks point at the same path as the
3619        // bad sink (an existing directory). Their sink-open also errors
3620        // out — but we still verify the *abort* path by counting how many
3621        // tasks make it past spawn before stop fires. The strict invariant
3622        // we assert: the bad row's failure is the first one observed.
3623        let yaml = format!(
3624            r#"version: 1
3625pipeline:
3626  source: {{ type: csv, config: {{ path: {good_csv} }} }}
3627  sink:   {{ type: jsonl, config: {{ path: {bad_dir} }} }}
3628matrix:
3629  - id: bad
3630  - id: good_a
3631  - id: good_b
3632execution:
3633  max_concurrent: 3
3634  on_error: stop
3635"#,
3636            good_csv = good_csv.display(),
3637            bad_dir = bad_sink_dir.display(),
3638        );
3639        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3640        let nodes = expand(&cfg).unwrap();
3641        let summary = run_expanded(
3642            nodes,
3643            ExecuteOptions {
3644                pipeline_name: "stop_parallel".into(),
3645                run_id: None,
3646                execution: cfg.execution.clone(),
3647                dry_run: false,
3648                limit: None,
3649                state_path_override: None,
3650                shard: None,
3651                auth: Default::default(),
3652                clock: chrono::Utc::now().fixed_offset(),
3653                cancel: None,
3654                resilience: None,
3655                sla: None,
3656                reconcile: None,
3657                #[cfg(feature = "lineage")]
3658                lineage: None,
3659                #[cfg(feature = "lineage")]
3660                lineage_cfg: None,
3661                #[cfg(feature = "notify")]
3662                notifier: None,
3663                #[cfg(feature = "catalog")]
3664                catalog: None,
3665            },
3666        )
3667        .await
3668        .unwrap();
3669
3670        // First-observed failure halts the run. The first outcome in the
3671        // summary is guaranteed to be a failure (other tasks either fail
3672        // too or get cancelled — both cases never push a *success* outcome
3673        // first because every sink in this matrix is configured to fail).
3674        assert!(
3675            summary.had_failures(),
3676            "summary should record at least one failure: {summary:?}"
3677        );
3678        assert!(
3679            summary.invocations[0].error.is_some(),
3680            "first outcome must be the failure that triggered stop: {summary:?}"
3681        );
3682        // No invocation should report `records_written > 0` — every sink is
3683        // bad. (Catches a regression where abort_all somehow let a task
3684        // bypass its broken sink.)
3685        for inv in &summary.invocations {
3686            assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
3687        }
3688    }
3689
3690    #[tokio::test]
3691    async fn on_error_continue_skips_failed_subtree_only() {
3692        // Two roots: one fails. The good one's invocation still completes.
3693        let dir = tempfile::tempdir().unwrap();
3694        let good_csv = dir.path().join("good.csv");
3695        std::fs::write(&good_csv, "x\n1\n").unwrap();
3696        let good_out = dir.path().join("good.jsonl");
3697
3698        let yaml = format!(
3699            r#"version: 1
3700pipeline:
3701  source: {{ type: csv, config: {{ path: {good_csv} }} }}
3702  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
3703matrix:
3704  - id: bad
3705    sink: {{ config: {{ path: {bad_dir} }} }}
3706  - id: good
3707"#,
3708            good_csv = good_csv.display(),
3709            good_out = good_out.display(),
3710            bad_dir = dir.path().display(),
3711        );
3712        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3713        let nodes = expand(&cfg).unwrap();
3714        let summary = run_expanded(
3715            nodes,
3716            ExecuteOptions {
3717                pipeline_name: "continuetest".into(),
3718                run_id: None,
3719                execution: None,
3720                dry_run: false,
3721                limit: None,
3722                state_path_override: None,
3723                shard: None,
3724                auth: Default::default(),
3725                clock: chrono::Utc::now().fixed_offset(),
3726                cancel: None,
3727                resilience: None,
3728                sla: None,
3729                reconcile: None,
3730                #[cfg(feature = "lineage")]
3731                lineage: None,
3732                #[cfg(feature = "lineage")]
3733                lineage_cfg: None,
3734                #[cfg(feature = "notify")]
3735                notifier: None,
3736                #[cfg(feature = "catalog")]
3737                catalog: None,
3738            },
3739        )
3740        .await
3741        .unwrap();
3742        assert_eq!(summary.invocations.len(), 2);
3743        assert_eq!(summary.failure_count(), 1);
3744        let good_outcome = summary
3745            .invocations
3746            .iter()
3747            .find(|i| i.row_id == "good")
3748            .unwrap();
3749        assert!(good_outcome.error.is_none());
3750    }
3751
3752    // ── projection helpers (#160) ─────────────────────────────────────────────
3753
3754    #[test]
3755    fn split_path_splits_on_dots() {
3756        assert_eq!(split_path("id"), vec!["id".to_string()]);
3757        assert_eq!(
3758            split_path("user.name"),
3759            vec!["user".to_string(), "name".to_string()]
3760        );
3761    }
3762
3763    #[test]
3764    fn minimal_paths_drops_descendants_of_kept_ancestors() {
3765        let paths = vec![
3766            vec!["user".into(), "name".into()],
3767            vec!["user".into()],
3768            vec!["id".into()],
3769            vec!["id".into()],
3770        ];
3771        let min = minimal_paths(paths);
3772        assert!(min.contains(&vec!["user".to_string()]));
3773        assert!(min.contains(&vec!["id".to_string()]));
3774        assert!(
3775            !min.contains(&vec!["user".to_string(), "name".to_string()]),
3776            "user.name must be dropped — covered by user"
3777        );
3778        assert_eq!(min.len(), 2);
3779    }
3780
3781    #[test]
3782    fn project_full_clones_whole_record() {
3783        let r = json!({"a": 1, "b": {"c": 2}});
3784        assert_eq!(project_record(&r, &Projection::Full), r);
3785    }
3786
3787    #[test]
3788    fn project_keeps_only_referenced_paths() {
3789        let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
3790        let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
3791        let got = project_record(&r, &p);
3792        assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
3793        assert!(got.get("blob").is_none());
3794        assert!(got["user"].get("age").is_none());
3795    }
3796
3797    #[test]
3798    fn project_array_index_path_resolves_same_as_original() {
3799        let r = json!({"tags": ["x", "y", "z"]});
3800        let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
3801        let got = project_record(&r, &p);
3802        assert_eq!(got, json!({"tags": {"0": "x"}}));
3803        assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
3804        assert_eq!(
3805            resolve_parent_key(&got, "tags.0"),
3806            resolve_parent_key(&r, "tags.0"),
3807            "reduced tree must resolve the same value as the original"
3808        );
3809    }
3810
3811    #[test]
3812    fn project_numeric_object_key_resolves_same_as_original() {
3813        // A numeric segment can address an OBJECT key in the original (not an
3814        // array index). The reduced all-objects tree stores it under the same
3815        // key, so resolution still matches — the parity property the design
3816        // relies on, distinct from the array-index case above.
3817        let r = json!({"data": {"0": "x", "1": "y"}});
3818        let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
3819        let got = project_record(&r, &p);
3820        assert_eq!(got, json!({"data": {"0": "x"}}));
3821        assert_eq!(
3822            resolve_parent_key(&got, "data.0"),
3823            resolve_parent_key(&r, "data.0"),
3824            "numeric object-key path must resolve identically on the reduced tree"
3825        );
3826    }
3827
3828    #[test]
3829    fn project_missing_path_is_omitted() {
3830        let r = json!({"id": 1});
3831        let p = Projection::Paths(vec![vec!["nope".into()]]);
3832        assert_eq!(project_record(&r, &p), json!({}));
3833    }
3834
3835    #[test]
3836    fn build_projections_unions_parent_key_and_refs() {
3837        use crate::config::ConnectorSpec;
3838        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3839
3840        fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
3841            ExpandedNode {
3842                id: id.into(),
3843                row_index: 0,
3844                role: NodeRole::Child {
3845                    parent_id: parent.into(),
3846                    parent_key: parent_key.into(),
3847                },
3848                source: ConnectorSpec {
3849                    kind: "csv".into(),
3850                    config: json!({}),
3851                    transforms: None,
3852                    inherit_transforms: true,
3853                    status: None,
3854                    tags: Vec::new(),
3855                    complete_for: None,
3856                },
3857                sink: ConnectorSpec {
3858                    kind: "jsonl".into(),
3859                    config: json!({}),
3860                    transforms: None,
3861                    inherit_transforms: true,
3862                    status: None,
3863                    tags: Vec::new(),
3864                    complete_for: None,
3865                },
3866                transforms: Vec::new(),
3867                state: None,
3868                dlq: None,
3869                delivery: faucet_core::DeliveryMode::AtLeastOnce,
3870                delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3871                #[cfg(feature = "quality")]
3872                quality: None,
3873                #[cfg(feature = "contract")]
3874                contract: None,
3875                #[cfg(feature = "masking")]
3876                masking: None,
3877                sink_ref: "default".into(),
3878                schema: None,
3879                depends_on: Vec::new(),
3880                status: crate::config::SourceStatus::Active,
3881                tags: Vec::new(),
3882                cleanup_scope: None,
3883                metadata_columns: None,
3884                deferred_refs: refs
3885                    .iter()
3886                    .map(|(rid, p)| DeferredRef {
3887                        referenced_id: (*rid).into(),
3888                        dotted_path: (*p).into(),
3889                        token: format!("${{{rid}.{p}}}"),
3890                    })
3891                    .collect(),
3892                source_override: None,
3893            }
3894        }
3895
3896        let c1 = child("c1", "p", "id", &[("p", "user.name")]);
3897        let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
3898        let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
3899        let children_of =
3900            HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
3901
3902        let projs = build_projections(&nodes_by_id, &children_of);
3903        let p = projs.get("p").expect("projection for p");
3904        match &**p {
3905            Projection::Paths(paths) => {
3906                assert!(paths.contains(&vec!["id".to_string()]));
3907                assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
3908                assert!(paths.contains(&vec!["email".to_string()]));
3909                assert!(
3910                    !paths.iter().any(|p| p == &vec!["x".to_string()]),
3911                    "a ref to a different parent must not be captured under p"
3912                );
3913            }
3914            Projection::Full => panic!("expected Paths, got Full"),
3915        }
3916    }
3917
3918    #[test]
3919    fn build_projections_whole_record_ref_is_full() {
3920        use crate::config::ConnectorSpec;
3921        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3922        let c = ExpandedNode {
3923            id: "c".into(),
3924            row_index: 0,
3925            role: NodeRole::Child {
3926                parent_id: "p".into(),
3927                parent_key: "id".into(),
3928            },
3929            source: ConnectorSpec {
3930                kind: "csv".into(),
3931                config: json!({}),
3932                transforms: None,
3933                inherit_transforms: true,
3934                status: None,
3935                tags: Vec::new(),
3936                complete_for: None,
3937            },
3938            sink: ConnectorSpec {
3939                kind: "jsonl".into(),
3940                config: json!({}),
3941                transforms: None,
3942                inherit_transforms: true,
3943                status: None,
3944                tags: Vec::new(),
3945                complete_for: None,
3946            },
3947            transforms: Vec::new(),
3948            state: None,
3949            dlq: None,
3950            delivery: faucet_core::DeliveryMode::AtLeastOnce,
3951            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3952            #[cfg(feature = "quality")]
3953            quality: None,
3954            #[cfg(feature = "contract")]
3955            contract: None,
3956            #[cfg(feature = "masking")]
3957            masking: None,
3958            sink_ref: "default".into(),
3959            schema: None,
3960            depends_on: Vec::new(),
3961            status: crate::config::SourceStatus::Active,
3962            tags: Vec::new(),
3963            cleanup_scope: None,
3964            metadata_columns: None,
3965            deferred_refs: vec![DeferredRef {
3966                referenced_id: "p".into(),
3967                dotted_path: "".into(),
3968                token: "${p}".into(),
3969            }],
3970            source_override: None,
3971        };
3972        let nodes_by_id = HashMap::from([("c".to_string(), c)]);
3973        let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
3974        let projs = build_projections(&nodes_by_id, &children_of);
3975        assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
3976    }
3977
3978    /// Helper: minimal `ExecuteOptions` with all optional knobs cleared.
3979    fn opts(name: &str) -> ExecuteOptions {
3980        ExecuteOptions {
3981            pipeline_name: name.into(),
3982            run_id: None,
3983            execution: None,
3984            dry_run: false,
3985            limit: None,
3986            state_path_override: None,
3987            shard: None,
3988            auth: Default::default(),
3989            clock: chrono::Utc::now().fixed_offset(),
3990            cancel: None,
3991            resilience: None,
3992            sla: None,
3993            reconcile: None,
3994            #[cfg(feature = "lineage")]
3995            lineage: None,
3996            #[cfg(feature = "lineage")]
3997            lineage_cfg: None,
3998            #[cfg(feature = "notify")]
3999            notifier: None,
4000            #[cfg(feature = "catalog")]
4001            catalog: None,
4002        }
4003    }
4004
4005    #[tokio::test]
4006    async fn dry_run_counts_records_without_writing_sink_file() {
4007        // `--dry-run` swaps the real sink for a CountingSink — records flow but
4008        // no output file is produced.
4009        let dir = tempfile::tempdir().unwrap();
4010        let input = dir.path().join("in.csv");
4011        let output = dir.path().join("out.jsonl");
4012        std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
4013        let cfg = cfg_csv_to_jsonl(&input, &output);
4014        let nodes = expand(&cfg).unwrap();
4015        let mut o = opts("dry");
4016        o.dry_run = true;
4017        let summary = run_expanded(nodes, o).await.unwrap();
4018        assert_eq!(summary.invocations.len(), 1);
4019        assert_eq!(summary.invocations[0].records_written, 3);
4020        assert!(!summary.had_failures());
4021        assert!(
4022            !output.exists(),
4023            "dry-run must not create the real sink file"
4024        );
4025    }
4026
4027    #[tokio::test]
4028    async fn read_only_state_store_drops_writes_keeps_reads() {
4029        // #321 H1: reads pass through; put/delete are dropped so a preview never
4030        // mutates the durable bookmark.
4031        let inner = Arc::new(faucet_core::MemoryStateStore::new()) as Arc<dyn StateStore>;
4032        inner.put("k", &json!("v0")).await.unwrap();
4033        let ro = ReadOnlyStateStore {
4034            inner: inner.clone(),
4035        };
4036        assert_eq!(ro.get("k").await.unwrap(), Some(json!("v0")));
4037        // A write must be a no-op — the inner store keeps its original value.
4038        ro.put("k", &json!("advanced")).await.unwrap();
4039        assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
4040        // A delete must be a no-op too.
4041        ro.delete("k").await.unwrap();
4042        assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
4043    }
4044
4045    #[tokio::test]
4046    async fn dry_run_with_state_does_not_persist_bookmark() {
4047        // #321 H1: a `--dry-run` with a durable state store must not advance the
4048        // persisted bookmark. Pre-seed a bookmark file, run dry, and confirm it is
4049        // byte-for-byte unchanged (the ReadOnlyStateStore wrapper drops writes).
4050        let dir = tempfile::tempdir().unwrap();
4051        let input = dir.path().join("in.csv");
4052        let output = dir.path().join("out.jsonl");
4053        let state_dir = dir.path().join("state");
4054        std::fs::create_dir_all(&state_dir).unwrap();
4055        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
4056        let cfg = cfg_csv_to_jsonl(&input, &output);
4057        let nodes = expand(&cfg).unwrap();
4058        let mut o = opts("drystate");
4059        o.dry_run = true;
4060        o.state_path_override = Some(state_dir.clone());
4061        let summary = run_expanded(nodes, o).await.unwrap();
4062        assert!(!summary.had_failures());
4063        assert!(!output.exists(), "dry-run must not write the sink file");
4064        // The state store is wrapped read-only under dry-run, so no bookmark
4065        // file is ever persisted — the state dir stays empty.
4066        let persisted: Vec<_> = std::fs::read_dir(&state_dir)
4067            .unwrap()
4068            .filter_map(Result::ok)
4069            .collect();
4070        assert!(
4071            persisted.is_empty(),
4072            "dry-run must not persist any bookmark file, found: {persisted:?}"
4073        );
4074    }
4075
4076    #[tokio::test]
4077    async fn limit_caps_records_written_across_the_run() {
4078        // `--limit N` wraps the sink so only the first N records land.
4079        let dir = tempfile::tempdir().unwrap();
4080        let input = dir.path().join("in.csv");
4081        let output = dir.path().join("out.jsonl");
4082        std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
4083        let cfg = cfg_csv_to_jsonl(&input, &output);
4084        let nodes = expand(&cfg).unwrap();
4085        let mut o = opts("lim");
4086        o.limit = Some(2);
4087        let summary = run_expanded(nodes, o).await.unwrap();
4088        assert_eq!(summary.invocations[0].records_written, 2);
4089        let body = std::fs::read_to_string(&output).unwrap();
4090        assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
4091    }
4092
4093    #[tokio::test]
4094    async fn duplicate_state_key_among_siblings_is_rejected() {
4095        // Two parent records whose `parent_key` value collides (both id="dup")
4096        // produce two child units with the SAME state key. With state
4097        // configured, that collision must surface as DuplicateStateKey.
4098        let dir = tempfile::tempdir().unwrap();
4099        let parent_csv = dir.path().join("parents.csv");
4100        let child_csv = dir.path().join("child.csv");
4101        // Both rows share id="dup" — the per-child state-key suffix collides.
4102        std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
4103        std::fs::write(&child_csv, "x\nA\n").unwrap();
4104        let parent_out = dir.path().join("parents.jsonl");
4105        let child_out = dir.path().join("child.jsonl");
4106        let yaml = format!(
4107            r#"version: 1
4108pipeline:
4109  source: {{ type: csv, config: {{ path: {parent} }} }}
4110  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
4111  state:  {{ type: memory }}
4112matrix:
4113  - id: parents
4114  - id: child
4115    parent: parents
4116    source: {{ config: {{ path: {child} }} }}
4117    sink:   {{ config: {{ path: {child_out} }} }}
4118"#,
4119            parent = parent_csv.display(),
4120            parent_out = parent_out.display(),
4121            child = child_csv.display(),
4122            child_out = child_out.display(),
4123        );
4124        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
4125        let nodes = expand(&cfg).unwrap();
4126        let err = run_expanded(nodes, opts("dupkey"))
4127            .await
4128            .expect_err("colliding sibling state keys must be rejected");
4129        match err {
4130            CliError::DuplicateStateKey { id, state_key } => {
4131                assert_eq!(id, "child");
4132                assert_eq!(state_key, "dupkey::child::dup");
4133            }
4134            other => panic!("expected DuplicateStateKey, got {other:?}"),
4135        }
4136    }
4137
4138    #[tokio::test]
4139    async fn state_path_override_writes_bookmark_file() {
4140        // `--state-path` with a node that has no `state:` block wires a
4141        // FileStateStore at the override path; running it should create the
4142        // bookmark file (REST-less csv source still opts into state via the
4143        // override).
4144        let dir = tempfile::tempdir().unwrap();
4145        let input = dir.path().join("in.csv");
4146        let output = dir.path().join("out.jsonl");
4147        let state_dir = dir.path().join("state");
4148        std::fs::write(&input, "name\nalice\n").unwrap();
4149        let cfg = cfg_csv_to_jsonl(&input, &output);
4150        let nodes = expand(&cfg).unwrap();
4151        let mut o = opts("statepath");
4152        o.state_path_override = Some(state_dir.clone());
4153        let summary = run_expanded(nodes, o).await.unwrap();
4154        assert!(!summary.had_failures());
4155        // The csv source has no natural state key, so the StateKeyOverride wrap
4156        // is skipped — but build_state_for_node still constructs the store.
4157        // The run completing without error exercises the (None, Some(path)) arm.
4158        assert_eq!(summary.invocations[0].records_written, 1);
4159    }
4160
4161    #[tokio::test]
4162    async fn build_dlq_config_maps_spec_fields() {
4163        use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
4164        let dir = tempfile::tempdir().unwrap();
4165        let dlq_out = dir.path().join("dlq.jsonl");
4166        let spec = DlqSpec {
4167            sink: ConnectorSpec {
4168                kind: "jsonl".into(),
4169                config: json!({ "path": dlq_out.to_str().unwrap() }),
4170                transforms: None,
4171                inherit_transforms: true,
4172                status: None,
4173                tags: Vec::new(),
4174                complete_for: None,
4175            },
4176            on_batch_error: OnBatchErrorSpec::DlqAll,
4177            max_failures_per_page: Some(7),
4178            max_failures_total: Some(42),
4179            include_original_payload: false,
4180        };
4181        let cfg = build_dlq_config(&spec).await.unwrap();
4182        assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
4183        assert_eq!(cfg.max_failures_per_page, Some(7));
4184        assert_eq!(cfg.max_failures_total, Some(42));
4185        assert!(!cfg.include_original_payload);
4186    }
4187
4188    #[tokio::test]
4189    async fn build_state_for_node_arms() {
4190        let dir = tempfile::tempdir().unwrap();
4191
4192        // (None, None) → no store.
4193        let node = stub_node(None);
4194        assert!(build_state_for_node(&node, None).await.unwrap().is_none());
4195
4196        // (None, Some(path)) → FileStateStore from override.
4197        let p = dir.path().join("s1");
4198        assert!(
4199            build_state_for_node(&node, Some(&p))
4200                .await
4201                .unwrap()
4202                .is_some()
4203        );
4204
4205        // (Some(memory spec), None) → built from spec.
4206        let node_mem = stub_node(Some(crate::config::StateStoreSpec {
4207            kind: "memory".into(),
4208            config: json!({}),
4209        }));
4210        assert!(
4211            build_state_for_node(&node_mem, None)
4212                .await
4213                .unwrap()
4214                .is_some()
4215        );
4216
4217        // (Some(file spec), Some(path)) → file backend uses the override path.
4218        let node_file = stub_node(Some(crate::config::StateStoreSpec {
4219            kind: "file".into(),
4220            config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
4221        }));
4222        let p2 = dir.path().join("override2");
4223        assert!(
4224            build_state_for_node(&node_file, Some(&p2))
4225                .await
4226                .unwrap()
4227                .is_some()
4228        );
4229
4230        // (Some(memory spec), Some(path)) → non-file backend ignores override,
4231        // still builds from spec.
4232        let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
4233            kind: "memory".into(),
4234            config: json!({}),
4235        }));
4236        let p3 = dir.path().join("override3");
4237        assert!(
4238            build_state_for_node(&node_mem2, Some(&p3))
4239                .await
4240                .unwrap()
4241                .is_some()
4242        );
4243    }
4244
4245    /// Build a minimal root `ExpandedNode` carrying only an (optional) state spec.
4246    fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
4247        use crate::config::ConnectorSpec;
4248        ExpandedNode {
4249            id: "n".into(),
4250            row_index: 0,
4251            role: NodeRole::Root,
4252            source: ConnectorSpec {
4253                kind: "csv".into(),
4254                config: json!({}),
4255                transforms: None,
4256                inherit_transforms: true,
4257                status: None,
4258                tags: Vec::new(),
4259                complete_for: None,
4260            },
4261            sink: ConnectorSpec {
4262                kind: "jsonl".into(),
4263                config: json!({}),
4264                transforms: None,
4265                inherit_transforms: true,
4266                status: None,
4267                tags: Vec::new(),
4268                complete_for: None,
4269            },
4270            transforms: Vec::new(),
4271            state,
4272            dlq: None,
4273            delivery: faucet_core::DeliveryMode::AtLeastOnce,
4274            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
4275            #[cfg(feature = "quality")]
4276            quality: None,
4277            #[cfg(feature = "contract")]
4278            contract: None,
4279            #[cfg(feature = "masking")]
4280            masking: None,
4281            sink_ref: "default".into(),
4282            schema: None,
4283            depends_on: Vec::new(),
4284            status: crate::config::SourceStatus::Active,
4285            tags: Vec::new(),
4286            cleanup_scope: None,
4287            metadata_columns: None,
4288            deferred_refs: Vec::new(),
4289            source_override: None,
4290        }
4291    }
4292
4293    #[tokio::test]
4294    async fn state_key_override_delegates_and_overrides_key() {
4295        // StateKeyOverride forwards fetch/bookmark to inner and reports its own key.
4296        let dir = tempfile::tempdir().unwrap();
4297        let input = dir.path().join("in.csv");
4298        std::fs::write(&input, "name\nz\n").unwrap();
4299        let inner = build_source(
4300            "csv",
4301            json!({"path": input.to_str().unwrap()}),
4302            &AuthCatalog::new(),
4303            None,
4304        )
4305        .await
4306        .unwrap();
4307        // The wrapped name must match the inner source's, whatever it reports.
4308        let inner_name = inner.connector_name();
4309        let ov = StateKeyOverride {
4310            inner,
4311            key: "my::custom::key".into(),
4312        };
4313        assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
4314        assert_eq!(ov.connector_name(), inner_name);
4315        let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
4316        assert_eq!(rows.len(), 1);
4317        // apply_start_bookmark delegates without error (csv ignores it).
4318        ov.apply_start_bookmark(json!({"any": "bookmark"}))
4319            .await
4320            .unwrap();
4321        // Capability passthroughs (csv defaults).
4322        assert!(!ov.supports_exactly_once());
4323        assert_eq!(
4324            ov.replay_guarantee(),
4325            faucet_core::ReplayGuarantee::NonDeterministic
4326        );
4327        assert_eq!(ov.capture_resume_position().await.unwrap(), None);
4328    }
4329
4330    #[tokio::test]
4331    async fn state_key_override_forwards_native_stream_pages() {
4332        // The wrap must preserve the inner source's NATIVE page stream —
4333        // per-page bookmarks included. Without the `stream_pages` forward, the
4334        // trait's buffering default kicks in and collapses everything into
4335        // final-page-bookmark-only pages (losing CDC per-transaction
4336        // durability and exactly-once per-page tokens).
4337        struct PerPageBookmarkSource;
4338        #[async_trait]
4339        impl Source for PerPageBookmarkSource {
4340            async fn fetch_with_context(
4341                &self,
4342                _ctx: &HashMap<String, Value>,
4343            ) -> Result<Vec<Value>, FaucetError> {
4344                Ok(vec![json!({"id": 1}), json!({"id": 2})])
4345            }
4346            fn stream_pages<'a>(
4347                &'a self,
4348                _ctx: &'a HashMap<String, Value>,
4349                _batch_size: usize,
4350            ) -> std::pin::Pin<
4351                Box<
4352                    dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
4353                        + Send
4354                        + 'a,
4355                >,
4356            > {
4357                Box::pin(faucet_core::async_stream::try_stream! {
4358                    yield faucet_core::StreamPage {
4359                        records: vec![json!({"id": 1})],
4360                        bookmark: Some(json!("bm-1")),
4361                    };
4362                    yield faucet_core::StreamPage {
4363                        records: vec![json!({"id": 2})],
4364                        bookmark: Some(json!("bm-2")),
4365                    };
4366                })
4367            }
4368            fn state_key(&self) -> Option<String> {
4369                Some("native".into())
4370            }
4371        }
4372
4373        use futures::StreamExt;
4374        let ov = StateKeyOverride {
4375            inner: Box::new(PerPageBookmarkSource),
4376            key: "override".into(),
4377        };
4378        let ctx = HashMap::new();
4379        let pages: Vec<_> = ov
4380            .stream_pages(&ctx, 1000)
4381            .collect::<Vec<_>>()
4382            .await
4383            .into_iter()
4384            .collect::<Result<Vec<_>, _>>()
4385            .unwrap();
4386        assert_eq!(pages.len(), 2, "native page boundaries survive the wrap");
4387        assert_eq!(pages[0].bookmark, Some(json!("bm-1")));
4388        assert_eq!(pages[1].bookmark, Some(json!("bm-2")));
4389    }
4390
4391    #[tokio::test]
4392    async fn capturing_sink_forwards_capabilities_and_captures_idempotent_writes() {
4393        struct IdemSink;
4394        #[async_trait]
4395        impl Sink for IdemSink {
4396            async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4397                Ok(records.len())
4398            }
4399            fn connector_name(&self) -> &'static str {
4400                "idem"
4401            }
4402            fn supports_idempotent_writes(&self) -> bool {
4403                true
4404            }
4405            fn dedups_by_key(&self) -> bool {
4406                true
4407            }
4408            fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
4409                &[
4410                    faucet_core::WriteMode::Append,
4411                    faucet_core::WriteMode::Upsert,
4412                ]
4413            }
4414            async fn write_batch_idempotent(
4415                &self,
4416                records: &[Value],
4417                _scope: &str,
4418                _token: &str,
4419            ) -> Result<usize, FaucetError> {
4420                Ok(records.len())
4421            }
4422            async fn last_committed_token(
4423                &self,
4424                _scope: &str,
4425            ) -> Result<Option<String>, FaucetError> {
4426                Ok(Some("tok".into()))
4427            }
4428        }
4429
4430        let captured = Arc::new(Mutex::new(Vec::new()));
4431        let sink = CapturingSink::wrap(
4432            Box::new(IdemSink),
4433            Arc::clone(&captured),
4434            Arc::new(Projection::Full),
4435        );
4436        // Capability passthroughs: a parent row feeding children keeps the
4437        // inner sink's delivery semantics.
4438        assert!(sink.supports_idempotent_writes());
4439        assert!(sink.dedups_by_key());
4440        assert_eq!(
4441            sink.sink_guarantee(),
4442            faucet_core::SinkGuarantee::AtomicWatermark
4443        );
4444        assert!(
4445            sink.supported_write_modes()
4446                .contains(&faucet_core::WriteMode::Upsert)
4447        );
4448        assert_eq!(
4449            sink.last_committed_token("k").await.unwrap(),
4450            Some("tok".into())
4451        );
4452        assert_eq!(sink.current_schema().await.unwrap(), None);
4453        assert!(!sink.supports_schema_evolution());
4454        // Idempotent writes are captured for child fan-out like plain writes.
4455        let n = sink
4456            .write_batch_idempotent(&[json!({"id": 7})], "k", "t")
4457            .await
4458            .unwrap();
4459        assert_eq!(n, 1);
4460        assert_eq!(*captured.lock().await, vec![json!({"id": 7})]);
4461    }
4462
4463    #[tokio::test]
4464    async fn orphaned_child_surfaces_executor_deadlock() {
4465        // A child node whose parent id is never present among the nodes can
4466        // never become ready. `expand` would reject this, but a hand-built node
4467        // list exercises the executor's own deadlock guard (lines 227-233).
4468        use crate::config::ConnectorSpec;
4469        let orphan = ExpandedNode {
4470            id: "orphan".into(),
4471            row_index: 0,
4472            role: NodeRole::Child {
4473                parent_id: "missing-parent".into(),
4474                parent_key: "id".into(),
4475            },
4476            source: ConnectorSpec {
4477                kind: "csv".into(),
4478                config: json!({}),
4479                transforms: None,
4480                inherit_transforms: true,
4481                status: None,
4482                tags: Vec::new(),
4483                complete_for: None,
4484            },
4485            sink: ConnectorSpec {
4486                kind: "jsonl".into(),
4487                config: json!({}),
4488                transforms: None,
4489                inherit_transforms: true,
4490                status: None,
4491                tags: Vec::new(),
4492                complete_for: None,
4493            },
4494            transforms: Vec::new(),
4495            state: None,
4496            dlq: None,
4497            delivery: faucet_core::DeliveryMode::AtLeastOnce,
4498            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
4499            #[cfg(feature = "quality")]
4500            quality: None,
4501            #[cfg(feature = "contract")]
4502            contract: None,
4503            #[cfg(feature = "masking")]
4504            masking: None,
4505            sink_ref: "default".into(),
4506            schema: None,
4507            depends_on: Vec::new(),
4508            status: crate::config::SourceStatus::Active,
4509            tags: Vec::new(),
4510            cleanup_scope: None,
4511            metadata_columns: None,
4512            deferred_refs: Vec::new(),
4513            source_override: None,
4514        };
4515        let err = run_expanded(vec![orphan], opts("deadlock"))
4516            .await
4517            .expect_err("an orphaned child must surface as an executor deadlock");
4518        match err {
4519            CliError::Internal(msg) => {
4520                assert!(msg.contains("executor deadlock"), "{msg}");
4521                assert!(msg.contains("orphan"), "{msg}");
4522            }
4523            other => panic!("expected Internal deadlock error, got {other:?}"),
4524        }
4525    }
4526
4527    #[test]
4528    fn value_to_string_brief_unquotes_strings_only() {
4529        assert_eq!(value_to_string_brief(&json!("hello")), "hello");
4530        assert_eq!(value_to_string_brief(&json!(42)), "42");
4531        assert_eq!(value_to_string_brief(&json!(true)), "true");
4532        assert_eq!(value_to_string_brief(&json!(null)), "null");
4533        assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
4534    }
4535
4536    #[test]
4537    fn build_state_key_with_and_without_parent() {
4538        assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
4539        assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
4540    }
4541
4542    #[test]
4543    fn resolve_parent_key_walks_objects_arrays_and_misses() {
4544        let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
4545        assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
4546        assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
4547        // Missing key → None.
4548        assert_eq!(resolve_parent_key(&r, "user.age"), None);
4549        // Descending into a scalar → None.
4550        assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
4551        // Non-numeric array index → None.
4552        assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
4553    }
4554
4555    #[tokio::test]
4556    async fn cooperative_cancel_returns_partial_ok() {
4557        // A pre-cancelled token makes the run stop at the first page boundary
4558        // and flush — returning Ok with a partial (possibly empty) result
4559        // rather than erroring. Covers the cancel-threading path.
4560        let dir = tempfile::tempdir().unwrap();
4561        let input = dir.path().join("in.csv");
4562        let output = dir.path().join("out.jsonl");
4563        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
4564        let cfg = cfg_csv_to_jsonl(&input, &output);
4565        let nodes = expand(&cfg).unwrap();
4566        let token = CancellationToken::new();
4567        token.cancel(); // already cancelled before the run starts
4568        let mut o = opts("cancel");
4569        o.cancel = Some(token);
4570        let summary = run_expanded(nodes, o).await.unwrap();
4571        // The single root invocation completes (Ok) — it is not reported as a
4572        // failure even though it was cancelled.
4573        assert_eq!(summary.invocations.len(), 1);
4574        assert!(
4575            !summary.had_failures(),
4576            "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
4577        );
4578    }
4579
4580    #[tokio::test]
4581    async fn fanout_projects_away_unreferenced_parent_fields() {
4582        // Parent CSV has id + a big unreferenced "payload" column. The child only
4583        // references ${parents.id} (in its output path), so projection keeps "id"
4584        // and the parent_key but drops "payload" — and fan-out still works.
4585        let dir = tempfile::tempdir().unwrap();
4586        let parent_csv = dir.path().join("parents.csv");
4587        let child_csv = dir.path().join("child.csv");
4588        std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
4589        std::fs::write(&child_csv, "x\nA\n").unwrap();
4590        let parent_out = dir.path().join("parents.jsonl");
4591        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
4592
4593        let yaml = format!(
4594            r#"version: 1
4595pipeline:
4596  source: {{ type: csv, config: {{ path: {parent} }} }}
4597  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
4598matrix:
4599  - id: parents
4600  - id: child
4601    parent: parents
4602    source: {{ config: {{ path: {child} }} }}
4603    sink:   {{ config: {{ path: "{child_out}" }} }}
4604"#,
4605            parent = parent_csv.display(),
4606            parent_out = parent_out.display(),
4607            child = child_csv.display(),
4608            child_out = child_out_pattern.display(),
4609        );
4610        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
4611        let nodes = expand(&cfg).unwrap();
4612        let summary = run_expanded(
4613            nodes,
4614            ExecuteOptions {
4615                pipeline_name: "projtest".into(),
4616                run_id: None,
4617                execution: None,
4618                dry_run: false,
4619                limit: None,
4620                state_path_override: None,
4621                shard: None,
4622                auth: Default::default(),
4623                clock: chrono::Utc::now().fixed_offset(),
4624                cancel: None,
4625                resilience: None,
4626                sla: None,
4627                reconcile: None,
4628                #[cfg(feature = "lineage")]
4629                lineage: None,
4630                #[cfg(feature = "lineage")]
4631                lineage_cfg: None,
4632                #[cfg(feature = "notify")]
4633                notifier: None,
4634                #[cfg(feature = "catalog")]
4635                catalog: None,
4636            },
4637        )
4638        .await
4639        .unwrap();
4640
4641        // 1 parent + 2 child invocations (one per parent record) — cardinality kept.
4642        assert_eq!(summary.invocations.len(), 3, "{summary:?}");
4643        assert!(!summary.had_failures(), "{summary:?}");
4644        // ${parents.id} resolved correctly for each child despite "payload" being projected away.
4645        assert!(dir.path().join("child-1.jsonl").exists());
4646        assert!(dir.path().join("child-2.jsonl").exists());
4647    }
4648}