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 validated: Option<bool>,
95 pub schema_changed: Option<bool>,
96 pub quality_passed: Option<bool>,
97 pub column_checksums: Vec<crate::manifest::ColumnChecksum>,
100 pub checksum_key_column: Option<String>,
102 pub column_checksums_incomplete: bool,
113 pub cursor_column: Option<String>,
118 pub cursor_low: Option<String>,
119 pub cursor_high: Option<String>,
120 pub error_message: Option<String>,
121 pub offending_value: Option<String>,
129 pub server_context_json: Option<String>,
130 pub key_native_type: Option<String>,
134 pub tuning_profile: String,
136 pub batch_size: usize,
138 pub batch_size_memory_mb: Option<usize>,
140 pub format: String,
141 pub mode: String,
142 pub compression: String,
143 pub destination_uri: Option<String>,
148 pub pg_temp_bytes_delta: Option<i64>,
155 pub skip_reason: Option<String>,
161 pub source_count: Option<i64>,
163 pub reconciled: Option<bool>,
165 pub manifest_parts: Vec<ManifestPart>,
170 pub schema_fingerprint: Option<String>,
184 pub manifest_verification: Option<crate::pipeline::ManifestVerification>,
190 pub apply_context: Option<ApplyContext>,
194 pub journal: RunJournal,
196}
197
198type Row = (&'static str, String);
201
202impl RunSummary {
203 pub(super) fn new(plan: &ResolvedRunPlan) -> Self {
204 let run_id = format!(
205 "{}_{}",
206 plan.export_name,
207 chrono::Utc::now().format("%Y%m%dT%H%M%S%.3f"),
208 );
209 let mut journal = RunJournal::new(&run_id, &plan.export_name);
210 journal.record(RunEvent::PlanResolved(plan_snapshot_from(plan)));
211
212 ipc::emit_event(&ChildEvent::Started {
213 export_name: plan.export_name.clone(),
214 run_id: run_id.clone(),
215 mode: plan.strategy.mode_label().to_string(),
216 tuning_profile: plan.tuning_profile_label.clone(),
217 batch_size: plan.tuning.batch_size,
218 });
219
220 Self {
221 run_id,
222 export_name: plan.export_name.clone(),
223 status: "running".into(),
224 total_rows: 0,
225 files_produced: 0,
226 bytes_written: 0,
227 files_committed: 0,
228 duration_ms: 0,
229 peak_rss_mb: 0,
230 retries: 0,
231 reconnects: 0,
232 resumed: false,
233 validated: None,
234 schema_changed: None,
235 quality_passed: None,
236 error_message: None,
237 offending_value: None,
238 server_context_json: None,
239 key_native_type: None,
240 tuning_profile: plan.tuning_profile_label.clone(),
241 batch_size: plan.tuning.batch_size,
242 batch_size_memory_mb: plan.tuning.batch_size_memory_mb,
243 format: plan.format.label().to_string(),
244 mode: plan.strategy.mode_label().to_string(),
245 compression: plan.compression.label().to_string(),
246 destination_uri: (!matches!(
247 plan.destination.destination_type,
248 crate::config::DestinationType::Stdout
249 ))
250 .then(|| crate::pipeline::finalize::destination_uri_for_manifest(&plan.destination)),
251 pg_temp_bytes_delta: None,
252 skip_reason: None,
253 source_count: None,
254 reconciled: None,
255 manifest_parts: Vec::new(),
256 schema_fingerprint: None,
257 manifest_verification: None,
258 apply_context: None,
259 column_checksums: Vec::new(),
260 column_checksums_incomplete: false,
261 checksum_key_column: None,
262 cursor_column: None,
263 cursor_low: None,
264 cursor_high: None,
265 journal,
266 }
267 }
268
269 #[doc(hidden)]
290 #[allow(dead_code)]
291 pub fn stub_for_testing(run_id: impl Into<String>, export_name: impl Into<String>) -> Self {
292 let run_id = run_id.into();
293 let export_name = export_name.into();
294 let journal = RunJournal::new(&run_id, &export_name);
295 Self {
296 run_id,
297 export_name,
298 status: "running".into(),
299 total_rows: 0,
300 files_produced: 0,
301 bytes_written: 0,
302 files_committed: 0,
303 duration_ms: 0,
304 peak_rss_mb: 0,
305 retries: 0,
306 reconnects: 0,
307 resumed: false,
308 validated: None,
309 schema_changed: None,
310 quality_passed: None,
311 error_message: None,
312 offending_value: None,
313 server_context_json: None,
314 key_native_type: None,
315 tuning_profile: "balanced".into(),
316 batch_size: 1000,
317 batch_size_memory_mb: None,
318 format: "parquet".into(),
319 mode: "snapshot".into(),
320 compression: "zstd".into(),
321 destination_uri: None,
322 pg_temp_bytes_delta: None,
323 skip_reason: None,
324 source_count: None,
325 reconciled: None,
326 manifest_parts: Vec::new(),
327 schema_fingerprint: None,
328 manifest_verification: None,
329 apply_context: None,
330 column_checksums: Vec::new(),
331 column_checksums_incomplete: false,
332 checksum_key_column: None,
333 cursor_column: None,
334 cursor_low: None,
335 cursor_high: None,
336 journal,
337 }
338 }
339
340 #[doc(hidden)]
347 #[allow(dead_code)]
348 pub fn with_status(mut self, status: impl Into<String>) -> Self {
349 let s = status.into();
350 if (s == "success" || s == "failed") && self.journal.final_outcome().is_none() {
351 self.journal.record(RunEvent::RunCompleted {
352 status: s.clone(),
353 error_message: self.error_message.clone(),
354 duration_ms: self.duration_ms,
355 });
356 }
357 self.status = s;
358 self
359 }
360
361 #[doc(hidden)]
365 #[allow(dead_code)]
366 pub fn with_files_committed(mut self, n: usize) -> Self {
367 self.files_committed = n;
368 self
369 }
370
371 #[doc(hidden)]
375 #[allow(dead_code)]
376 pub fn with_manifest_parts(mut self, parts: Vec<crate::manifest::ManifestPart>) -> Self {
377 self.total_rows = parts.iter().map(|p| p.rows).sum();
378 self.bytes_written = parts.iter().map(|p| p.size_bytes).sum();
379 self.files_produced = parts.len();
380 self.files_committed = parts.len();
381 self.manifest_parts = parts;
382 self
383 }
384
385 #[doc(hidden)]
389 #[allow(dead_code)]
390 pub fn with_error(mut self, msg: impl Into<String>) -> Self {
391 self.error_message = Some(msg.into());
392 self
393 }
394
395 #[doc(hidden)]
400 #[allow(dead_code)]
401 pub fn with_plan_snapshot(mut self, snap: PlanSnapshot) -> Self {
402 self.journal.record(RunEvent::PlanResolved(snap));
403 self
404 }
405
406 pub(super) fn print(&self) {
407 if ipc::capturing_events() {
411 ipc::emit_event(&ChildEvent::Finished {
412 export_name: self.export_name.clone(),
413 run_id: self.run_id.clone(),
414 status: self.status.clone(),
415 total_rows: self.total_rows,
416 files_produced: self.files_produced as u64,
417 bytes_written: self.bytes_written,
418 duration_ms: self.duration_ms,
419 peak_rss_mb: self.peak_rss_mb,
420 error_message: self.error_message.clone(),
421 });
422 return;
423 }
424
425 self.print_stderr_block();
426 }
427
428 pub(super) fn print_stderr_block(&self) {
433 let block = if multi_export_mode() {
434 self.render_compact()
435 } else {
436 self.render().trim_end_matches('\n').to_string()
442 };
443
444 use std::io::Write;
445 let mut buf = super::parent_ui::sanitize_terminal(&block);
452 buf.push('\n');
453 let stderr = std::io::stderr();
454 let mut handle = stderr.lock();
455 let _ = handle.write_all(buf.as_bytes());
456 let _ = handle.flush();
457 }
458
459 fn render_compact(&self) -> String {
464 const NAME_COL: usize = 22;
465 const MODE_COL: usize = 8;
466 let icon = match self.status.as_str() {
467 "success" => "✓",
468 "failed" => "✗",
469 _ => "•",
470 };
471 let body = if self.status == "failed" {
472 let err = self
473 .error_message
474 .as_deref()
475 .unwrap_or("(no error message recorded)");
476 let (cause, _) = strip_chunked_recovery_hint(err);
477 compact_error(cause)
481 } else {
482 let rss = if self.peak_rss_mb > 0 {
483 format!(" RSS {} MB", fmt_thousands(self.peak_rss_mb))
484 } else {
485 String::new()
486 };
487 format!(
488 "{} rows {} files {} {}{}",
489 fmt_thousands(self.total_rows),
490 fmt_thousands(self.files_produced as i64),
491 format_bytes(self.bytes_written),
492 fmt_duration_ms(self.duration_ms),
493 rss
494 )
495 };
496 format!(
497 "{} {:<name$} {:<mode$} {}",
498 icon,
499 self.export_name,
500 self.mode,
501 body,
502 name = NAME_COL,
503 mode = MODE_COL,
504 )
505 }
506
507 fn render(&self) -> String {
519 let mut rows: Vec<Row> = Vec::with_capacity(16);
520 rows.push(("run_id", self.run_id.clone()));
521 rows.push(self.status_row());
522 rows.push(self.tuning_row());
523 rows.push(("rows", fmt_thousands(self.total_rows)));
524 rows.push(("files", fmt_thousands(self.files_produced as i64)));
525 rows.extend(self.output_row());
526 rows.extend(self.position_row());
527 rows.extend(self.bytes_row());
528 rows.push(("duration", fmt_duration_ms(self.duration_ms)));
529 rows.extend(self.peak_rss_row());
530 rows.extend(self.pg_temp_spill_row());
531 rows.extend(self.compression_row());
532 rows.extend(self.retries_row());
533 rows.extend(self.outcome_rows());
534 rows.extend(self.error_row());
535 format_block(&self.export_name, &rows)
536 }
537
538 fn status_row(&self) -> Row {
540 let value = match (&self.status, &self.skip_reason) {
541 (s, Some(reason)) if s == "skipped" => format!("{s} ({reason})"),
542 (s, _) => s.clone(),
543 };
544 ("status", value)
545 }
546
547 fn tuning_row(&self) -> Row {
550 let value = match self.batch_size_memory_mb {
551 Some(mem) => format!(
552 "profile={}, batch_size={} (batch_size_memory_mb={}MiB → effective FETCH in logs)",
553 self.tuning_profile,
554 fmt_thousands(self.batch_size as i64),
555 mem
556 ),
557 None => format!(
558 "profile={}, batch_size={}",
559 self.tuning_profile,
560 fmt_thousands(self.batch_size as i64)
561 ),
562 };
563 ("tuning", value)
564 }
565
566 fn output_row(&self) -> Option<Row> {
570 if self.files_produced > 0 {
571 self.destination_uri.clone().map(|uri| ("output", uri))
572 } else {
573 None
574 }
575 }
576
577 fn position_row(&self) -> Option<Row> {
584 if let Some(pos) = incremental_position_line(self.skip_reason.as_deref()) {
585 Some(("cursor", pos))
586 } else {
587 time_window_skip_line(&self.mode, self.skip_reason.as_deref()).map(|w| ("window", w))
588 }
589 }
590
591 fn bytes_row(&self) -> Option<Row> {
593 if self.bytes_written > 0 {
594 Some(("bytes", format_bytes(self.bytes_written)))
595 } else {
596 None
597 }
598 }
599
600 fn peak_rss_row(&self) -> Option<Row> {
602 if self.peak_rss_mb > 0 {
603 Some((
604 "peak RSS",
605 format!(
606 "{} MB (sampled during run)",
607 fmt_thousands(self.peak_rss_mb)
608 ),
609 ))
610 } else {
611 None
612 }
613 }
614
615 fn pg_temp_spill_row(&self) -> Option<Row> {
619 let temp = self.pg_temp_bytes_delta?;
620 if temp <= 0 {
621 return None;
622 }
623 let temp_mb = temp as f64 / (1024.0 * 1024.0);
624 let label = if temp > 100 * 1024 * 1024 {
625 format!(
626 "{:.1} MB ⚠ shrink tuning.batch_size or set batch_size_memory_mb",
627 temp_mb
628 )
629 } else {
630 format!("{:.1} MB", temp_mb)
631 };
632 Some(("pg temp spill", label))
633 }
634
635 fn compression_row(&self) -> Option<Row> {
637 if self.format == "parquet" && self.compression != "zstd" {
638 Some(("compression", self.compression.clone()))
639 } else {
640 None
641 }
642 }
643
644 fn retries_row(&self) -> Option<Row> {
646 if self.retries > 0 {
647 Some(("retries", self.retries.to_string()))
648 } else {
649 None
650 }
651 }
652
653 fn outcome_rows(&self) -> Vec<Row> {
659 let mut rows: Vec<Row> = Vec::new();
660 if let Some(v) = self.validated {
661 rows.push(("validated", if v { "pass".into() } else { "FAIL".into() }));
662 }
663 if let Some(sc) = self.schema_changed {
664 rows.push((
665 "schema",
666 if sc {
667 "CHANGED".into()
668 } else {
669 "unchanged".into()
670 },
671 ));
672 }
673 if let Some(q) = self.quality_passed {
674 rows.push(("quality", if q { "pass".into() } else { "FAIL".into() }));
675 }
676 if let Some(reconciled) = self.reconciled {
677 let src = self
678 .source_count
679 .map(fmt_thousands)
680 .unwrap_or_else(|| "?".into());
681 let exported = fmt_thousands(self.total_rows);
682 let value = if reconciled {
683 format!("MATCH ({exported}/{src})")
684 } else {
685 format!("MISMATCH (exported {exported} vs source {src})")
686 };
687 rows.push(("reconcile", value));
688 }
689 if self.status == "success"
694 && self.files_produced > 0
695 && self.validated.is_none()
696 && self.reconciled.is_none()
697 {
698 rows.push((
699 "verify",
700 "not run — add `--reconcile` (count vs source) or `rivet validate` (re-read outputs)"
701 .into(),
702 ));
703 }
704 rows
705 }
706
707 fn error_row(&self) -> Option<Row> {
713 self.error_message
714 .as_ref()
715 .map(|err| ("error", err.trim_end().to_string()))
716 }
717
718 pub fn check_post_run_invariants(&self) -> Result<(), String> {
734 let parts_bytes: u64 = self.manifest_parts.iter().map(|p| p.size_bytes).sum();
735
736 if self.files_committed > self.manifest_parts.len() {
737 return Err(format!(
738 "summary.files_committed ({}) > manifest_parts.len() ({}) — \
739 a runner bumped files_committed without commit::record_part",
740 self.files_committed,
741 self.manifest_parts.len()
742 ));
743 }
744 if self.files_produced > self.manifest_parts.len() {
745 return Err(format!(
746 "summary.files_produced ({}) > manifest_parts.len() ({}) — \
747 a runner bumped files_produced without commit::record_part",
748 self.files_produced,
749 self.manifest_parts.len()
750 ));
751 }
752 if self.bytes_written > parts_bytes {
753 return Err(format!(
754 "summary.bytes_written ({}) > sum(manifest_parts.size_bytes) ({}) — \
755 a runner bumped bytes_written without commit::record_part",
756 self.bytes_written, parts_bytes
757 ));
758 }
759 if self.status == "success" && self.files_committed > 0 && self.manifest_parts.is_empty() {
760 return Err(format!(
761 "success run with files_committed={} has empty manifest_parts — \
762 cloud manifest (ADR-0012 M1) would ship with no part list \
763 (this is the gap parallel_checkpoint had before commit e9b0796)",
764 self.files_committed
765 ));
766 }
767 if self.status == "success" && self.total_rows > 0 && self.files_committed == 0 {
779 return Err(format!(
780 "summary.total_rows={} but files_committed=0 — rows extracted from \
781 source but no files committed (no output reached the destination)",
782 self.total_rows
783 ));
784 }
785 Ok(())
786 }
787}
788
789fn compact_error(raw: &str) -> String {
801 const MAX_CHARS: usize = 240;
802 if let Some(summary) = summarize_parallel_chunk_errors(raw) {
803 return clamp_chars(&summary, MAX_CHARS);
804 }
805 let collapsed: String = raw
806 .lines()
807 .map(str::trim_end)
808 .filter(|s| !s.is_empty())
809 .collect::<Vec<_>>()
810 .join("; ");
811 clamp_chars(&collapsed, MAX_CHARS)
812}
813
814fn incremental_position_line(skip_reason: Option<&str>) -> Option<String> {
822 let col = skip_reason?
823 .strip_prefix("no new rows since cursor '")?
824 .strip_suffix('\'')?;
825 Some(format!("'{col}' unchanged (no new rows this run)"))
826}
827
828fn time_window_skip_line(mode: &str, skip_reason: Option<&str>) -> Option<String> {
845 skip_reason?;
846 if mode != "timewindow" {
847 return None;
848 }
849 Some("rolling time window matched no rows — check `time_column`/`days_window`".to_string())
850}
851
852fn summarize_parallel_chunk_errors(raw: &str) -> Option<String> {
853 let header_pos = raw.find("parallel checkpoint worker errors:")?;
854 let prefix = raw[..header_pos].trim_end_matches(": ").trim_end();
855 let tail = &raw[header_pos + "parallel checkpoint worker errors:".len()..];
856
857 let chunk_lines: Vec<&str> = tail
858 .lines()
859 .map(str::trim)
860 .filter(|l| l.starts_with("chunk "))
861 .collect();
862 if chunk_lines.is_empty() {
863 return None;
864 }
865 let first_chunk_full = chunk_lines[0];
866 let first_chunk_short = clamp_chars(first_chunk_full, 140);
868 let prefix = if prefix.is_empty() {
869 String::new()
870 } else {
871 format!("{}: ", prefix)
872 };
873 Some(format!(
874 "{}parallel checkpoint workers failed: {} chunk(s) ({}); see stderr for full payloads",
875 prefix,
876 chunk_lines.len(),
877 first_chunk_short
878 ))
879}
880
881fn clamp_chars(s: &str, max_chars: usize) -> String {
882 if max_chars == 0 {
883 return String::new();
884 }
885 if s.chars().count() <= max_chars {
886 return s.to_string();
887 }
888 let keep = max_chars.saturating_sub(1);
889 let mut out: String = s.chars().take(keep).collect();
890 out.push('…');
891 out
892}
893
894fn format_block(name: &str, rows: &[(&str, String)]) -> String {
897 const HEADER_WIDTH: usize = 60;
898 let label_w = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
899
900 let prefix = format!("── {} ", name);
901 let prefix_chars = prefix.chars().count();
902 let dashes = HEADER_WIDTH.saturating_sub(prefix_chars);
903 let mut out = String::with_capacity(HEADER_WIDTH * (rows.len() + 3));
904 out.push('\n');
905 out.push_str(&prefix);
906 for _ in 0..dashes {
907 out.push('─');
908 }
909 out.push('\n');
910 let value_indent = " ".repeat(2 + (label_w + 1) + 2);
914 for (label, value) in rows {
915 let mut lines = value.split('\n');
918 let first = lines.next().unwrap_or("");
919 out.push_str(&format!(
920 " {:<width$} {}\n",
921 format!("{label}:"),
922 first,
923 width = label_w + 1
924 ));
925 for cont in lines {
926 out.push_str(&value_indent);
927 out.push_str(cont);
928 out.push('\n');
929 }
930 }
931 out
932}
933
934fn fmt_duration_ms(ms: i64) -> String {
935 if ms < 1000 {
936 return format!("{}ms", ms);
937 }
938 let total_secs = ms / 1000;
939 let h = total_secs / 3600;
940 let m = (total_secs % 3600) / 60;
941 let s_frac = (ms % 60_000) as f64 / 1000.0;
942 if h > 0 {
943 format!("{}h {:02}m {:04.1}s", h, m, s_frac)
944 } else if m > 0 {
945 format!("{}m {:04.1}s", m, s_frac)
946 } else {
947 format!("{:.1}s", ms as f64 / 1000.0)
948 }
949}
950
951fn fmt_thousands(n: i64) -> String {
955 let abs = n.unsigned_abs();
956 let s = abs.to_string();
957 let bytes = s.as_bytes();
958 let mut out = String::with_capacity(s.len() + s.len() / 3 + 1);
959 if n < 0 {
960 out.push('-');
961 }
962 for (i, b) in bytes.iter().enumerate() {
963 let from_end = bytes.len() - i;
964 if i > 0 && from_end.is_multiple_of(3) {
965 out.push(',');
966 }
967 out.push(*b as char);
968 }
969 out
970}
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975
976 #[test]
977 fn fmt_thousands_handles_small_and_large() {
978 assert_eq!(fmt_thousands(0), "0");
979 assert_eq!(fmt_thousands(7), "7");
980 assert_eq!(fmt_thousands(999), "999");
981 assert_eq!(fmt_thousands(1_000), "1,000");
982 assert_eq!(fmt_thousands(1_000_908), "1,000,908");
983 assert_eq!(fmt_thousands(39_990_376), "39,990,376");
984 assert_eq!(fmt_thousands(-1_234), "-1,234");
985 assert_eq!(fmt_thousands(i64::MAX), "9,223,372,036,854,775,807");
986 }
987
988 #[test]
989 fn fmt_duration_picks_unit() {
990 assert_eq!(fmt_duration_ms(0), "0ms");
991 assert_eq!(fmt_duration_ms(800), "800ms");
992 assert_eq!(fmt_duration_ms(1_500), "1.5s");
993 assert_eq!(fmt_duration_ms(68_400), "1m 08.4s");
994 assert_eq!(fmt_duration_ms(3_725_300), "1h 02m 05.3s");
995 }
996
997 #[test]
998 fn format_block_pads_labels_uniformly() {
999 let rows = vec![
1000 ("run_id", "abc".to_string()),
1001 ("rows", "42".to_string()),
1002 ("compression", "zstd".to_string()),
1003 ];
1004 let out = format_block("orders", &rows);
1005
1006 let lines: Vec<&str> = out.lines().filter(|l| l.contains(':')).collect();
1008 assert_eq!(lines.len(), 3);
1009 let value_starts: Vec<usize> = lines
1010 .iter()
1011 .map(|l| l.find(':').unwrap() + l[l.find(':').unwrap()..].find(' ').unwrap())
1012 .collect();
1013 let value_col = lines[0].rfind("abc").unwrap();
1017 assert_eq!(lines[1].rfind("42").unwrap(), value_col);
1018 assert_eq!(lines[2].rfind("zstd").unwrap(), value_col);
1019 let _ = value_starts;
1021 }
1022
1023 #[test]
1024 fn format_block_header_has_consistent_width() {
1025 let block_a = format_block("a", &[("rows", "1".into())]);
1026 let block_b = format_block("orders_table_xyz", &[("rows", "1".into())]);
1027 let header_a = block_a.lines().nth(1).unwrap();
1028 let header_b = block_b.lines().nth(1).unwrap();
1029 assert_eq!(
1030 header_a.chars().count(),
1031 header_b.chars().count(),
1032 "headers must be the same width regardless of name length: {:?} vs {:?}",
1033 header_a,
1034 header_b
1035 );
1036 }
1037
1038 #[test]
1039 fn render_produces_a_single_string_with_trailing_newline() {
1040 use crate::plan::{
1041 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1042 MetaColumns, ResolvedRunPlan,
1043 };
1044 use crate::tuning::SourceTuning;
1045 let plan = ResolvedRunPlan {
1046 export_name: "orders".into(),
1047 base_query: "SELECT 1".into(),
1048 strategy: ExtractionStrategy::Snapshot,
1049 format: FormatType::Parquet,
1050 compression: CompressionType::default(),
1051 compression_level: None,
1052 max_file_size_bytes: None,
1053 skip_empty: false,
1054 meta_columns: MetaColumns::default(),
1055 destination: DestinationConfig {
1056 destination_type: DestinationType::Local,
1057 path: Some("./out".into()),
1058 ..Default::default()
1059 },
1060 quality: None,
1061 tuning: SourceTuning::from_config(None),
1062 tuning_profile_label: "balanced (default)".into(),
1063 validate: false,
1064 reconcile: false,
1065 resume: false,
1066 source: crate::config::SourceConfig {
1067 source_type: crate::config::SourceType::Postgres,
1068 url: Some("postgresql://localhost/test".into()),
1069 url_env: None,
1070 url_file: None,
1071 host: None,
1072 port: None,
1073 user: None,
1074 password: None,
1075 password_env: None,
1076 database: None,
1077 environment: None,
1078 tuning: None,
1079 tls: None,
1080 mongo: None,
1081 },
1082 column_overrides: Default::default(),
1083 verify: crate::config::VerifyMode::Size,
1084 schema_drift_policy: Default::default(),
1085 shape_drift_warn_factor: 2.0,
1086 parquet: None,
1087 };
1088 let mut s = RunSummary::new(&plan);
1089 s.status = "success".into();
1090 s.total_rows = 1_000_908;
1091 s.files_produced = 11;
1092 s.bytes_written = 32 * 1024 * 1024 + 400 * 1024;
1093 s.duration_ms = 68_400;
1094 s.peak_rss_mb = 884;
1095
1096 let block = s.render();
1097 assert!(
1098 block.starts_with('\n'),
1099 "block should start with a blank line"
1100 );
1101 assert!(block.ends_with('\n'), "block should end with a newline");
1102 assert!(block.contains("── orders "));
1103 assert!(
1104 block.contains("1,000,908"),
1105 "rows should be formatted with thousands separator: {}",
1106 block
1107 );
1108 assert!(block.contains("1m 08.4s"), "duration formatting: {}", block);
1109 assert!(!block.contains('\r'));
1112
1113 let line = s.render_compact();
1115 assert!(line.starts_with("✓ "), "success icon present: {:?}", line);
1116 assert!(line.contains("orders"), "export name present: {:?}", line);
1117 assert!(line.contains("1,000,908 rows"), "rows present: {:?}", line);
1118 assert!(line.contains("32.4 MB"), "bytes present: {:?}", line);
1119 assert!(line.contains("1m 08.4s"), "duration present: {:?}", line);
1120 assert!(line.contains("RSS 884 MB"), "rss present: {:?}", line);
1121 assert!(!line.contains('\n'), "single line: {:?}", line);
1122 }
1123
1124 #[test]
1125 fn compact_error_summarises_parallel_chunk_errors() {
1126 let raw = "export 'page_views': parallel checkpoint worker errors:\n\
1127 chunk 4: Unexpected (temporary) at write, context: { url: https://storage.googleapis.com/rivet_data_test/exports%2Fpage_views%2Fpage_views_20260430_202442_chunk4.parquet?partNumber=1&uploadId=ABPnzm7RqplA, called: http_util::Client::send } => send http request, source: error sending request: client error (SendRequest): dispatch task is gone\n\
1128 chunk 5: Unexpected (temporary) at write, context: { url: https://storage.googleapis.com/rivet_data_test/exports%2Fpage_views%2Fpage_views_20260430_202443_chunk5.parquet?partNumber=1&uploadId=ABPnzm6q, called: http_util::Client::send } => send http request, source: dispatch task is gone";
1129 let out = compact_error(raw);
1130 assert!(
1131 out.contains("2 chunk(s)"),
1132 "should report number of failed chunks: {:?}",
1133 out
1134 );
1135 assert!(
1136 out.starts_with("export 'page_views': parallel checkpoint workers failed:"),
1137 "should keep export prefix and use compact phrasing: {:?}",
1138 out
1139 );
1140 assert!(
1141 out.contains("chunk 4:"),
1142 "should include the first chunk as an example: {:?}",
1143 out
1144 );
1145 assert!(!out.contains('\n'), "single line output: {:?}", out);
1146 assert!(
1147 out.chars().count() <= 240,
1148 "must be clamped to <=240 chars, got {}: {:?}",
1149 out.chars().count(),
1150 out
1151 );
1152 }
1153
1154 #[test]
1155 fn compact_error_collapses_generic_multiline() {
1156 let raw = "first line of trouble\nsecond line with detail\n\nthird line\n";
1157 let out = compact_error(raw);
1158 assert_eq!(
1159 out, "first line of trouble; second line with detail; third line",
1160 "newlines should collapse to '; ' and blanks dropped"
1161 );
1162 }
1163
1164 #[test]
1165 fn compact_error_clamps_excessively_long_lines() {
1166 let raw = "x".repeat(1_000);
1167 let out = compact_error(&raw);
1168 assert_eq!(out.chars().count(), 240);
1169 assert!(out.ends_with('…'));
1170 }
1171
1172 #[test]
1173 fn render_compact_strips_chunked_recovery_hint_for_failed() {
1174 use crate::plan::{
1175 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1176 MetaColumns, ResolvedRunPlan,
1177 };
1178 use crate::tuning::SourceTuning;
1179 let plan = ResolvedRunPlan {
1180 export_name: "events".into(),
1181 base_query: "SELECT 1".into(),
1182 strategy: ExtractionStrategy::Snapshot,
1183 format: FormatType::Parquet,
1184 compression: CompressionType::default(),
1185 compression_level: None,
1186 max_file_size_bytes: None,
1187 skip_empty: false,
1188 meta_columns: MetaColumns::default(),
1189 destination: DestinationConfig {
1190 destination_type: DestinationType::Local,
1191 path: Some("./out".into()),
1192 ..Default::default()
1193 },
1194 quality: None,
1195 tuning: SourceTuning::from_config(None),
1196 tuning_profile_label: "balanced (default)".into(),
1197 validate: false,
1198 reconcile: false,
1199 resume: false,
1200 source: crate::config::SourceConfig {
1201 source_type: crate::config::SourceType::Postgres,
1202 url: Some("postgresql://localhost/test".into()),
1203 url_env: None,
1204 url_file: None,
1205 host: None,
1206 port: None,
1207 user: None,
1208 password: None,
1209 password_env: None,
1210 database: None,
1211 environment: None,
1212 tuning: None,
1213 tls: None,
1214 mongo: None,
1215 },
1216 column_overrides: Default::default(),
1217 verify: crate::config::VerifyMode::Size,
1218 schema_drift_policy: Default::default(),
1219 shape_drift_warn_factor: 2.0,
1220 parquet: None,
1221 };
1222 let mut s = RunSummary::new(&plan);
1223 s.status = "failed".into();
1224 s.error_message = Some(
1225 "export 'events': --resume but no in-progress chunk checkpoint; \
1226 run without --resume first or `rivet state reset-chunks --config x.yaml --export events`"
1227 .to_string(),
1228 );
1229
1230 let line = s.render_compact();
1231 assert!(line.starts_with("✗ "), "failure icon: {:?}", line);
1232 assert!(line.contains("events"), "name present: {:?}", line);
1233 assert!(
1234 line.contains("--resume but no in-progress chunk checkpoint"),
1235 "cause kept: {:?}",
1236 line
1237 );
1238 assert!(
1239 !line.contains("rivet state reset-chunks"),
1240 "recovery hint should be stripped from per-export line: {:?}",
1241 line
1242 );
1243 assert!(!line.contains('\n'), "single line: {:?}", line);
1244 }
1245
1246 fn plan_for(export_name: &str) -> crate::plan::ResolvedRunPlan {
1247 use crate::plan::{
1248 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1249 MetaColumns, ResolvedRunPlan,
1250 };
1251 use crate::tuning::SourceTuning;
1252 ResolvedRunPlan {
1253 export_name: export_name.into(),
1254 base_query: "SELECT 1".into(),
1255 strategy: ExtractionStrategy::Snapshot,
1256 format: FormatType::Parquet,
1257 compression: CompressionType::default(),
1258 compression_level: None,
1259 max_file_size_bytes: None,
1260 skip_empty: false,
1261 meta_columns: MetaColumns::default(),
1262 destination: DestinationConfig {
1263 destination_type: DestinationType::Local,
1264 path: Some("./out".into()),
1265 ..Default::default()
1266 },
1267 quality: None,
1268 tuning: SourceTuning::from_config(None),
1269 tuning_profile_label: "balanced (default)".into(),
1270 validate: false,
1271 reconcile: false,
1272 resume: false,
1273 source: crate::config::SourceConfig {
1274 source_type: crate::config::SourceType::Postgres,
1275 url: Some("postgresql://localhost/test".into()),
1276 url_env: None,
1277 url_file: None,
1278 host: None,
1279 port: None,
1280 user: None,
1281 password: None,
1282 password_env: None,
1283 database: None,
1284 environment: None,
1285 tuning: None,
1286 tls: None,
1287 mongo: None,
1288 },
1289 column_overrides: Default::default(),
1290 verify: crate::config::VerifyMode::Size,
1291 schema_drift_policy: Default::default(),
1292 shape_drift_warn_factor: 2.0,
1293 parquet: None,
1294 }
1295 }
1296
1297 #[test]
1298 fn plan_snapshot_records_chunk_key_and_resumable_for_post_mortem() {
1299 use crate::plan::{ExtractionStrategy, KeysetPlan};
1300 let mut plan = plan_for("statistic_lifetime");
1304 plan.strategy = ExtractionStrategy::Keyset(KeysetPlan {
1305 key_column: "id".into(),
1306 chunk_size: 1_000_000,
1307 checkpoint: true,
1308 incremental: false,
1309 parallel: 1,
1310 });
1311 let s = RunSummary::new(&plan);
1312 let snap = s.journal.plan_snapshot().expect("PlanResolved recorded");
1313 assert_eq!(snap.chunk_key.as_deref(), Some("id"));
1314 assert!(
1315 snap.resumable,
1316 "chunk_checkpoint on → resumable must be recorded"
1317 );
1318
1319 let full = RunSummary::new(&plan_for("small"));
1321 let fsnap = full.journal.plan_snapshot().unwrap();
1322 assert_eq!(fsnap.chunk_key, None);
1323 assert!(!fsnap.resumable);
1324 }
1325
1326 #[test]
1327 fn plan_snapshot_deserializes_pre_field_journal_as_defaults() {
1328 let legacy = r#"{
1333 "export_name":"orders","base_query":"SELECT 1","strategy":"keyset",
1334 "format":"parquet","compression":"zstd","destination_type":"gcs",
1335 "tuning_profile":"balanced","batch_size":10000,
1336 "validate":false,"reconcile":false,"resume":false
1337 }"#;
1338 let snap: crate::journal::PlanSnapshot = serde_json::from_str(legacy).unwrap();
1339 assert_eq!(snap.chunk_key, None);
1340 assert!(!snap.resumable);
1341 assert_eq!(snap.strategy, "keyset");
1342 }
1343
1344 #[test]
1345 fn render_preserves_multiline_error_block() {
1346 let mut s = RunSummary::new(&plan_for("orders"));
1350 s.status = "failed".into();
1351 s.error_message = Some(
1352 "export 'orders': 1 quality check(s) failed:\n \
1353 - row_count 10 below minimum 999999\n \
1354 Fix the source data, or adjust the thresholds under `quality:` in your config."
1355 .to_string(),
1356 );
1357
1358 let block = s.render();
1359 assert!(
1362 !block.contains("failed:;"),
1363 "error must not be '; '-flattened in the detailed block: {block}"
1364 );
1365 assert!(
1366 block.contains("- row_count 10 below minimum 999999"),
1367 "failing check line present: {block}"
1368 );
1369 let err_lines: Vec<&str> = block
1371 .lines()
1372 .filter(|l| {
1373 l.contains("quality check(s) failed")
1374 || l.contains("row_count 10 below minimum")
1375 || l.contains("Fix the source data")
1376 })
1377 .collect();
1378 assert_eq!(
1379 err_lines.len(),
1380 3,
1381 "all three error lines should render on separate lines: {block}"
1382 );
1383 for l in &err_lines {
1385 assert!(l.starts_with(' '), "error line should be indented: {l:?}");
1386 }
1387 }
1388
1389 #[test]
1390 fn render_nudges_verification_when_unverified_success() {
1391 let mut s = RunSummary::new(&plan_for("orders"));
1394 s.status = "success".into();
1395 s.files_produced = 3;
1396 s.total_rows = 1_000;
1397 let block = s.render();
1399 assert!(
1400 block.lines().any(|l| l.trim_start().starts_with("verify:")),
1401 "expected a verify nudge on an unverified success: {block}"
1402 );
1403
1404 let mut s2 = RunSummary::new(&plan_for("orders"));
1406 s2.status = "success".into();
1407 s2.files_produced = 3;
1408 s2.validated = Some(true);
1409 let block2 = s2.render();
1410 assert!(
1411 !block2
1412 .lines()
1413 .any(|l| l.trim_start().starts_with("verify:")),
1414 "a verified run must not nudge: {block2}"
1415 );
1416 }
1417
1418 #[test]
1419 fn pg_temp_spill_row_only_for_real_spill_and_annotates_large() {
1420 let mut s = RunSummary::stub_for_testing("r", "orders");
1423 assert_eq!(s.pg_temp_spill_row(), None, "no delta → no row");
1424 s.pg_temp_bytes_delta = Some(0);
1425 assert_eq!(s.pg_temp_spill_row(), None, "zero spill → no row");
1426 s.pg_temp_bytes_delta = Some(-5);
1427 assert_eq!(s.pg_temp_spill_row(), None, "negative delta → no row");
1428
1429 s.pg_temp_bytes_delta = Some(50 * 1024 * 1024);
1430 let (label, value) = s.pg_temp_spill_row().expect("50MB spill → row");
1431 assert_eq!(label, "pg temp spill");
1432 assert!(
1433 value.contains("50.0 MB") && !value.contains('⚠'),
1434 "small spill is plain info: {value:?}"
1435 );
1436
1437 s.pg_temp_bytes_delta = Some(200 * 1024 * 1024);
1438 let (_, value) = s.pg_temp_spill_row().expect("200MB spill → row");
1439 assert!(
1440 value.contains('⚠') && value.contains("batch_size"),
1441 "spill over 100 MB carries the tuning hint: {value:?}"
1442 );
1443 }
1444
1445 #[test]
1446 fn outcome_rows_format_reconcile_and_suppress_nudge_when_checked() {
1447 let mut s = RunSummary::stub_for_testing("r", "orders");
1448 s.reconciled = Some(true);
1449 s.source_count = Some(1_000);
1450 s.total_rows = 1_000;
1451 assert!(
1452 s.outcome_rows()
1453 .iter()
1454 .any(|(l, v)| *l == "reconcile" && v == "MATCH (1,000/1,000)"),
1455 "match wording: {:?}",
1456 s.outcome_rows()
1457 );
1458
1459 s.reconciled = Some(false);
1460 s.source_count = Some(1_200);
1461 let rows = s.outcome_rows();
1462 let recon = rows
1463 .iter()
1464 .find(|(l, _)| *l == "reconcile")
1465 .expect("reconcile row");
1466 assert!(
1467 recon.1.contains("MISMATCH") && recon.1.contains("1,000") && recon.1.contains("1,200"),
1468 "mismatch names both sides: {:?}",
1469 recon
1470 );
1471
1472 s.status = "success".into();
1475 s.files_produced = 2;
1476 assert!(
1477 !s.outcome_rows().iter().any(|(l, _)| *l == "verify"),
1478 "a reconciled run must not also nudge"
1479 );
1480 }
1481
1482 #[test]
1483 fn render_surfaces_cursor_position_on_zero_new_incremental() {
1484 let mut s = RunSummary::new(&plan_for("orders"));
1488 s.status = "skipped".into();
1489 s.skip_reason = Some("no new rows since cursor 'updated_at'".into());
1490
1491 let block = s.render();
1492 let cursor_line = block
1493 .lines()
1494 .find(|l| l.trim_start().starts_with("cursor:"))
1495 .unwrap_or_else(|| panic!("expected a cursor: line in block: {block}"));
1496 assert!(
1497 cursor_line.contains("'updated_at'"),
1498 "cursor line names the column: {cursor_line:?}"
1499 );
1500 assert!(
1501 cursor_line.contains("unchanged"),
1502 "cursor line reports the position held: {cursor_line:?}"
1503 );
1504 }
1505
1506 #[test]
1507 fn incremental_position_line_only_for_cursor_skips() {
1508 assert_eq!(
1510 incremental_position_line(Some("no new rows since cursor 'ts'")),
1511 Some("'ts' unchanged (no new rows this run)".into())
1512 );
1513 assert_eq!(
1514 incremental_position_line(Some("source returned 0 rows")),
1515 None
1516 );
1517 assert_eq!(incremental_position_line(None), None);
1518 }
1519
1520 #[test]
1521 fn render_surfaces_window_position_on_zero_row_time_window() {
1522 let mut s = RunSummary::new(&plan_for("events"));
1528 s.status = "skipped".into();
1529 s.mode = "timewindow".into();
1530 s.skip_reason = Some("source returned 0 rows".into());
1531
1532 let block = s.render();
1533 let window_line = block
1534 .lines()
1535 .find(|l| l.trim_start().starts_with("window:"))
1536 .unwrap_or_else(|| panic!("expected a window: line in block: {block}"));
1537 assert!(
1538 window_line.contains("matched no rows"),
1539 "window line reports the empty window: {window_line:?}"
1540 );
1541 assert!(
1542 window_line.contains("time_column") && window_line.contains("days_window"),
1543 "window line points at the window config to check: {window_line:?}"
1544 );
1545 assert!(
1547 !block.lines().any(|l| l.trim_start().starts_with("cursor:")),
1548 "no cursor line for a non-cursor strategy: {block}"
1549 );
1550 }
1551
1552 #[test]
1553 fn time_window_skip_line_only_for_skipped_time_window() {
1554 assert_eq!(
1556 time_window_skip_line("timewindow", Some("source returned 0 rows")),
1557 Some("rolling time window matched no rows — check `time_column`/`days_window`".into())
1558 );
1559 assert_eq!(
1561 time_window_skip_line("incremental", Some("source returned 0 rows")),
1562 None
1563 );
1564 assert_eq!(
1565 time_window_skip_line("full", Some("source returned 0 rows")),
1566 None
1567 );
1568 assert_eq!(time_window_skip_line("timewindow", None), None);
1570 }
1571}