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