1use 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
23fn 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
54pub struct ApplyContext {
55 pub plan_id: String,
57 pub forced: bool,
59 pub force_bypassed: Vec<String>,
64}
65
66#[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 pub files_committed: usize,
82 pub duration_ms: i64,
83 pub peak_rss_mb: i64,
84 pub retries: u32,
85 pub reconnects: u32,
90 pub resumed: bool,
94 pub state_backed: bool,
103 pub validated: Option<bool>,
104 pub schema_changed: Option<bool>,
105 pub quality_passed: Option<bool>,
106 pub column_checksums: Vec<crate::manifest::ColumnChecksum>,
109 pub checksum_key_column: Option<String>,
111 pub column_checksums_incomplete: bool,
122 pub cursor_column: Option<String>,
127 pub cursor_low: Option<String>,
128 pub cursor_high: Option<String>,
129 pub error_message: Option<String>,
130 pub offending_value: Option<String>,
138 pub server_context_json: Option<String>,
139 pub key_native_type: Option<String>,
143 pub tuning_profile: String,
145 pub batch_size: usize,
147 pub batch_size_memory_mb: Option<usize>,
149 pub format: String,
150 pub mode: String,
151 pub compression: String,
152 pub destination_uri: Option<String>,
157 pub pg_temp_bytes_delta: Option<i64>,
164 pub skip_reason: Option<String>,
170 pub source_count: Option<i64>,
172 pub reconciled: Option<bool>,
174 pub manifest_parts: Vec<ManifestPart>,
179 pub schema_fingerprint: Option<String>,
193 pub manifest_verification: Option<crate::pipeline::ManifestVerification>,
199 pub apply_context: Option<ApplyContext>,
203 pub journal: RunJournal,
205}
206
207type 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 #[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 #[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 #[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 #[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 #[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 #[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 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 pub(super) fn print_stderr_block(&self) {
444 let block = if multi_export_mode() {
445 self.render_compact()
446 } else {
447 self.render().trim_end_matches('\n').to_string()
453 };
454
455 use std::io::Write;
456 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if self.state_backed && self.status == "success" && self.files_committed > 0 {
812 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 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
851fn 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
876fn 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
890fn 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 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
956fn 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 let value_indent = " ".repeat(2 + (label_w + 1) + 2);
976 for (label, value) in rows {
977 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
1013fn 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 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 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 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 assert!(!block.contains('\r'));
1174
1175 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut resumed = base();
1570 resumed.resumed = true;
1571 resumed.column_checksums.push(ck());
1572 assert!(
1574 resumed.check_post_run_invariants(false).is_ok(),
1575 "a resume must not trip the drift-gate branch"
1576 );
1577
1578 let mut resume_flag_no_adopt = base();
1584 resume_flag_no_adopt.resumed = false;
1585 resume_flag_no_adopt.column_checksums.push(ck());
1586 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 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 let mut csv = base();
1602 csv.schema_changed = Some(false);
1603 csv.format = "csv".into();
1604 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 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 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 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 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 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 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 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 assert_eq!(time_window_skip_line("timewindow", None), None);
1736 }
1737}