Skip to main content

rivet/pipeline/
summary.rs

1//! **Layer: Observability**
2//!
3//! `RunSummary` is the single observability artifact for a pipeline run.
4//! It accumulates operational data during execution and is consumed by:
5//! - the end-of-run terminal output (`print`)
6//! - the metrics store (`state::record_metric`)
7//! - the notification system (`notify::maybe_send`)
8//!
9//! `RunSummary` is written to by execution modules (row counts, byte counts, retries)
10//! but it makes no execution decisions itself — it is a pure data accumulator.
11//!
12//! It embeds a `RunJournal` so that all pipeline modules — which already hold
13//! `&mut RunSummary` — can record structured events via `summary.journal.record()`
14//! without any signature changes.  In a future epic the relationship will invert:
15//! `RunSummary` will be derived from `RunJournal`.
16
17use super::ipc::{self, ChildEvent};
18use super::{format_bytes, multi_export_mode, strip_chunked_recovery_hint};
19use crate::journal::{PlanSnapshot, RunEvent, RunJournal};
20use crate::manifest::ManifestPart;
21use crate::plan::ResolvedRunPlan;
22
23/// Build a `PlanSnapshot` from a `ResolvedRunPlan`.
24///
25/// Lives here rather than on `journal` itself so that the journal module
26/// stays free of plan/pipeline dependencies (avoids the state→pipeline cycle
27/// we used to have via `state::journal_store`).
28fn plan_snapshot_from(plan: &ResolvedRunPlan) -> PlanSnapshot {
29    PlanSnapshot {
30        export_name: plan.export_name.clone(),
31        base_query: plan.base_query.clone(),
32        strategy: plan.strategy.mode_label().to_string(),
33        format: plan.format.label().to_string(),
34        compression: plan.compression.label().to_string(),
35        destination_type: plan.destination.destination_type.label().to_string(),
36        tuning_profile: plan.tuning_profile_label.clone(),
37        batch_size: plan.tuning.batch_size,
38        validate: plan.validate,
39        reconcile: plan.reconcile,
40        resume: plan.resume,
41        chunk_key: plan.strategy.chunk_key().map(|c| c.to_string()),
42        resumable: plan.strategy.is_resumable(),
43    }
44}
45
46/// Context recorded when a run was launched via `rivet apply` rather than
47/// `rivet run`.  Provides the audit trail for **F5**: which plan artifact
48/// was applied, whether `--force` was passed, and which preflight checks
49/// `--force` actually bypassed.
50///
51/// `None` on `RunSummary` means the run came from `rivet run` (no plan
52/// artifact was applied).
53#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
54pub struct ApplyContext {
55    /// `plan_id` from the applied `PlanArtifact`.
56    pub plan_id: String,
57    /// Was `--force` passed to `rivet apply`?
58    pub forced: bool,
59    /// Names of preflight checks that `--force` actually overrode for this
60    /// run.  Possible values: `"staleness"`, `"cursor_drift"`.  Empty when
61    /// `forced` is true but no check actually had to be bypassed (the plan
62    /// was fresh and the cursor matched).
63    pub force_bypassed: Vec<String>,
64}
65
66/// Accumulates operational data during a pipeline run for summary and metrics.
67///
68/// The embedded `journal` is the structured event log for this run.  Use
69/// `summary.journal.record(event)` at any call site that already holds
70/// `&mut RunSummary`.
71#[derive(Debug, Clone, Default)]
72pub struct RunSummary {
73    pub run_id: String,
74    pub export_name: String,
75    pub status: String,
76    pub total_rows: i64,
77    pub files_produced: usize,
78    pub bytes_written: u64,
79    /// Incremented after each successful `dest.write()`. Non-zero means a previous
80    /// attempt already committed data — retrying from the same cursor would duplicate rows.
81    pub files_committed: usize,
82    pub duration_ms: i64,
83    pub peak_rss_mb: i64,
84    pub retries: u32,
85    /// Of `retries`, how many opened a FRESH source connection (a
86    /// reconnect-class error: network reset, "gone away", tunnel drop). The
87    /// field-signal for a flaky link — N reconnects = N blips survived. Distinct
88    /// from a same-connection retry (a lock wait) that `retries` also counts.
89    pub reconnects: u32,
90    /// True when this run picked up a prior CRASHED run instead of starting fresh
91    /// (keyset via the in-progress run_id, chunked via `--resume`). A resume-hit is
92    /// the tell that the previous run died — visible in the run's own log line.
93    pub resumed: bool,
94    pub validated: Option<bool>,
95    pub schema_changed: Option<bool>,
96    pub quality_passed: Option<bool>,
97    /// Form B per-column value checksums (name-keyed), harvested from the sink;
98    /// recorded in the manifest so `validate` re-reads + verifies. Empty = none.
99    pub column_checksums: Vec<crate::manifest::ColumnChecksum>,
100    /// The column the Form B checksum is keyed to (cursor/key column); `None` = un-keyed.
101    pub checksum_key_column: Option<String>,
102    /// Set when a checkpoint RESUME hydrated pre-crash parts into
103    /// `manifest_parts` whose per-column checksum contribution is unrecoverable
104    /// (file_log stores no per-column checksums, and the prior manifest keeps
105    /// only the run-wide XOR, not per-part). The run-wide Form B XOR this run
106    /// harvests would then cover only the re-exported parts while the manifest
107    /// lists ALL parts, so `harvest_column_checksums` SUPPRESSES Form B entirely
108    /// rather than record a partial XOR that `validate --depth full` would flag
109    /// as a false mismatch — mirroring how a rehydrated part's empty md5 degrades
110    /// to a size-only check. An incomplete integrity record must be ABSENT, not
111    /// partial-and-lying.
112    pub column_checksums_incomplete: bool,
113    /// Cursor range this run covered (incremental strategies) — travels to the
114    /// manifest's extraction section for warehouse-side continuity checks.
115    /// v1 ships column + low + high; cursor_type / source_row_count are
116    /// follow-ups (they need extra source plumbing).
117    pub cursor_column: Option<String>,
118    pub cursor_low: Option<String>,
119    pub cursor_high: Option<String>,
120    pub error_message: Option<String>,
121    /// v18 failure forensics. `offending_value`: the last key read before an
122    /// unadvanceable keyset row (with `cursor_high` this brackets the value that
123    /// broke advancing — e.g. a u64 above i64::MAX). `server_context_json`: a JSON
124    /// snapshot of the source server's version + limits captured at OPEN (the
125    /// statement-timeout that surfaces as ERROR 3024 is unexplainable without it).
126    /// Both flow onto the failed `export_metrics` row so a post-mortem needs
127    /// neither the source table nor a still-live server.
128    pub offending_value: Option<String>,
129    pub server_context_json: Option<String>,
130    /// The keyset/chunk key's SOURCE native type (`bigint unsigned`, `numeric(11,4)`),
131    /// resolved by the open probe — folded into `key_descriptor_json` so the "was the
132    /// key unsigned" answer travels on the metric row.
133    pub key_native_type: Option<String>,
134    /// `profile` from YAML, or `balanced (default)` if omitted.
135    pub tuning_profile: String,
136    /// Configured `batch_size` from YAML/profile (FETCH cap before `batch_size_memory_mb` override).
137    pub batch_size: usize,
138    /// When set, actual FETCH size is derived from schema (see logs).
139    pub batch_size_memory_mb: Option<usize>,
140    pub format: String,
141    pub mode: String,
142    pub compression: String,
143    /// Where the files were written, as an operator would type it to find them
144    /// again (`./output`, `s3://bucket/prefix`, …). `None` for stdout or a
145    /// summary built outside the run path (tests). Surfaced on the success card
146    /// so a newcomer isn't left wondering where their data landed.
147    pub destination_uri: Option<String>,
148    /// Postgres `pg_stat_database.temp_bytes` delta around the run. `None` for
149    /// non-Postgres sources or when the snapshot probe failed (no admin perms
150    /// not required — the view is readable by any role). When set and large,
151    /// indicates cursor / sort spill to `pgsql_tmp/` — the safe action is to
152    /// shrink `tuning.batch_size` or set `tuning.batch_size_memory_mb` below
153    /// PG's `work_mem`.
154    pub pg_temp_bytes_delta: Option<i64>,
155    /// Human-readable parenthetical attached to `status: skipped` so the
156    /// operator knows *why* there was nothing to export this run (e.g.
157    /// `"no new rows since cursor 'updated_at'"`). Always `None` when
158    /// `status != "skipped"`. Surfaced in the console summary card as
159    /// `status: skipped (<reason>)`.
160    pub skip_reason: Option<String>,
161    /// Source COUNT(*) result for reconciliation (None = not requested or not applicable).
162    pub source_count: Option<i64>,
163    /// Whether reconciliation passed (Some(true) = match, Some(false) = mismatch, None = skipped).
164    pub reconciled: Option<bool>,
165    /// Committed parts accumulated during the run, in commit order.  Populated by
166    /// `pipeline::manifest_writer::record_committed_part` at each `dest.write`
167    /// site (ADR-0012 M1 — Parts Before Manifest).  Drained at finalize into a
168    /// `RunManifest` by [`crate::pipeline::manifest_writer::write_manifest`].
169    pub manifest_parts: Vec<ManifestPart>,
170    /// xxh3 fingerprint of the dest-facing column schema for this run, in the
171    /// canonical `xxh3:<16-hex>` form produced by [`crate::state::schema_fingerprint`].
172    ///
173    /// Recorded by [`crate::pipeline::manifest_writer::record_run_schema_fingerprint`]
174    /// the first time the sink has resolved a schema (i.e. on the first batch
175    /// of any chunk).  Idempotent within a run — the schema is identical across
176    /// chunks, so later writes are no-ops.
177    ///
178    /// `finalize_manifest` reads this directly so the manifest's
179    /// `schema_fingerprint` no longer depends on the per-export schema row
180    /// happening to land in `state` before the manifest write.  The state
181    /// lookup remains a fallback for resume scenarios where the summary was
182    /// reconstructed without ever seeing a live schema.
183    pub schema_fingerprint: Option<String>,
184    /// Result of the manifest-aware `--validate` pass (ADR-0012 M5/M6,
185    /// ADR-0013).  Populated by `pipeline::job::finalize_validate_manifest`
186    /// after `finalize_manifest` succeeds; `None` when the run targeted a
187    /// streaming destination, when `--validate` was not requested, or when
188    /// the run failed before any manifest could be written.
189    pub manifest_verification: Option<crate::pipeline::ManifestVerification>,
190    /// Apply-time context (plan_id, --force usage, bypassed checks).
191    /// `None` when the run came from `rivet run` rather than `rivet apply`.
192    /// See [`ApplyContext`] and finding **F5** of the 0.7.5 audit.
193    pub apply_context: Option<ApplyContext>,
194    /// Structured event log for this run.  Answers the four DoD observability questions.
195    pub journal: RunJournal,
196}
197
198/// One `(label, value)` line in the rendered summary block. The label is a
199/// fixed string literal; the value is computed per run.
200type Row = (&'static str, String);
201
202impl RunSummary {
203    pub(super) fn new(plan: &ResolvedRunPlan) -> Self {
204        let run_id = format!(
205            "{}_{}",
206            plan.export_name,
207            chrono::Utc::now().format("%Y%m%dT%H%M%S%.3f"),
208        );
209        let mut journal = RunJournal::new(&run_id, &plan.export_name);
210        journal.record(RunEvent::PlanResolved(plan_snapshot_from(plan)));
211
212        ipc::emit_event(&ChildEvent::Started {
213            export_name: plan.export_name.clone(),
214            run_id: run_id.clone(),
215            mode: plan.strategy.mode_label().to_string(),
216            tuning_profile: plan.tuning_profile_label.clone(),
217            batch_size: plan.tuning.batch_size,
218        });
219
220        Self {
221            run_id,
222            export_name: plan.export_name.clone(),
223            status: "running".into(),
224            total_rows: 0,
225            files_produced: 0,
226            bytes_written: 0,
227            files_committed: 0,
228            duration_ms: 0,
229            peak_rss_mb: 0,
230            retries: 0,
231            reconnects: 0,
232            resumed: false,
233            validated: None,
234            schema_changed: None,
235            quality_passed: None,
236            error_message: None,
237            offending_value: None,
238            server_context_json: None,
239            key_native_type: None,
240            tuning_profile: plan.tuning_profile_label.clone(),
241            batch_size: plan.tuning.batch_size,
242            batch_size_memory_mb: plan.tuning.batch_size_memory_mb,
243            format: plan.format.label().to_string(),
244            mode: plan.strategy.mode_label().to_string(),
245            compression: plan.compression.label().to_string(),
246            destination_uri: (!matches!(
247                plan.destination.destination_type,
248                crate::config::DestinationType::Stdout
249            ))
250            .then(|| crate::pipeline::finalize::destination_uri_for_manifest(&plan.destination)),
251            pg_temp_bytes_delta: None,
252            skip_reason: None,
253            source_count: None,
254            reconciled: None,
255            manifest_parts: Vec::new(),
256            schema_fingerprint: None,
257            manifest_verification: None,
258            apply_context: None,
259            column_checksums: Vec::new(),
260            column_checksums_incomplete: false,
261            checksum_key_column: None,
262            cursor_column: None,
263            cursor_low: None,
264            cursor_high: None,
265            journal,
266        }
267    }
268
269    /// One canonical builder for tests across the crate + integration suite.
270    ///
271    /// Every field is filled with a sensible default; callers tweak only what
272    /// the test cares about via the chainable setters below.  This replaces
273    /// the seven copies of `stub_summary` / `fresh_summary` / `make_summary`
274    /// / `dummy_summary` / `empty_summary` that used to live in `notify.rs`,
275    /// `report.rs`, `chunked/{exec,mod}.rs`, `manifest_writer.rs`, and the
276    /// two integration-test files.  When `RunSummary` gains a field, it is
277    /// updated here once instead of across nine sites.
278    ///
279    /// Available outside `pipeline` (`pub` not `pub(crate)`) so integration
280    /// tests in `tests/` can use it via `RunSummary::stub_for_testing(...)`.
281    /// The `_for_testing` suffix is the convention from elsewhere in the
282    /// codebase (`destination_for_tests`, etc.) — production code should
283    /// never call it.
284    ///
285    /// `#[allow(dead_code)]` on each helper because the bin target's
286    /// dead-code analysis doesn't see uses from integration tests in `tests/`.
287    /// The lib's unit tests + the integration suite together exercise every
288    /// helper; the attribute is a no-op for them.
289    #[doc(hidden)]
290    #[allow(dead_code)]
291    pub fn stub_for_testing(run_id: impl Into<String>, export_name: impl Into<String>) -> Self {
292        let run_id = run_id.into();
293        let export_name = export_name.into();
294        let journal = RunJournal::new(&run_id, &export_name);
295        Self {
296            run_id,
297            export_name,
298            status: "running".into(),
299            total_rows: 0,
300            files_produced: 0,
301            bytes_written: 0,
302            files_committed: 0,
303            duration_ms: 0,
304            peak_rss_mb: 0,
305            retries: 0,
306            reconnects: 0,
307            resumed: false,
308            validated: None,
309            schema_changed: None,
310            quality_passed: None,
311            error_message: None,
312            offending_value: None,
313            server_context_json: None,
314            key_native_type: None,
315            tuning_profile: "balanced".into(),
316            batch_size: 1000,
317            batch_size_memory_mb: None,
318            format: "parquet".into(),
319            mode: "snapshot".into(),
320            compression: "zstd".into(),
321            destination_uri: None,
322            pg_temp_bytes_delta: None,
323            skip_reason: None,
324            source_count: None,
325            reconciled: None,
326            manifest_parts: Vec::new(),
327            schema_fingerprint: None,
328            manifest_verification: None,
329            apply_context: None,
330            column_checksums: Vec::new(),
331            column_checksums_incomplete: false,
332            checksum_key_column: None,
333            cursor_column: None,
334            cursor_low: None,
335            cursor_high: None,
336            journal,
337        }
338    }
339
340    /// Test-only chainable setter for the run's status field.
341    ///
342    /// Used to build success/failed/running variants without re-listing every
343    /// field.  Keeps the journal in sync: terminal statuses get a matching
344    /// `RunCompleted` event recorded so consumers reading
345    /// `journal.final_outcome()` see the right shape.
346    #[doc(hidden)]
347    #[allow(dead_code)]
348    pub fn with_status(mut self, status: impl Into<String>) -> Self {
349        let s = status.into();
350        if (s == "success" || s == "failed") && self.journal.final_outcome().is_none() {
351            self.journal.record(RunEvent::RunCompleted {
352                status: s.clone(),
353                error_message: self.error_message.clone(),
354                duration_ms: self.duration_ms,
355            });
356        }
357        self.status = s;
358        self
359    }
360
361    /// Test-only setter — record `files_committed` so resume-hint logic
362    /// (`pipeline::report`) can detect the "failed run with committed files"
363    /// path that produces a resume command.
364    #[doc(hidden)]
365    #[allow(dead_code)]
366    pub fn with_files_committed(mut self, n: usize) -> Self {
367        self.files_committed = n;
368        self
369    }
370
371    /// Test-only setter — replace the recorded manifest parts (and adjust
372    /// `total_rows` / `bytes_written` / `files_produced` to keep them
373    /// consistent with the parts list, the way real production code does).
374    #[doc(hidden)]
375    #[allow(dead_code)]
376    pub fn with_manifest_parts(mut self, parts: Vec<crate::manifest::ManifestPart>) -> Self {
377        self.total_rows = parts.iter().map(|p| p.rows).sum();
378        self.bytes_written = parts.iter().map(|p| p.size_bytes).sum();
379        self.files_produced = parts.len();
380        self.files_committed = parts.len();
381        self.manifest_parts = parts;
382        self
383    }
384
385    /// Test-only setter — error_message + (optionally) status.  Common
386    /// shape for the "failed-run" fixtures that populated 4+ existing
387    /// stubs.
388    #[doc(hidden)]
389    #[allow(dead_code)]
390    pub fn with_error(mut self, msg: impl Into<String>) -> Self {
391        self.error_message = Some(msg.into());
392        self
393    }
394
395    /// Test-only setter — record a PlanResolved event in the journal so
396    /// downstream observability paths (`journal.plan_snapshot()`,
397    /// `RunReport::from_summary` plan_origin lookup) see the same shape
398    /// they would on a real run.
399    #[doc(hidden)]
400    #[allow(dead_code)]
401    pub fn with_plan_snapshot(mut self, snap: PlanSnapshot) -> Self {
402        self.journal.record(RunEvent::PlanResolved(snap));
403        self
404    }
405
406    pub(super) fn print(&self) {
407        // Capturing mode (IPC child or in-process channel): emit a
408        // `Finished` event and let the unified UI thread render the card.
409        // No stderr block here — the renderer owns the screen.
410        if ipc::capturing_events() {
411            ipc::emit_event(&ChildEvent::Finished {
412                export_name: self.export_name.clone(),
413                run_id: self.run_id.clone(),
414                status: self.status.clone(),
415                total_rows: self.total_rows,
416                files_produced: self.files_produced as u64,
417                bytes_written: self.bytes_written,
418                duration_ms: self.duration_ms,
419                peak_rss_mb: self.peak_rss_mb,
420                error_message: self.error_message.clone(),
421            });
422            return;
423        }
424
425        self.print_stderr_block();
426    }
427
428    /// Write the per-export summary block to stderr, bypassing IPC capture.
429    /// Used after the in-process card UI finishes on single-export sequential
430    /// runs so operators still get the detailed `── name ──` block below the
431    /// live card line.
432    pub(super) fn print_stderr_block(&self) {
433        let block = if multi_export_mode() {
434            self.render_compact()
435        } else {
436            // Render the whole block into a single buffer so the call site
437            // emits one `write_all` to stderr.  Without this, parallel
438            // exports could interleave individual lines from different
439            // `RunSummary::print()` calls — visible as garbled blocks in
440            // `--parallel-exports` runs.
441            self.render().trim_end_matches('\n').to_string()
442        };
443
444        use std::io::Write;
445        // V9 (CWE-150): the block embeds error_message, which can carry
446        // attacker-controlled ANSI/OSC escapes from a malicious source DB. The
447        // single-export path reaches the operator terminal here (the parallel
448        // renderer sanitises separately). Funnel the whole block through the
449        // shared sanitiser before write — it preserves the renderer's own
450        // multi-byte glyphs (✓/✗/──) and strips only C0/C1/DEL control bytes.
451        let mut buf = super::parent_ui::sanitize_terminal(&block);
452        buf.push('\n');
453        let stderr = std::io::stderr();
454        let mut handle = stderr.lock();
455        let _ = handle.write_all(buf.as_bytes());
456        let _ = handle.flush();
457    }
458
459    /// Compact one-line summary used when several exports run in the same
460    /// invocation.  Mirrors the parent_ui card line so `--parallel-exports`
461    /// (threads), sequential, and `--parallel-export-processes` (processes)
462    /// produce visually consistent per-export rows.
463    fn render_compact(&self) -> String {
464        const NAME_COL: usize = 22;
465        const MODE_COL: usize = 8;
466        let icon = match self.status.as_str() {
467            "success" => "✓",
468            "failed" => "✗",
469            _ => "•",
470        };
471        let body = if self.status == "failed" {
472            let err = self
473                .error_message
474                .as_deref()
475                .unwrap_or("(no error message recorded)");
476            let (cause, _) = strip_chunked_recovery_hint(err);
477            // Collapse multi-line / extremely long errors so the compact
478            // line stays one row tall.  Full payload lives in the stderr
479            // log above the run summary.
480            compact_error(cause)
481        } else {
482            let rss = if self.peak_rss_mb > 0 {
483                format!("  RSS {} MB", fmt_thousands(self.peak_rss_mb))
484            } else {
485                String::new()
486            };
487            format!(
488                "{} rows  {} files  {}  {}{}",
489                fmt_thousands(self.total_rows),
490                fmt_thousands(self.files_produced as i64),
491                format_bytes(self.bytes_written),
492                fmt_duration_ms(self.duration_ms),
493                rss
494            )
495        };
496        format!(
497            "{} {:<name$}  {:<mode$}  {}",
498            icon,
499            self.export_name,
500            self.mode,
501            body,
502            name = NAME_COL,
503            mode = MODE_COL,
504        )
505    }
506
507    /// Build the block as a string.  Module-private so tests can assert
508    /// formatting without capturing stderr.
509    ///
510    /// Adaptive layout: assemble the `(label, value)` rows that apply to this
511    /// run from a flat manifest of per-row providers, then [`format_block`]
512    /// pads labels to the longest so columns line up *within* the block (the
513    /// header is fixed-width so consecutive blocks stay uniform regardless of
514    /// which optional fields are present). Each provider owns one row's gate +
515    /// formatting and is unit-testable in isolation; this method owns only
516    /// their order — completing the pattern begun by [`incremental_position_line`]
517    /// / [`time_window_skip_line`].
518    fn render(&self) -> String {
519        let mut rows: Vec<Row> = Vec::with_capacity(16);
520        rows.push(("run_id", self.run_id.clone()));
521        rows.push(self.status_row());
522        rows.push(self.tuning_row());
523        rows.push(("rows", fmt_thousands(self.total_rows)));
524        rows.push(("files", fmt_thousands(self.files_produced as i64)));
525        rows.extend(self.output_row());
526        rows.extend(self.position_row());
527        rows.extend(self.bytes_row());
528        rows.push(("duration", fmt_duration_ms(self.duration_ms)));
529        rows.extend(self.peak_rss_row());
530        rows.extend(self.pg_temp_spill_row());
531        rows.extend(self.compression_row());
532        rows.extend(self.retries_row());
533        rows.extend(self.outcome_rows());
534        rows.extend(self.error_row());
535        format_block(&self.export_name, &rows)
536    }
537
538    /// `status`, annotated with the skip reason when the run was skipped.
539    fn status_row(&self) -> Row {
540        let value = match (&self.status, &self.skip_reason) {
541            (s, Some(reason)) if s == "skipped" => format!("{s} ({reason})"),
542            (s, _) => s.clone(),
543        };
544        ("status", value)
545    }
546
547    /// `tuning` — profile + configured batch_size, noting the memory-derived
548    /// FETCH override when `batch_size_memory_mb` is set.
549    fn tuning_row(&self) -> Row {
550        let value = match self.batch_size_memory_mb {
551            Some(mem) => format!(
552                "profile={}, batch_size={} (batch_size_memory_mb={}MiB → effective FETCH in logs)",
553                self.tuning_profile,
554                fmt_thousands(self.batch_size as i64),
555                mem
556            ),
557            None => format!(
558                "profile={}, batch_size={}",
559                self.tuning_profile,
560                fmt_thousands(self.batch_size as i64)
561            ),
562        };
563        ("tuning", value)
564    }
565
566    /// `output` — where the files landed, so a newcomer isn't left guessing
567    /// after a successful export. Only when we actually wrote somewhere
568    /// addressable (skips stdout and 0-file skips).
569    fn output_row(&self) -> Option<Row> {
570        if self.files_produced > 0 {
571            self.destination_uri.clone().map(|uri| ("output", uri))
572        } else {
573            None
574        }
575    }
576
577    /// `cursor` (incremental) xor `window` (time_window): the position line for
578    /// a 0-row run. On a bare `0 rows  0 files` run this tells the operator the
579    /// incremental boundary held / the rolling window was simply empty, rather
580    /// than leaving an empty run indistinguishable from a misconfigured one.
581    /// `None` for a run that produced rows or whose skip carries no position.
582    /// (Detail lives in the two helpers it wraps.)
583    fn position_row(&self) -> Option<Row> {
584        if let Some(pos) = incremental_position_line(self.skip_reason.as_deref()) {
585            Some(("cursor", pos))
586        } else {
587            time_window_skip_line(&self.mode, self.skip_reason.as_deref()).map(|w| ("window", w))
588        }
589    }
590
591    /// `bytes` — only when something was written.
592    fn bytes_row(&self) -> Option<Row> {
593        if self.bytes_written > 0 {
594            Some(("bytes", format_bytes(self.bytes_written)))
595        } else {
596            None
597        }
598    }
599
600    /// `peak RSS` — only when sampled during the run.
601    fn peak_rss_row(&self) -> Option<Row> {
602        if self.peak_rss_mb > 0 {
603            Some((
604                "peak RSS",
605                format!(
606                    "{} MB (sampled during run)",
607                    fmt_thousands(self.peak_rss_mb)
608                ),
609            ))
610        } else {
611            None
612        }
613    }
614
615    /// `pg temp spill` — PostgreSQL temp-file spill around the run. Chatters
616    /// only on actual spill (`> 0`); annotates a tuning hint above 100 MB.
617    /// `None` for non-Postgres sources, a failed probe, or no spill.
618    fn pg_temp_spill_row(&self) -> Option<Row> {
619        let temp = self.pg_temp_bytes_delta?;
620        if temp <= 0 {
621            return None;
622        }
623        let temp_mb = temp as f64 / (1024.0 * 1024.0);
624        let label = if temp > 100 * 1024 * 1024 {
625            format!(
626                "{:.1} MB ⚠ shrink tuning.batch_size or set batch_size_memory_mb",
627                temp_mb
628            )
629        } else {
630            format!("{:.1} MB", temp_mb)
631        };
632        Some(("pg temp spill", label))
633    }
634
635    /// `compression` — only when it differs from the parquet default (zstd).
636    fn compression_row(&self) -> Option<Row> {
637        if self.format == "parquet" && self.compression != "zstd" {
638            Some(("compression", self.compression.clone()))
639        } else {
640            None
641        }
642    }
643
644    /// `retries` — only when the run had to retry.
645    fn retries_row(&self) -> Option<Row> {
646        if self.retries > 0 {
647            Some(("retries", self.retries.to_string()))
648        } else {
649            None
650        }
651    }
652
653    /// Post-extraction check outcomes: `--validate`, schema-drift, the quality
654    /// gate, `--reconcile`, plus the advisory verify nudge. Grouped so the
655    /// nudge's "ran neither verification pass" gate sits next to the very
656    /// fields it inspects — when it was added (#4) it landed as a 10-line block
657    /// appended to a flat ladder; here that dependency is local.
658    fn outcome_rows(&self) -> Vec<Row> {
659        let mut rows: Vec<Row> = Vec::new();
660        if let Some(v) = self.validated {
661            rows.push(("validated", if v { "pass".into() } else { "FAIL".into() }));
662        }
663        if let Some(sc) = self.schema_changed {
664            rows.push((
665                "schema",
666                if sc {
667                    "CHANGED".into()
668                } else {
669                    "unchanged".into()
670                },
671            ));
672        }
673        if let Some(q) = self.quality_passed {
674            rows.push(("quality", if q { "pass".into() } else { "FAIL".into() }));
675        }
676        if let Some(reconciled) = self.reconciled {
677            let src = self
678                .source_count
679                .map(fmt_thousands)
680                .unwrap_or_else(|| "?".into());
681            let exported = fmt_thousands(self.total_rows);
682            let value = if reconciled {
683                format!("MATCH ({exported}/{src})")
684            } else {
685                format!("MISMATCH (exported {exported} vs source {src})")
686            };
687            rows.push(("reconcile", value));
688        }
689        // Nudge: a successful run that wrote files but ran neither verification
690        // pass leaves completeness unconfirmed. Advisory only — so skipping
691        // verification is a deliberate choice, not an oversight (a pilot loaded
692        // hundreds of millions of rows across 5 runs with 0 verified).
693        if self.status == "success"
694            && self.files_produced > 0
695            && self.validated.is_none()
696            && self.reconciled.is_none()
697        {
698            rows.push((
699                "verify",
700                "not run — add `--reconcile` (count vs source) or `rivet validate` (re-read outputs)"
701                    .into(),
702            ));
703        }
704        rows
705    }
706
707    /// `error` — the failure message, with its own multi-line structure
708    /// preserved (the detailed block indents continuation lines under the
709    /// value column; flattening to `"; "`-joined text is the compact
710    /// one-liner's job, not this one's — a quality failure's multi-line
711    /// `failed:\n  - <check>\n  Fix …` stays readable here).
712    fn error_row(&self) -> Option<Row> {
713        self.error_message
714            .as_ref()
715            .map(|err| ("error", err.trim_end().to_string()))
716    }
717
718    /// Sanity-check the post-run summary ↔ manifest_parts coherence. Used as
719    /// a `debug_assert!`-style runtime gate from `finalize_manifest` so any
720    /// future runner that bumps `bytes_written` / `files_committed` /
721    /// `files_produced` without going through `pipeline::commit::record_part`
722    /// is caught the moment it finishes a real export. Compiled out in
723    /// release builds via the `cfg!(debug_assertions)` guard at the call site.
724    ///
725    /// **Resume-safe inequalities only**: on resume, `manifest_parts` carries
726    /// prior runs' parts via `chunked::resume_m8` while `bytes_written` /
727    /// `files_committed` reflect only the current invocation — so strict
728    /// equality is wrong across resume boundaries. Strict equality on the
729    /// non-resume path is pinned by `pipeline::commit::tests`.
730    ///
731    /// Returns `Ok(())` when the summary satisfies the invariants, else an
732    /// `Err(String)` naming which one was violated and by how much.
733    pub fn check_post_run_invariants(&self) -> Result<(), String> {
734        let parts_bytes: u64 = self.manifest_parts.iter().map(|p| p.size_bytes).sum();
735
736        if self.files_committed > self.manifest_parts.len() {
737            return Err(format!(
738                "summary.files_committed ({}) > manifest_parts.len() ({}) — \
739                 a runner bumped files_committed without commit::record_part",
740                self.files_committed,
741                self.manifest_parts.len()
742            ));
743        }
744        if self.files_produced > self.manifest_parts.len() {
745            return Err(format!(
746                "summary.files_produced ({}) > manifest_parts.len() ({}) — \
747                 a runner bumped files_produced without commit::record_part",
748                self.files_produced,
749                self.manifest_parts.len()
750            ));
751        }
752        if self.bytes_written > parts_bytes {
753            return Err(format!(
754                "summary.bytes_written ({}) > sum(manifest_parts.size_bytes) ({}) — \
755                 a runner bumped bytes_written without commit::record_part",
756                self.bytes_written, parts_bytes
757            ));
758        }
759        if self.status == "success" && self.files_committed > 0 && self.manifest_parts.is_empty() {
760            return Err(format!(
761                "success run with files_committed={} has empty manifest_parts — \
762                 cloud manifest (ADR-0012 M1) would ship with no part list \
763                 (this is the gap parallel_checkpoint had before commit e9b0796)",
764                self.files_committed
765            ));
766        }
767        // Invariant audit gap #1, weak form: a successful run that produced
768        // rows for THIS invocation must have committed at least one file.
769        // The strict form ("rows_written <= rows_read") would require a
770        // separate source-side row counter we do not track, and concurrent
771        // INSERTs on the source (live_oltp_load) make a source_count
772        // comparison brittle. This weak form catches a fabrication shape:
773        // total_rows accumulated but nothing reached the destination — a
774        // runner that fetched and silently dropped rows produces exactly
775        // this signature. Resume-safe: total_rows reflects only this
776        // invocation, so a resume with no work to do legitimately ends at
777        // total_rows=0 / files_committed=0 and the guard does not fire.
778        if self.status == "success" && self.total_rows > 0 && self.files_committed == 0 {
779            return Err(format!(
780                "summary.total_rows={} but files_committed=0 — rows extracted from \
781                 source but no files committed (no output reached the destination)",
782                self.total_rows
783            ));
784        }
785        Ok(())
786    }
787}
788
789/// Reduce a possibly-multi-line execution error to a single-line, bounded-
790/// length cause suitable for the per-export summary block and the compact
791/// one-liner.  Keeps the user-actionable bit and drops noisy diagnostic
792/// payloads (long URLs, query strings, repeated chunk errors).
793///
794/// Recognised shapes:
795/// - `parallel checkpoint worker errors:\nchunk N: <msg>\nchunk M: <msg>` →
796///   `parallel checkpoint workers failed: K chunk(s) (chunk N: <truncated>)`.
797///   The full per-chunk detail is already in stderr logs.
798/// - Generic multi-line: newlines are replaced with `; ` and the result is
799///   clamped to 240 characters with an ellipsis.
800fn compact_error(raw: &str) -> String {
801    const MAX_CHARS: usize = 240;
802    if let Some(summary) = summarize_parallel_chunk_errors(raw) {
803        return clamp_chars(&summary, MAX_CHARS);
804    }
805    let collapsed: String = raw
806        .lines()
807        .map(str::trim_end)
808        .filter(|s| !s.is_empty())
809        .collect::<Vec<_>>()
810        .join("; ");
811    clamp_chars(&collapsed, MAX_CHARS)
812}
813
814/// Derive the summary block's `cursor:` line from a `skip_reason`.
815///
816/// `skip_reason` for an incremental no-op is `"no new rows since cursor
817/// '<col>'"` (set by the runner); we lift the column out and report the
818/// position as held. Returns `None` for the non-cursor `"source returned 0
819/// rows"` skip and for `None` (a run that actually produced rows). The cursor
820/// *value* isn't carried on the summary yet, so this is column-level only.
821fn incremental_position_line(skip_reason: Option<&str>) -> Option<String> {
822    let col = skip_reason?
823        .strip_prefix("no new rows since cursor '")?
824        .strip_suffix('\'')?;
825    Some(format!("'{col}' unchanged (no new rows this run)"))
826}
827
828/// Derive the summary block's `window:` line for a time_window run that
829/// returned nothing.
830///
831/// A `TimeWindow` strategy has no cursor column, so a 0-row run reports the
832/// generic `"source returned 0 rows"` skip — the `incremental_position_line`
833/// branch never fires and, without this line, an empty time window looks
834/// identical to any other empty export. Surfacing it lets the operator tell an
835/// *empty window* (data simply outside the rolling range) from a *misconfigured
836/// window* (wrong `time_column` / `days_window`).
837///
838/// Keyed on `mode == "timewindow"` (set from `ExtractionStrategy::mode_label`)
839/// plus a set skip reason, so it only fires on a skipped time_window run and
840/// never on incremental/snapshot/chunked/keyset. The window column, days, and
841/// computed lower bound are not carried on `RunSummary`, so this reports the
842/// strategy-level fact and where to look — the concrete bound is a follow-up
843/// once the runner records it onto the summary.
844fn time_window_skip_line(mode: &str, skip_reason: Option<&str>) -> Option<String> {
845    skip_reason?;
846    if mode != "timewindow" {
847        return None;
848    }
849    Some("rolling time window matched no rows — check `time_column`/`days_window`".to_string())
850}
851
852fn summarize_parallel_chunk_errors(raw: &str) -> Option<String> {
853    let header_pos = raw.find("parallel checkpoint worker errors:")?;
854    let prefix = raw[..header_pos].trim_end_matches(": ").trim_end();
855    let tail = &raw[header_pos + "parallel checkpoint worker errors:".len()..];
856
857    let chunk_lines: Vec<&str> = tail
858        .lines()
859        .map(str::trim)
860        .filter(|l| l.starts_with("chunk "))
861        .collect();
862    if chunk_lines.is_empty() {
863        return None;
864    }
865    let first_chunk_full = chunk_lines[0];
866    // Truncate the example chunk message; the URL/payload is in stderr logs.
867    let first_chunk_short = clamp_chars(first_chunk_full, 140);
868    let prefix = if prefix.is_empty() {
869        String::new()
870    } else {
871        format!("{}: ", prefix)
872    };
873    Some(format!(
874        "{}parallel checkpoint workers failed: {} chunk(s) ({}); see stderr for full payloads",
875        prefix,
876        chunk_lines.len(),
877        first_chunk_short
878    ))
879}
880
881fn clamp_chars(s: &str, max_chars: usize) -> String {
882    if max_chars == 0 {
883        return String::new();
884    }
885    if s.chars().count() <= max_chars {
886        return s.to_string();
887    }
888    let keep = max_chars.saturating_sub(1);
889    let mut out: String = s.chars().take(keep).collect();
890    out.push('…');
891    out
892}
893
894/// Render a `── name ─────…─` header plus one indented `label:  value` line
895/// per row, all joined into a single string ending with `\n`.
896fn format_block(name: &str, rows: &[(&str, String)]) -> String {
897    const HEADER_WIDTH: usize = 60;
898    let label_w = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
899
900    let prefix = format!("── {} ", name);
901    let prefix_chars = prefix.chars().count();
902    let dashes = HEADER_WIDTH.saturating_sub(prefix_chars);
903    let mut out = String::with_capacity(HEADER_WIDTH * (rows.len() + 3));
904    out.push('\n');
905    out.push_str(&prefix);
906    for _ in 0..dashes {
907        out.push('─');
908    }
909    out.push('\n');
910    // Continuation lines of a multi-line value (e.g. the multi-line `error`
911    // row) are indented to align under the value column, so block-shaped
912    // messages stay readable instead of being flattened onto one line.
913    let value_indent = " ".repeat(2 + (label_w + 1) + 2);
914    for (label, value) in rows {
915        // `label_w + 1` so the colon stays attached to the label and the
916        // value column starts uniformly two spaces after it.
917        let mut lines = value.split('\n');
918        let first = lines.next().unwrap_or("");
919        out.push_str(&format!(
920            "  {:<width$}  {}\n",
921            format!("{label}:"),
922            first,
923            width = label_w + 1
924        ));
925        for cont in lines {
926            out.push_str(&value_indent);
927            out.push_str(cont);
928            out.push('\n');
929        }
930    }
931    out
932}
933
934fn fmt_duration_ms(ms: i64) -> String {
935    if ms < 1000 {
936        return format!("{}ms", ms);
937    }
938    let total_secs = ms / 1000;
939    let h = total_secs / 3600;
940    let m = (total_secs % 3600) / 60;
941    let s_frac = (ms % 60_000) as f64 / 1000.0;
942    if h > 0 {
943        format!("{}h {:02}m {:04.1}s", h, m, s_frac)
944    } else if m > 0 {
945        format!("{}m {:04.1}s", m, s_frac)
946    } else {
947        format!("{:.1}s", ms as f64 / 1000.0)
948    }
949}
950
951/// Format integers with a comma every three digits.  Negative values keep
952/// their sign.  Used for rows / files / batch_size so large numbers stay
953/// readable: `39_990_376` → `39,990,376`.
954fn fmt_thousands(n: i64) -> String {
955    let abs = n.unsigned_abs();
956    let s = abs.to_string();
957    let bytes = s.as_bytes();
958    let mut out = String::with_capacity(s.len() + s.len() / 3 + 1);
959    if n < 0 {
960        out.push('-');
961    }
962    for (i, b) in bytes.iter().enumerate() {
963        let from_end = bytes.len() - i;
964        if i > 0 && from_end.is_multiple_of(3) {
965            out.push(',');
966        }
967        out.push(*b as char);
968    }
969    out
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975
976    #[test]
977    fn fmt_thousands_handles_small_and_large() {
978        assert_eq!(fmt_thousands(0), "0");
979        assert_eq!(fmt_thousands(7), "7");
980        assert_eq!(fmt_thousands(999), "999");
981        assert_eq!(fmt_thousands(1_000), "1,000");
982        assert_eq!(fmt_thousands(1_000_908), "1,000,908");
983        assert_eq!(fmt_thousands(39_990_376), "39,990,376");
984        assert_eq!(fmt_thousands(-1_234), "-1,234");
985        assert_eq!(fmt_thousands(i64::MAX), "9,223,372,036,854,775,807");
986    }
987
988    #[test]
989    fn fmt_duration_picks_unit() {
990        assert_eq!(fmt_duration_ms(0), "0ms");
991        assert_eq!(fmt_duration_ms(800), "800ms");
992        assert_eq!(fmt_duration_ms(1_500), "1.5s");
993        assert_eq!(fmt_duration_ms(68_400), "1m 08.4s");
994        assert_eq!(fmt_duration_ms(3_725_300), "1h 02m 05.3s");
995    }
996
997    #[test]
998    fn format_block_pads_labels_uniformly() {
999        let rows = vec![
1000            ("run_id", "abc".to_string()),
1001            ("rows", "42".to_string()),
1002            ("compression", "zstd".to_string()),
1003        ];
1004        let out = format_block("orders", &rows);
1005
1006        // Each value column starts at the same character position.
1007        let lines: Vec<&str> = out.lines().filter(|l| l.contains(':')).collect();
1008        assert_eq!(lines.len(), 3);
1009        let value_starts: Vec<usize> = lines
1010            .iter()
1011            .map(|l| l.find(':').unwrap() + l[l.find(':').unwrap()..].find(' ').unwrap())
1012            .collect();
1013        // The value (after `label:` plus padding plus two spaces) starts at the
1014        // same column for every row.  We verify by checking all lines have the
1015        // value substring at the same byte offset.
1016        let value_col = lines[0].rfind("abc").unwrap();
1017        assert_eq!(lines[1].rfind("42").unwrap(), value_col);
1018        assert_eq!(lines[2].rfind("zstd").unwrap(), value_col);
1019        // Sanity: silence unused.
1020        let _ = value_starts;
1021    }
1022
1023    #[test]
1024    fn format_block_header_has_consistent_width() {
1025        let block_a = format_block("a", &[("rows", "1".into())]);
1026        let block_b = format_block("orders_table_xyz", &[("rows", "1".into())]);
1027        let header_a = block_a.lines().nth(1).unwrap();
1028        let header_b = block_b.lines().nth(1).unwrap();
1029        assert_eq!(
1030            header_a.chars().count(),
1031            header_b.chars().count(),
1032            "headers must be the same width regardless of name length: {:?} vs {:?}",
1033            header_a,
1034            header_b
1035        );
1036    }
1037
1038    #[test]
1039    fn render_produces_a_single_string_with_trailing_newline() {
1040        use crate::plan::{
1041            CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1042            MetaColumns, ResolvedRunPlan,
1043        };
1044        use crate::tuning::SourceTuning;
1045        let plan = ResolvedRunPlan {
1046            export_name: "orders".into(),
1047            base_query: "SELECT 1".into(),
1048            strategy: ExtractionStrategy::Snapshot,
1049            format: FormatType::Parquet,
1050            compression: CompressionType::default(),
1051            compression_level: None,
1052            max_file_size_bytes: None,
1053            skip_empty: false,
1054            meta_columns: MetaColumns::default(),
1055            destination: DestinationConfig {
1056                destination_type: DestinationType::Local,
1057                path: Some("./out".into()),
1058                ..Default::default()
1059            },
1060            quality: None,
1061            tuning: SourceTuning::from_config(None),
1062            tuning_profile_label: "balanced (default)".into(),
1063            validate: false,
1064            reconcile: false,
1065            resume: false,
1066            source: crate::config::SourceConfig {
1067                source_type: crate::config::SourceType::Postgres,
1068                url: Some("postgresql://localhost/test".into()),
1069                url_env: None,
1070                url_file: None,
1071                host: None,
1072                port: None,
1073                user: None,
1074                password: None,
1075                password_env: None,
1076                database: None,
1077                environment: None,
1078                tuning: None,
1079                tls: None,
1080                mongo: None,
1081            },
1082            column_overrides: Default::default(),
1083            verify: crate::config::VerifyMode::Size,
1084            schema_drift_policy: Default::default(),
1085            shape_drift_warn_factor: 2.0,
1086            parquet: None,
1087        };
1088        let mut s = RunSummary::new(&plan);
1089        s.status = "success".into();
1090        s.total_rows = 1_000_908;
1091        s.files_produced = 11;
1092        s.bytes_written = 32 * 1024 * 1024 + 400 * 1024;
1093        s.duration_ms = 68_400;
1094        s.peak_rss_mb = 884;
1095
1096        let block = s.render();
1097        assert!(
1098            block.starts_with('\n'),
1099            "block should start with a blank line"
1100        );
1101        assert!(block.ends_with('\n'), "block should end with a newline");
1102        assert!(block.contains("── orders "));
1103        assert!(
1104            block.contains("1,000,908"),
1105            "rows should be formatted with thousands separator: {}",
1106            block
1107        );
1108        assert!(block.contains("1m 08.4s"), "duration formatting: {}", block);
1109        // No raw progress-bar bleed: header dashes still present, no carriage
1110        // returns or escape sequences.
1111        assert!(!block.contains('\r'));
1112
1113        // Compact one-liner used in multi-export runs.
1114        let line = s.render_compact();
1115        assert!(line.starts_with("✓ "), "success icon present: {:?}", line);
1116        assert!(line.contains("orders"), "export name present: {:?}", line);
1117        assert!(line.contains("1,000,908 rows"), "rows present: {:?}", line);
1118        assert!(line.contains("32.4 MB"), "bytes present: {:?}", line);
1119        assert!(line.contains("1m 08.4s"), "duration present: {:?}", line);
1120        assert!(line.contains("RSS 884 MB"), "rss present: {:?}", line);
1121        assert!(!line.contains('\n'), "single line: {:?}", line);
1122    }
1123
1124    #[test]
1125    fn compact_error_summarises_parallel_chunk_errors() {
1126        let raw = "export 'page_views': parallel checkpoint worker errors:\n\
1127                   chunk 4: Unexpected (temporary) at write, context: { url: https://storage.googleapis.com/rivet_data_test/exports%2Fpage_views%2Fpage_views_20260430_202442_chunk4.parquet?partNumber=1&uploadId=ABPnzm7RqplA, called: http_util::Client::send } => send http request, source: error sending request: client error (SendRequest): dispatch task is gone\n\
1128                   chunk 5: Unexpected (temporary) at write, context: { url: https://storage.googleapis.com/rivet_data_test/exports%2Fpage_views%2Fpage_views_20260430_202443_chunk5.parquet?partNumber=1&uploadId=ABPnzm6q, called: http_util::Client::send } => send http request, source: dispatch task is gone";
1129        let out = compact_error(raw);
1130        assert!(
1131            out.contains("2 chunk(s)"),
1132            "should report number of failed chunks: {:?}",
1133            out
1134        );
1135        assert!(
1136            out.starts_with("export 'page_views': parallel checkpoint workers failed:"),
1137            "should keep export prefix and use compact phrasing: {:?}",
1138            out
1139        );
1140        assert!(
1141            out.contains("chunk 4:"),
1142            "should include the first chunk as an example: {:?}",
1143            out
1144        );
1145        assert!(!out.contains('\n'), "single line output: {:?}", out);
1146        assert!(
1147            out.chars().count() <= 240,
1148            "must be clamped to <=240 chars, got {}: {:?}",
1149            out.chars().count(),
1150            out
1151        );
1152    }
1153
1154    #[test]
1155    fn compact_error_collapses_generic_multiline() {
1156        let raw = "first line of trouble\nsecond line with detail\n\nthird line\n";
1157        let out = compact_error(raw);
1158        assert_eq!(
1159            out, "first line of trouble; second line with detail; third line",
1160            "newlines should collapse to '; ' and blanks dropped"
1161        );
1162    }
1163
1164    #[test]
1165    fn compact_error_clamps_excessively_long_lines() {
1166        let raw = "x".repeat(1_000);
1167        let out = compact_error(&raw);
1168        assert_eq!(out.chars().count(), 240);
1169        assert!(out.ends_with('…'));
1170    }
1171
1172    #[test]
1173    fn render_compact_strips_chunked_recovery_hint_for_failed() {
1174        use crate::plan::{
1175            CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1176            MetaColumns, ResolvedRunPlan,
1177        };
1178        use crate::tuning::SourceTuning;
1179        let plan = ResolvedRunPlan {
1180            export_name: "events".into(),
1181            base_query: "SELECT 1".into(),
1182            strategy: ExtractionStrategy::Snapshot,
1183            format: FormatType::Parquet,
1184            compression: CompressionType::default(),
1185            compression_level: None,
1186            max_file_size_bytes: None,
1187            skip_empty: false,
1188            meta_columns: MetaColumns::default(),
1189            destination: DestinationConfig {
1190                destination_type: DestinationType::Local,
1191                path: Some("./out".into()),
1192                ..Default::default()
1193            },
1194            quality: None,
1195            tuning: SourceTuning::from_config(None),
1196            tuning_profile_label: "balanced (default)".into(),
1197            validate: false,
1198            reconcile: false,
1199            resume: false,
1200            source: crate::config::SourceConfig {
1201                source_type: crate::config::SourceType::Postgres,
1202                url: Some("postgresql://localhost/test".into()),
1203                url_env: None,
1204                url_file: None,
1205                host: None,
1206                port: None,
1207                user: None,
1208                password: None,
1209                password_env: None,
1210                database: None,
1211                environment: None,
1212                tuning: None,
1213                tls: None,
1214                mongo: None,
1215            },
1216            column_overrides: Default::default(),
1217            verify: crate::config::VerifyMode::Size,
1218            schema_drift_policy: Default::default(),
1219            shape_drift_warn_factor: 2.0,
1220            parquet: None,
1221        };
1222        let mut s = RunSummary::new(&plan);
1223        s.status = "failed".into();
1224        s.error_message = Some(
1225            "export 'events': --resume but no in-progress chunk checkpoint; \
1226             run without --resume first or `rivet state reset-chunks --config x.yaml --export events`"
1227                .to_string(),
1228        );
1229
1230        let line = s.render_compact();
1231        assert!(line.starts_with("✗ "), "failure icon: {:?}", line);
1232        assert!(line.contains("events"), "name present: {:?}", line);
1233        assert!(
1234            line.contains("--resume but no in-progress chunk checkpoint"),
1235            "cause kept: {:?}",
1236            line
1237        );
1238        assert!(
1239            !line.contains("rivet state reset-chunks"),
1240            "recovery hint should be stripped from per-export line: {:?}",
1241            line
1242        );
1243        assert!(!line.contains('\n'), "single line: {:?}", line);
1244    }
1245
1246    fn plan_for(export_name: &str) -> crate::plan::ResolvedRunPlan {
1247        use crate::plan::{
1248            CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1249            MetaColumns, ResolvedRunPlan,
1250        };
1251        use crate::tuning::SourceTuning;
1252        ResolvedRunPlan {
1253            export_name: export_name.into(),
1254            base_query: "SELECT 1".into(),
1255            strategy: ExtractionStrategy::Snapshot,
1256            format: FormatType::Parquet,
1257            compression: CompressionType::default(),
1258            compression_level: None,
1259            max_file_size_bytes: None,
1260            skip_empty: false,
1261            meta_columns: MetaColumns::default(),
1262            destination: DestinationConfig {
1263                destination_type: DestinationType::Local,
1264                path: Some("./out".into()),
1265                ..Default::default()
1266            },
1267            quality: None,
1268            tuning: SourceTuning::from_config(None),
1269            tuning_profile_label: "balanced (default)".into(),
1270            validate: false,
1271            reconcile: false,
1272            resume: false,
1273            source: crate::config::SourceConfig {
1274                source_type: crate::config::SourceType::Postgres,
1275                url: Some("postgresql://localhost/test".into()),
1276                url_env: None,
1277                url_file: None,
1278                host: None,
1279                port: None,
1280                user: None,
1281                password: None,
1282                password_env: None,
1283                database: None,
1284                environment: None,
1285                tuning: None,
1286                tls: None,
1287                mongo: None,
1288            },
1289            column_overrides: Default::default(),
1290            verify: crate::config::VerifyMode::Size,
1291            schema_drift_policy: Default::default(),
1292            shape_drift_warn_factor: 2.0,
1293            parquet: None,
1294        }
1295    }
1296
1297    #[test]
1298    fn plan_snapshot_records_chunk_key_and_resumable_for_post_mortem() {
1299        use crate::plan::{ExtractionStrategy, KeysetPlan};
1300        // A keyset export must persist WHICH column paged (`id`) and that
1301        // checkpoint-resume was on, so a post-mortem from the state DB alone
1302        // explains the strategy decision without re-querying the source schema.
1303        let mut plan = plan_for("statistic_lifetime");
1304        plan.strategy = ExtractionStrategy::Keyset(KeysetPlan {
1305            key_column: "id".into(),
1306            chunk_size: 1_000_000,
1307            checkpoint: true,
1308            incremental: false,
1309            parallel: 1,
1310        });
1311        let s = RunSummary::new(&plan);
1312        let snap = s.journal.plan_snapshot().expect("PlanResolved recorded");
1313        assert_eq!(snap.chunk_key.as_deref(), Some("id"));
1314        assert!(
1315            snap.resumable,
1316            "chunk_checkpoint on → resumable must be recorded"
1317        );
1318
1319        // Full (snapshot) export → no paging column, not resumable.
1320        let full = RunSummary::new(&plan_for("small"));
1321        let fsnap = full.journal.plan_snapshot().unwrap();
1322        assert_eq!(fsnap.chunk_key, None);
1323        assert!(!fsnap.resumable);
1324    }
1325
1326    #[test]
1327    fn plan_snapshot_deserializes_pre_field_journal_as_defaults() {
1328        // A journal written before chunk_key/resumable existed (real: the state
1329        // DBs already in the field) must still deserialize — the new fields
1330        // default to None/false, never a hard error that would break `--resume`
1331        // or a post-mortem read of an older run.
1332        let legacy = r#"{
1333            "export_name":"orders","base_query":"SELECT 1","strategy":"keyset",
1334            "format":"parquet","compression":"zstd","destination_type":"gcs",
1335            "tuning_profile":"balanced","batch_size":10000,
1336            "validate":false,"reconcile":false,"resume":false
1337        }"#;
1338        let snap: crate::journal::PlanSnapshot = serde_json::from_str(legacy).unwrap();
1339        assert_eq!(snap.chunk_key, None);
1340        assert!(!snap.resumable);
1341        assert_eq!(snap.strategy, "keyset");
1342    }
1343
1344    #[test]
1345    fn render_preserves_multiline_error_block() {
1346        // L19: a multi-line error (a quality failure here) must stay multi-line
1347        // in the detailed single-export block — not collapsed to `"; "`-joined
1348        // text the way the compact one-liner does.
1349        let mut s = RunSummary::new(&plan_for("orders"));
1350        s.status = "failed".into();
1351        s.error_message = Some(
1352            "export 'orders': 1 quality check(s) failed:\n  \
1353             - row_count 10 below minimum 999999\n  \
1354             Fix the source data, or adjust the thresholds under `quality:` in your config."
1355                .to_string(),
1356        );
1357
1358        let block = s.render();
1359        // The collapsed form joined lines with `"; "` — assert that flattening
1360        // is gone and the original newline structure survives.
1361        assert!(
1362            !block.contains("failed:;"),
1363            "error must not be '; '-flattened in the detailed block: {block}"
1364        );
1365        assert!(
1366            block.contains("- row_count 10 below minimum 999999"),
1367            "failing check line present: {block}"
1368        );
1369        // Each part of the multi-line error lands on its own line.
1370        let err_lines: Vec<&str> = block
1371            .lines()
1372            .filter(|l| {
1373                l.contains("quality check(s) failed")
1374                    || l.contains("row_count 10 below minimum")
1375                    || l.contains("Fix the source data")
1376            })
1377            .collect();
1378        assert_eq!(
1379            err_lines.len(),
1380            3,
1381            "all three error lines should render on separate lines: {block}"
1382        );
1383        // Continuation lines are indented under the value column, not at col 0.
1384        for l in &err_lines {
1385            assert!(l.starts_with(' '), "error line should be indented: {l:?}");
1386        }
1387    }
1388
1389    #[test]
1390    fn render_nudges_verification_when_unverified_success() {
1391        // #4: a successful run that wrote files but ran no verification pass
1392        // should surface an advisory `verify:` line — so skipping it is a choice.
1393        let mut s = RunSummary::new(&plan_for("orders"));
1394        s.status = "success".into();
1395        s.files_produced = 3;
1396        s.total_rows = 1_000;
1397        // validated / reconciled left None (no --validate / --reconcile).
1398        let block = s.render();
1399        assert!(
1400            block.lines().any(|l| l.trim_start().starts_with("verify:")),
1401            "expected a verify nudge on an unverified success: {block}"
1402        );
1403
1404        // A run that verified must NOT nudge.
1405        let mut s2 = RunSummary::new(&plan_for("orders"));
1406        s2.status = "success".into();
1407        s2.files_produced = 3;
1408        s2.validated = Some(true);
1409        let block2 = s2.render();
1410        assert!(
1411            !block2
1412                .lines()
1413                .any(|l| l.trim_start().starts_with("verify:")),
1414            "a verified run must not nudge: {block2}"
1415        );
1416    }
1417
1418    #[test]
1419    fn pg_temp_spill_row_only_for_real_spill_and_annotates_large() {
1420        // Direct provider test — previously this threshold logic was only
1421        // reachable by rendering a whole block (and no test set the field).
1422        let mut s = RunSummary::stub_for_testing("r", "orders");
1423        assert_eq!(s.pg_temp_spill_row(), None, "no delta → no row");
1424        s.pg_temp_bytes_delta = Some(0);
1425        assert_eq!(s.pg_temp_spill_row(), None, "zero spill → no row");
1426        s.pg_temp_bytes_delta = Some(-5);
1427        assert_eq!(s.pg_temp_spill_row(), None, "negative delta → no row");
1428
1429        s.pg_temp_bytes_delta = Some(50 * 1024 * 1024);
1430        let (label, value) = s.pg_temp_spill_row().expect("50MB spill → row");
1431        assert_eq!(label, "pg temp spill");
1432        assert!(
1433            value.contains("50.0 MB") && !value.contains('⚠'),
1434            "small spill is plain info: {value:?}"
1435        );
1436
1437        s.pg_temp_bytes_delta = Some(200 * 1024 * 1024);
1438        let (_, value) = s.pg_temp_spill_row().expect("200MB spill → row");
1439        assert!(
1440            value.contains('⚠') && value.contains("batch_size"),
1441            "spill over 100 MB carries the tuning hint: {value:?}"
1442        );
1443    }
1444
1445    #[test]
1446    fn outcome_rows_format_reconcile_and_suppress_nudge_when_checked() {
1447        let mut s = RunSummary::stub_for_testing("r", "orders");
1448        s.reconciled = Some(true);
1449        s.source_count = Some(1_000);
1450        s.total_rows = 1_000;
1451        assert!(
1452            s.outcome_rows()
1453                .iter()
1454                .any(|(l, v)| *l == "reconcile" && v == "MATCH (1,000/1,000)"),
1455            "match wording: {:?}",
1456            s.outcome_rows()
1457        );
1458
1459        s.reconciled = Some(false);
1460        s.source_count = Some(1_200);
1461        let rows = s.outcome_rows();
1462        let recon = rows
1463            .iter()
1464            .find(|(l, _)| *l == "reconcile")
1465            .expect("reconcile row");
1466        assert!(
1467            recon.1.contains("MISMATCH") && recon.1.contains("1,000") && recon.1.contains("1,200"),
1468            "mismatch names both sides: {:?}",
1469            recon
1470        );
1471
1472        // A set reconcile result suppresses the verify nudge even on a
1473        // files-produced success (the nudge's gate lives beside this field).
1474        s.status = "success".into();
1475        s.files_produced = 2;
1476        assert!(
1477            !s.outcome_rows().iter().any(|(l, _)| *l == "verify"),
1478            "a reconciled run must not also nudge"
1479        );
1480    }
1481
1482    #[test]
1483    fn render_surfaces_cursor_position_on_zero_new_incremental() {
1484        // L27: a 0-new incremental run shows `0 rows  0 files`; without a
1485        // cursor line the operator can't tell the boundary held. Assert the
1486        // dedicated `cursor:` line appears, derived from `skip_reason`.
1487        let mut s = RunSummary::new(&plan_for("orders"));
1488        s.status = "skipped".into();
1489        s.skip_reason = Some("no new rows since cursor 'updated_at'".into());
1490
1491        let block = s.render();
1492        let cursor_line = block
1493            .lines()
1494            .find(|l| l.trim_start().starts_with("cursor:"))
1495            .unwrap_or_else(|| panic!("expected a cursor: line in block: {block}"));
1496        assert!(
1497            cursor_line.contains("'updated_at'"),
1498            "cursor line names the column: {cursor_line:?}"
1499        );
1500        assert!(
1501            cursor_line.contains("unchanged"),
1502            "cursor line reports the position held: {cursor_line:?}"
1503        );
1504    }
1505
1506    #[test]
1507    fn incremental_position_line_only_for_cursor_skips() {
1508        // The non-cursor 0-row skip and the no-skip case produce no cursor line.
1509        assert_eq!(
1510            incremental_position_line(Some("no new rows since cursor 'ts'")),
1511            Some("'ts' unchanged (no new rows this run)".into())
1512        );
1513        assert_eq!(
1514            incremental_position_line(Some("source returned 0 rows")),
1515            None
1516        );
1517        assert_eq!(incremental_position_line(None), None);
1518    }
1519
1520    #[test]
1521    fn render_surfaces_window_position_on_zero_row_time_window() {
1522        // L27 (time_window arm): a 0-row time_window run reports the generic
1523        // `"source returned 0 rows"` skip (the strategy has no cursor column),
1524        // so the `cursor:` branch never fires. Without a `window:` line the
1525        // operator can't tell an empty window from a wrong column/window —
1526        // assert the dedicated `window:` line appears for this mode.
1527        let mut s = RunSummary::new(&plan_for("events"));
1528        s.status = "skipped".into();
1529        s.mode = "timewindow".into();
1530        s.skip_reason = Some("source returned 0 rows".into());
1531
1532        let block = s.render();
1533        let window_line = block
1534            .lines()
1535            .find(|l| l.trim_start().starts_with("window:"))
1536            .unwrap_or_else(|| panic!("expected a window: line in block: {block}"));
1537        assert!(
1538            window_line.contains("matched no rows"),
1539            "window line reports the empty window: {window_line:?}"
1540        );
1541        assert!(
1542            window_line.contains("time_column") && window_line.contains("days_window"),
1543            "window line points at the window config to check: {window_line:?}"
1544        );
1545        // The generic 0-row skip must not also produce a `cursor:` line.
1546        assert!(
1547            !block.lines().any(|l| l.trim_start().starts_with("cursor:")),
1548            "no cursor line for a non-cursor strategy: {block}"
1549        );
1550    }
1551
1552    #[test]
1553    fn time_window_skip_line_only_for_skipped_time_window() {
1554        // Fires only when the run skipped AND the strategy is time_window.
1555        assert_eq!(
1556            time_window_skip_line("timewindow", Some("source returned 0 rows")),
1557            Some("rolling time window matched no rows — check `time_column`/`days_window`".into())
1558        );
1559        // Wrong mode → no window line (incremental/snapshot handle their own).
1560        assert_eq!(
1561            time_window_skip_line("incremental", Some("source returned 0 rows")),
1562            None
1563        );
1564        assert_eq!(
1565            time_window_skip_line("full", Some("source returned 0 rows")),
1566            None
1567        );
1568        // A time_window run that produced rows (no skip) gets no window line.
1569        assert_eq!(time_window_skip_line("timewindow", None), None);
1570    }
1571}