rivet/pipeline/run.rs
1//! **Layer: Coordinator entrypoint** — the `rivet run` orchestrator.
2//!
3//! Single bridge between planning, execution, and persistence/observability.
4//! Owns the multi-export render-mode flags, decides between sequential vs
5//! thread-parallel vs process-parallel, and produces the run aggregate at
6//! the end.
7//!
8//! Lives in its own file so [`crate::pipeline`] (which is read as a facade
9//! by every other module) stays a thin re-export layer rather than a
10//! ~300-LOC orchestrator wrapped in mod-level declarations.
11
12use std::path::Path;
13use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
14
15use crate::config::{Config, ExportConfig};
16use crate::error::Result;
17use crate::state::StateStore;
18
19use super::summary::RunSummary;
20use super::{aggregate, finalize, ipc, job, parallel_children, parent_ui, partition_expand};
21
22/// Per-run configuration flags passed from the CLI to the pipeline.
23///
24/// Replaces the previous pattern of threading 4+ positional `bool` arguments
25/// through `run`, `run_export_job`, and child-process invocations. Named fields
26/// prevent silent argument transposition (e.g., `validate` and `reconcile`
27/// swapped).
28#[derive(Debug, Clone, Copy)]
29pub struct RunOptions<'a> {
30 pub validate: bool,
31 pub reconcile: bool,
32 pub resume: bool,
33 /// Override safety gates that would otherwise refuse to start the run.
34 ///
35 /// Currently used by ADR-0012 M8 — `--resume` against a prefix whose
36 /// `_SUCCESS` marker is present is refused unless `--force` is given,
37 /// so an operator cannot accidentally re-export over a verified
38 /// dataset. Other gates may share the same flag in the future
39 /// (per ADR-0013: one `--force`, scoped to whichever gate it overrides).
40 pub force: bool,
41 pub params: Option<&'a std::collections::HashMap<String, String>>,
42}
43
44/// True when the current process is running more than one export in this
45/// `rivet run` invocation (sequential or `--parallel-exports`). Per-export
46/// renderers (`RunSummary::print`, `ChunkProgress`) read this to switch to
47/// the compact one-line format and to suppress the indicatif chunk bar
48/// respectively, so 15 exports take 15 lines instead of 100+ and threads
49/// don't stack progress bars on top of each other.
50///
51/// Children of `--parallel-export-processes` always have `exports.len() == 1`
52/// in their own process so this flag stays `false` for them; the parent
53/// renders cards itself via `parent_ui`.
54pub(crate) static MULTI_EXPORT_MODE: AtomicBool = AtomicBool::new(false);
55
56/// True only when multiple exports run **concurrently** in the current
57/// process (i.e. `--parallel-exports`, threads). Used to suppress
58/// per-export `indicatif` chunk progress bars whose terminal writes
59/// otherwise interleave across threads and corrupt each other.
60pub(crate) static MULTI_EXPORT_CONCURRENT: AtomicBool = AtomicBool::new(false);
61
62pub(crate) fn multi_export_mode() -> bool {
63 MULTI_EXPORT_MODE.load(AtomicOrdering::Relaxed)
64}
65
66#[allow(dead_code)] // kept for future renderers; flag is still set in `run` below.
67pub(crate) fn multi_export_concurrent() -> bool {
68 MULTI_EXPORT_CONCURRENT.load(AtomicOrdering::Relaxed)
69}
70
71fn print_json_summary(agg: &crate::state::RunAggregate) {
72 match serde_json::to_string_pretty(agg) {
73 Ok(json) => println!("{json}"),
74 Err(e) => eprintln!(
75 "rivet: error: failed to serialize run summary as JSON: {:#}",
76 e
77 ),
78 }
79}
80
81/// Emit captured child stderr from a parallel run. It's verbose — every child's
82/// full run card — so write it to a timestamped log beside the config and print
83/// a one-line pointer, instead of flooding the console with all N exports'
84/// stderr. Falls back to the inline console dump if the file can't be written.
85fn emit_child_stderr(dump: &str, dir: &Path) {
86 if dump.is_empty() {
87 return;
88 }
89 let name = format!(
90 "rivet-child-stderr-{}.log",
91 chrono::Utc::now().format("%Y%m%dT%H%M%S")
92 );
93 let path = dir.join(name);
94 match std::fs::write(&path, dump) {
95 // stderr, not stdout — stdout may carry the machine-readable `--json`
96 // run summary, which this pointer would otherwise corrupt.
97 Ok(()) => eprintln!(
98 "\n child stderr (full per-export logs) → {}",
99 path.display()
100 ),
101 Err(e) => {
102 log::warn!(
103 "could not write child stderr to {} ({e}); printing inline",
104 path.display()
105 );
106 use std::io::Write;
107 let mut h = std::io::stderr().lock();
108 let _ = h.write_all(dump.as_bytes());
109 let _ = h.flush();
110 }
111 }
112}
113
114#[allow(clippy::too_many_arguments)] // CLI fan-in; surface stays stable per ADR-0013
115pub fn run(
116 config_path: &str,
117 export_name: Option<&str>,
118 validate: bool,
119 reconcile: bool,
120 resume: bool,
121 force: bool,
122 params: Option<&std::collections::HashMap<String, String>>,
123 parallel_exports_cli: bool,
124 parallel_export_processes_cli: bool,
125 summary_output: Option<&Path>,
126 json_output: bool,
127) -> Result<()> {
128 // F-NEW-B (0.7.5 audit): `--force` is scoped to whichever gate it
129 // overrides (today: the `_SUCCESS`-already-present refusal on
130 // resume). When the operator passes `--force` without `--resume`,
131 // the flag is a no-op — surface that explicitly so a typo or
132 // copy-paste mistake does not pass silently.
133 if force && !resume {
134 log::warn!(
135 "--force without --resume is a no-op today (force only overrides the resume safety \
136 gate against a destination prefix whose _SUCCESS is already present)"
137 );
138 }
139 let config = Config::load_with_params(config_path, params)?;
140
141 let config_dir = Path::new(config_path)
142 .parent()
143 .unwrap_or(Path::new("."))
144 .to_path_buf();
145
146 let selected: Vec<&ExportConfig> = if let Some(name) = export_name {
147 let e = config
148 .exports
149 .iter()
150 .find(|e| e.name == name)
151 .ok_or_else(|| anyhow::anyhow!("export '{}' not found in config", name))?;
152 vec![e]
153 } else {
154 config.exports.iter().collect()
155 };
156
157 // Value-based partitioning: rewrite any `partition_by` export into one
158 // concrete child export per bucket *before* the run loop. Non-partitioned
159 // exports pass through. The owned vec must outlive the borrowed `exports`
160 // view rebuilt over it, so it is declared in the enclosing scope.
161 let partitioned = partition_expand::any_partitioned(&selected);
162 let expanded_owned: Vec<ExportConfig>;
163 let exports: Vec<&ExportConfig> = if partitioned {
164 expanded_owned = partition_expand::expand_partitioned_exports(
165 &selected,
166 &config.source,
167 &config_dir,
168 params,
169 )?;
170 expanded_owned.iter().collect()
171 } else {
172 selected
173 };
174
175 let opts = RunOptions {
176 validate,
177 reconcile,
178 resume,
179 force,
180 params,
181 };
182
183 // Seeds the card-table name column so it aligns from the first redraw
184 // (the renderer can't see a long name until its export emits `Started`).
185 let name_floor = exports
186 .iter()
187 .map(|e| e.name.chars().count())
188 .max()
189 .unwrap_or(0);
190 let process_mode_requested = parallel_export_processes_cli || config.parallel_export_processes;
191 // Process-mode children re-exec `rivet run --export <name>` and re-load the
192 // config from disk, so they cannot see the synthesised partition child
193 // names. Force in-process execution when partitioning is active.
194 if partitioned && process_mode_requested {
195 log::warn!(
196 "partition_by: --parallel-export-processes is disabled with partitioned exports \
197 (child processes re-load the config and can't see synthesised partitions); \
198 running in-process"
199 );
200 }
201 let run_parallel_processes =
202 process_mode_requested && export_name.is_none() && exports.len() > 1 && !partitioned;
203
204 let started_at = chrono::Utc::now();
205
206 if run_parallel_processes {
207 // Run schema migrations once in the parent BEFORE forking children.
208 // Otherwise N children race for the exclusive write lock on a
209 // brand-new `.rivet_state.db` and `busy_timeout` is not enough to
210 // serialise them — most fail with `migration v1 failed: database is
211 // locked`. After this open succeeds the schema is at the latest
212 // version and children's `StateStore::open` calls become idempotent
213 // (the `MIGRATIONS` loop is a no-op when `ver <= current`).
214 if let Err(e) = StateStore::open(config_path) {
215 return Err(anyhow::anyhow!(
216 "state: failed to initialize state DB before spawning children: {:#}",
217 e
218 ));
219 }
220
221 let (result, child_failures, stderr_dump) =
222 parallel_children::run_exports_as_child_processes(
223 config_path,
224 &exports,
225 validate,
226 reconcile,
227 resume,
228 force,
229 params,
230 name_floor,
231 );
232 let finished_at = chrono::Utc::now();
233 // Best-effort aggregate: open the state DB read-only-ish and reconstruct
234 // entries from the per-child `record_metric` rows. Failure to open the
235 // DB here only suppresses the aggregate, not the run itself.
236 match StateStore::open(config_path) {
237 Ok(state) => {
238 let entries =
239 aggregate::collect_child_entries(&state, &exports, started_at, &child_failures);
240 let agg = aggregate::build(
241 entries,
242 started_at,
243 finished_at,
244 Some(config_path),
245 "parallel-processes",
246 );
247 aggregate::print(&agg);
248 aggregate::persist(&state, &agg, summary_output);
249 if json_output {
250 print_json_summary(&agg);
251 }
252 }
253 Err(e) => log::warn!(
254 "aggregate: cannot open state DB to record run aggregate: {:#}",
255 e
256 ),
257 }
258 // Captured child stderr (verbose per-export cards) goes to a file
259 // artifact beside the config, with a one-line console pointer — the run
260 // summary stays clean instead of flooding with every child's stderr.
261 emit_child_stderr(&stderr_dump, &config_dir);
262 return result;
263 }
264
265 let run_parallel = (parallel_exports_cli || config.parallel_exports)
266 && export_name.is_none()
267 && exports.len() > 1;
268
269 // Compact-rendering hints for the per-export renderers. Set once here so
270 // every code path below — sequential, `--parallel-exports`, the apply
271 // path, etc. — sees a consistent mode. Restored at the end of the run
272 // so subsequent invocations within the same process (tests, library
273 // callers) start with a clean slate.
274 let multi_export = export_name.is_none() && exports.len() > 1;
275 let prev_multi = MULTI_EXPORT_MODE.swap(multi_export, AtomicOrdering::Relaxed);
276 let prev_concurrent = MULTI_EXPORT_CONCURRENT.swap(run_parallel, AtomicOrdering::Relaxed);
277 struct ResetMultiExport(bool, bool);
278 impl Drop for ResetMultiExport {
279 fn drop(&mut self) {
280 MULTI_EXPORT_MODE.store(self.0, AtomicOrdering::Relaxed);
281 MULTI_EXPORT_CONCURRENT.store(self.1, AtomicOrdering::Relaxed);
282 }
283 }
284 let _reset_multi = ResetMultiExport(prev_multi, prev_concurrent);
285
286 let mut summaries: Vec<RunSummary> = Vec::with_capacity(exports.len());
287 // Keep the typed `anyhow::Error`s (not flattened strings) so the final bail
288 // can carry a representative one — its DataIntegrityError / SchemaDriftError /
289 // transient marker downcasts through anyhow's context chain in
290 // `error::classify_exit`, giving the right process exit code without grepping
291 // the message.
292 let mut failures: Vec<anyhow::Error> = Vec::new();
293
294 if run_parallel {
295 log::info!(
296 "running {} exports in parallel (separate state DB connection per export)",
297 exports.len()
298 );
299
300 // In threads mode every export emits the same `ChildEvent` stream
301 // that `--parallel-export-processes` children emit, but routed
302 // through an in-process `mpsc` channel. A single UI thread (the
303 // same `parent_ui::run_ui` used for the process-mode parent) owns
304 // stderr and renders one card line per export — no indicatif, no
305 // multi-bar coordination headache, no scrollback artefacts from
306 // concurrent redraws. Ensure stderr is also pre-migrated so child
307 // threads opening their own `StateStore` don't race on schema DDL.
308 if let Err(e) = StateStore::open(config_path) {
309 return Err(anyhow::anyhow!(
310 "state: failed to initialize state DB before spawning export threads: {:#}",
311 e
312 ));
313 }
314 let n_cards = exports.len();
315 let (tx, rx) = std::sync::mpsc::channel::<parent_ui::UiMessage>();
316 ipc::install_in_process_tx(tx);
317 let ui_thread = std::thread::Builder::new()
318 .name("rivet-ui".to_string())
319 .spawn(move || parent_ui::run_ui(rx, name_floor, n_cards))
320 .ok();
321
322 let collected: std::sync::Mutex<Vec<(Result<()>, RunSummary)>> =
323 std::sync::Mutex::new(Vec::with_capacity(exports.len()));
324 std::thread::scope(|s| {
325 let mut handles = Vec::new();
326 for &export in &exports {
327 handles.push(s.spawn(|| {
328 let state = match StateStore::open(config_path) {
329 Ok(s) => s,
330 Err(e) => {
331 let err = anyhow::anyhow!(
332 "export '{}': failed to open state database: {:#}",
333 export.name,
334 e
335 );
336 let summary = job::synthetic_failed_summary(&export.name, &err);
337 return (Err(err), summary);
338 }
339 };
340 job::run_export_job(config_path, &config, export, &state, &config_dir, &opts)
341 }));
342 }
343 for h in handles {
344 match h.join() {
345 Ok(pair) => collected.lock().unwrap().push(pair),
346 Err(payload) => std::panic::resume_unwind(payload),
347 }
348 }
349 });
350
351 // All exports are done → drop the sender so `parent_ui::run_ui`
352 // sees the channel close and exits cleanly (committing the final
353 // card stack to scrollback). Joining is best-effort: even if the
354 // UI thread is wedged we still want to print the run aggregate
355 // below.
356 ipc::clear_in_process_tx();
357 if let Some(t) = ui_thread {
358 let _ = t.join();
359 }
360
361 for (res, summary) in collected.into_inner().unwrap() {
362 if let Err(e) = res {
363 failures.push(e);
364 }
365 summaries.push(summary);
366 }
367 } else {
368 let state = StateStore::open(config_path)?;
369
370 // Always route through `parent_ui` — same as `--parallel-exports`.
371 // Gating on `is_attended()` left VHS/ttyd on indicatif when the
372 // attended bit is unset; `run_ui` already falls back to linear
373 // mode for piped stderr.
374 let n_cards = exports.len();
375 let (tx, rx) = std::sync::mpsc::channel::<parent_ui::UiMessage>();
376 ipc::install_in_process_tx(tx);
377 let ui_thread = std::thread::Builder::new()
378 .name("rivet-ui".to_string())
379 .spawn(move || parent_ui::run_ui(rx, name_floor, n_cards))
380 .ok();
381
382 for export in &exports {
383 let (res, summary) =
384 job::run_export_job(config_path, &config, export, &state, &config_dir, &opts);
385 if let Err(e) = res {
386 failures.push(e);
387 }
388 summaries.push(summary);
389 }
390
391 ipc::clear_in_process_tx();
392 if let Some(t) = ui_thread {
393 let _ = t.join();
394 }
395 // Single-export sequential runs still emit the detailed block after
396 // the card commits to scrollback.
397 if exports.len() == 1
398 && let Some(summary) = summaries.last()
399 {
400 summary.print_stderr_block();
401 }
402 }
403
404 let finished_at = chrono::Utc::now();
405 // Skip the aggregate for single-export runs. Two cases this catches:
406 // 1) `rivet run --export X` (manual one-off): the per-export block
407 // already says everything, an aggregate of one row is just noise.
408 // 2) Children spawned by `--parallel-export-processes`: each child
409 // enters this code path with exports.len() == 1. The parent
410 // (parallel_processes branch above) builds the run-wide aggregate
411 // from every child's `export_metrics` row, so a child-level
412 // aggregate would just write a duplicate into `run_aggregate`.
413 // Force-write the JSON file even when skipping, so `--summary-output`
414 // remains useful for one-off runs.
415 if exports.len() > 1 {
416 let parallel_mode = if run_parallel {
417 "parallel-threads"
418 } else {
419 "sequential"
420 };
421 let entries: Vec<_> = summaries
422 .iter()
423 .map(aggregate::entry_from_summary)
424 .collect();
425 let agg = aggregate::build(
426 entries,
427 started_at,
428 finished_at,
429 Some(config_path),
430 parallel_mode,
431 );
432 aggregate::print(&agg);
433 // Open a fresh state handle for persisting the aggregate so we don't
434 // assume which thread owned the per-export `StateStore` above.
435 match StateStore::open(config_path) {
436 Ok(state) => aggregate::persist(&state, &agg, summary_output),
437 Err(e) => log::warn!(
438 "aggregate: cannot open state DB to record run aggregate: {:#}",
439 e
440 ),
441 }
442 if json_output {
443 print_json_summary(&agg);
444 }
445 } else if summary_output.is_some() || json_output {
446 // One export, but the user asked for a summary file and/or JSON stdout —
447 // honour both without polluting the DB or stderr.
448 let entries: Vec<_> = summaries
449 .iter()
450 .map(aggregate::entry_from_summary)
451 .collect();
452 let agg = aggregate::build(
453 entries,
454 started_at,
455 finished_at,
456 Some(config_path),
457 "sequential",
458 );
459 if let Some(out) = summary_output
460 && let Err(e) =
461 std::fs::write(out, serde_json::to_string_pretty(&agg).unwrap_or_default())
462 {
463 log::warn!(
464 "aggregate: failed to write summary JSON to {}: {:#}",
465 out.display(),
466 e
467 );
468 }
469 if json_output {
470 print_json_summary(&agg);
471 }
472 }
473
474 if !failures.is_empty() {
475 // Carry a representative typed failure as the returned error so
476 // `error::classify_exit` downcasts the marker (DataIntegrityError=3,
477 // SchemaDriftError=4, transient=2) through anyhow's context chain. Pick
478 // the most "stop-worthy" class — data-integrity (possibly-wrong data)
479 // outranks schema-drift, which outranks retryable, which outranks
480 // generic — so a mixed batch exits on the scariest reason.
481 let primary_idx = representative_failure_idx(&failures).unwrap();
482 let primary = failures.remove(primary_idx);
483 if failures.is_empty() {
484 // Single failure — return it verbatim (its own message + marker).
485 return Err(primary);
486 }
487 // Multiple failures: list the others as higher-level context; `primary`
488 // (with its typed marker) rides underneath so the downcast still finds it.
489 let others = failures
490 .iter()
491 .map(|e| format!("{e:#}"))
492 .collect::<Vec<_>>()
493 .join("; ");
494 return Err(primary.context(format!(
495 "{} export(s) failed; representative error follows (also: {others})",
496 failures.len() + 1
497 )));
498 }
499
500 Ok(())
501}
502
503/// `rivet apply -c config.yaml` (plan→apply cycle): run every export of the
504/// config **wave by wave** in ascending `wave:` order — exports with no `wave:`
505/// run last — reusing the same per-export job + run aggregate as [`run`]. This
506/// first cut runs each wave's exports SEQUENTIALLY (deterministic); safety-aware
507/// within-wave parallelism is a follow-up, and `partition_by` exports are not
508/// expanded here yet (use `rivet run` for those).
509pub(crate) fn run_waves(
510 config_path: &str,
511 force: bool,
512 parallel_cli: bool,
513 resume: bool,
514) -> Result<()> {
515 let config = Config::load_with_params(config_path, None)?;
516 let config_dir = Path::new(config_path)
517 .parent()
518 .unwrap_or(Path::new("."))
519 .to_path_buf();
520 let opts = RunOptions {
521 validate: false,
522 reconcile: false,
523 resume,
524 force,
525 params: None,
526 };
527
528 // Group exports by wave (ascending; an export with no `wave:` runs last).
529 // The ordering is the contract apply depends on, so it lives in a pure
530 // tested helper rather than hiding inline here.
531 let by_wave = group_exports_by_wave(&config.exports);
532 let total: usize = by_wave.iter().map(|(_, v)| v.len()).sum();
533 if total == 0 {
534 log::warn!("apply: config '{config_path}' defines no exports");
535 return Ok(());
536 }
537
538 // `--parallel` (or `parallel_export_processes: true` in the config) opts into
539 // within-wave parallelism: each wave's exports run as concurrent child
540 // processes (per-child governor keeps each one source-safe), the call blocks
541 // until all exit = the wave barrier. Default stays sequential.
542 let parallel = parallel_cli || config.parallel_export_processes;
543
544 // Compact per-export rendering for the SEQUENTIAL path only. The parallel
545 // (subprocess) path renders the parent card stack itself and each child sees
546 // `exports.len() == 1`, so the flag must stay clear there — matching `run`'s
547 // parallel-processes branch.
548 let prev_multi = MULTI_EXPORT_MODE.swap(total > 1 && !parallel, AtomicOrdering::Relaxed);
549 struct ResetMulti(bool);
550 impl Drop for ResetMulti {
551 fn drop(&mut self) {
552 MULTI_EXPORT_MODE.store(self.0, AtomicOrdering::Relaxed);
553 }
554 }
555 let _reset = ResetMulti(prev_multi);
556
557 let state = StateStore::open(config_path)?;
558 let started_at = chrono::Utc::now();
559 let mut summaries: Vec<RunSummary> = Vec::with_capacity(total);
560 let mut failures: Vec<anyhow::Error> = Vec::new();
561 // Parallel-path accumulators: per-child metrics live in the state DB, so the
562 // parent reconstructs one aggregate from them after every wave has joined.
563 let mut all_exports: Vec<&ExportConfig> = Vec::with_capacity(total);
564 let mut child_failures: std::collections::HashMap<String, String> =
565 std::collections::HashMap::new();
566 let mut combined_stderr = String::new();
567
568 for (wave, exports) in &by_wave {
569 let label = if *wave == u32::MAX {
570 "unscheduled".to_string()
571 } else {
572 wave.to_string()
573 };
574 // Skip-completed under --resume: an export whose destination already has
575 // `_SUCCESS` is done — re-running must not redo it (and would hit the
576 // resume gate). The rest run with `resume`, so an incomplete chunked
577 // export continues from its checkpoint. Reuses `finalize`'s prior-run
578 // probe rather than re-implementing the marker check.
579 let pending: Vec<&ExportConfig> = exports
580 .iter()
581 .copied()
582 .filter(|e| {
583 // Probe the EXPANDED destination, not the raw template. A
584 // templated prefix (`{export}`/`{table}`/`{date}`) never matches a
585 // literal `_SUCCESS` path, so a completed templated export was
586 // never skipped and instead re-ran into the resume gate. Resolve
587 // the same way `rivet run` does at write time (today's UTC date, no
588 // `{run_id}` — a run-unique prefix is fresh every run, so there is
589 // nothing to skip and the literal token correctly never matches).
590 let ctx = crate::destination::placeholder::PlaceholderContext::for_today(&e.name);
591 let expanded = crate::destination::placeholder::expand_destination(
592 e.destination.clone(),
593 &ctx,
594 );
595 let done = resume && finalize::destination_has_success(&expanded);
596 if done {
597 log::info!(
598 "apply: skipping '{}' — destination already complete (_SUCCESS)",
599 e.name
600 );
601 }
602 !done
603 })
604 .collect();
605 if pending.is_empty() {
606 continue;
607 }
608 if total > 1 {
609 println!("\n ── wave {label} · {} export(s) ──", pending.len());
610 }
611 // The wave barrier is the loop itself: each strategy below fully drains
612 // the wave (the sequential loop, or the blocking child-process join)
613 // before the next iteration starts the next wave.
614 if parallel {
615 // Cost safety-gate: within the wave, the cheap (`parallel_safe`)
616 // exports run together in ONE concurrent batch; every heavier export
617 // runs ALONE in its own single-child batch, since a big table already
618 // chunk-parallelizes internally and two at once would overload the
619 // source. The per-child governor still bounds each one; this gate also
620 // bounds the concurrent connection count.
621 let (safe, lone): (Vec<&ExportConfig>, Vec<&ExportConfig>) =
622 pending.iter().copied().partition(|e| is_parallel_safe(e));
623 log::info!(
624 "apply: wave {} — {} parallel-safe export(s) in parallel, {} run alone",
625 label,
626 safe.len(),
627 lone.len()
628 );
629 // One single-child batch per lone export (run sequentially), then
630 // one concurrent batch for all parallel-safe exports.
631 let mut batches: Vec<Vec<&ExportConfig>> = lone.iter().map(|e| vec![*e]).collect();
632 if !safe.is_empty() {
633 batches.push(safe);
634 }
635 // Wave-wide name floor so cards align across the safe/lone batches
636 // (the cost gate splits a wave into one safe batch + N lone batches,
637 // each its own renderer — without a shared floor they'd each pad to
638 // their own widest name and the table would step).
639 let wave_name_floor = pending
640 .iter()
641 .map(|e| e.name.chars().count())
642 .max()
643 .unwrap_or(0);
644 for batch in &batches {
645 let (result, cf, stderr_dump) = parallel_children::run_exports_as_child_processes(
646 config_path,
647 batch,
648 false,
649 false,
650 resume,
651 force,
652 None,
653 wave_name_floor,
654 );
655 child_failures.extend(cf);
656 combined_stderr.push_str(&stderr_dump);
657 if let Err(e) = result {
658 failures.push(e);
659 }
660 }
661 all_exports.extend_from_slice(&pending);
662 } else {
663 log::info!(
664 "apply: wave {} — {} export(s), sequential",
665 label,
666 pending.len()
667 );
668 for export in &pending {
669 let (res, summary) =
670 job::run_export_job(config_path, &config, export, &state, &config_dir, &opts);
671 if let Err(e) = res {
672 failures.push(e);
673 }
674 summaries.push(summary);
675 }
676 }
677 }
678
679 let finished_at = chrono::Utc::now();
680 if total > 1 {
681 let entries = if parallel {
682 aggregate::collect_child_entries(&state, &all_exports, started_at, &child_failures)
683 } else {
684 summaries
685 .iter()
686 .map(aggregate::entry_from_summary)
687 .collect()
688 };
689 let agg = aggregate::build(
690 entries,
691 started_at,
692 finished_at,
693 Some(config_path),
694 if parallel {
695 "wave-parallel-processes"
696 } else {
697 "wave-sequential"
698 },
699 );
700 aggregate::print(&agg);
701 aggregate::persist(&state, &agg, None);
702 }
703 // Captured child stderr (verbose per-export cards, parallel path only) goes
704 // to a file artifact beside the config, with a one-line console pointer.
705 emit_child_stderr(&combined_stderr, &config_dir);
706
707 if !failures.is_empty() {
708 let primary_idx = representative_failure_idx(&failures).unwrap();
709 let primary = failures.remove(primary_idx);
710 if failures.is_empty() {
711 return Err(primary);
712 }
713 let others = failures
714 .iter()
715 .map(|e| format!("{e:#}"))
716 .collect::<Vec<_>>()
717 .join("; ");
718 return Err(primary.context(format!(
719 "{} export(s) failed across waves; representative error follows (also: {others})",
720 failures.len() + 1
721 )));
722 }
723 Ok(())
724}
725
726/// Group exports by `wave:` in ascending order; an export with no `wave:` runs
727/// last (sorted as `u32::MAX`). Pure + unit-tested — the ordering is the
728/// contract `apply` depends on, so it does not hide inside [`run_waves`].
729fn group_exports_by_wave(exports: &[ExportConfig]) -> Vec<(u32, Vec<&ExportConfig>)> {
730 let mut by_wave: std::collections::BTreeMap<u32, Vec<&ExportConfig>> =
731 std::collections::BTreeMap::new();
732 for e in exports {
733 by_wave
734 .entry(e.wave.unwrap_or(u32::MAX))
735 .or_default()
736 .push(e);
737 }
738 by_wave.into_iter().collect()
739}
740
741/// Whether an export may run concurrently with its wave-mates: the
742/// `parallel_safe` flag that `rivet plan` records from the source-aware cost
743/// class (true only for cheap, `Low`-cost tables — see
744/// [`ExportConfig::parallel_safe`]). A heavy table already chunk-parallelizes
745/// internally, so it runs ALONE within its wave; only the cheap exports share a
746/// concurrent batch. `None` (un-planned / hand-written) is treated as not-safe.
747fn is_parallel_safe(export: &ExportConfig) -> bool {
748 export.parallel_safe.unwrap_or(false)
749}
750
751#[cfg(test)]
752mod wave_grouping_tests {
753 use super::{group_exports_by_wave, is_parallel_safe};
754
755 #[test]
756 fn groups_ascending_with_unscheduled_last() {
757 let mut a = crate::config::sample_export("a");
758 a.wave = Some(3);
759 let mut b = crate::config::sample_export("b");
760 b.wave = None; // unscheduled → must sort last
761 let mut c = crate::config::sample_export("c");
762 c.wave = Some(1);
763 let mut d = crate::config::sample_export("d");
764 d.wave = Some(1); // shares wave 1 with c, preserves input order
765
766 let exports = vec![a, b, c, d];
767 let grouped = group_exports_by_wave(&exports);
768
769 let waves: Vec<u32> = grouped.iter().map(|(w, _)| *w).collect();
770 assert_eq!(waves, vec![1, 3, u32::MAX], "ascending, unscheduled last");
771 let wave1: Vec<&str> = grouped[0].1.iter().map(|e| e.name.as_str()).collect();
772 assert_eq!(wave1, vec!["c", "d"], "same-wave keeps input order");
773 assert_eq!(grouped[2].1.len(), 1);
774 assert_eq!(
775 grouped[2].1[0].name, "b",
776 "the no-wave export lands in the last group"
777 );
778 }
779
780 #[test]
781 fn parallel_safe_reads_the_plan_flag() {
782 // default sample_export leaves `parallel_safe` None → not safe
783 let unset = crate::config::sample_export("unset");
784 assert!(!is_parallel_safe(&unset), "None is treated as not-safe");
785
786 let mut safe = crate::config::sample_export("safe");
787 safe.parallel_safe = Some(true);
788 assert!(is_parallel_safe(&safe), "parallel_safe: true → concurrent");
789
790 let mut not_safe = crate::config::sample_export("heavy");
791 not_safe.parallel_safe = Some(false);
792 assert!(!is_parallel_safe(¬_safe), "parallel_safe: false → alone");
793 }
794}
795
796/// Index of the most "stop-worthy" failure in a batch: data-integrity (exit 3)
797/// outranks schema-drift (4), which outranks retryable (2), which outranks
798/// generic (1). The chosen error's typed marker then rides up so `classify_exit`
799/// exits the process on the scariest reason rather than whichever export happened
800/// to fail first. Returns `None` for an empty slice.
801fn representative_failure_idx(failures: &[anyhow::Error]) -> Option<usize> {
802 let rank = |e: &anyhow::Error| match crate::error::classify_exit(e) {
803 c if c == crate::error::ExitClass::DataIntegrity.code() => 3,
804 c if c == crate::error::ExitClass::SchemaDrift.code() => 2,
805 c if c == crate::error::ExitClass::Retryable.code() => 1,
806 _ => 0,
807 };
808 (0..failures.len()).max_by_key(|&i| rank(&failures[i]))
809}
810
811#[cfg(test)]
812mod representative_failure_tests {
813 use super::representative_failure_idx;
814 use crate::error::{DataIntegrityError, ExitClass, SchemaDriftError, classify_exit};
815
816 #[test]
817 fn empty_batch_has_no_representative() {
818 assert_eq!(representative_failure_idx(&[]), None);
819 }
820
821 #[test]
822 fn data_integrity_outranks_everything_regardless_of_position() {
823 // Data-integrity sits LAST so a naive "first failure" or a flipped
824 // min/max selector would pick the generic error instead.
825 let failures = vec![
826 anyhow::anyhow!("generic boom"),
827 SchemaDriftError::new("shape changed").into(),
828 anyhow::anyhow!("another generic"),
829 DataIntegrityError::new("reconcile mismatch").into(),
830 ];
831 let idx = representative_failure_idx(&failures).unwrap();
832 assert_eq!(
833 classify_exit(&failures[idx]),
834 ExitClass::DataIntegrity.code(),
835 "a mixed batch must surface the data-integrity (exit 3) failure"
836 );
837 }
838
839 #[test]
840 fn schema_drift_outranks_retryable_and_generic() {
841 // No data-integrity present → schema-drift (exit 4) is the scariest.
842 let failures = vec![
843 anyhow::anyhow!("generic"),
844 SchemaDriftError::new("drift").into(),
845 ];
846 let idx = representative_failure_idx(&failures).unwrap();
847 assert_eq!(classify_exit(&failures[idx]), ExitClass::SchemaDrift.code());
848 }
849}