1#![allow(dead_code)]
30
31use serde::{Deserialize, Serialize};
32
33pub const MANIFEST_VERSION: u32 = 1;
35
36pub const MANIFEST_FILENAME: &str = "manifest.json";
38
39pub const SUCCESS_FILENAME: &str = "_SUCCESS";
42
43pub const QUARANTINE_PREFIX: &str = "_quarantine";
46
47pub fn run_unique_manifest_name(run_id: &str) -> String {
55 let token: String = run_id
56 .chars()
57 .map(|c| {
58 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
59 c
60 } else {
61 '-'
62 }
63 })
64 .collect();
65 format!("manifest-{token}.json")
66}
67
68pub fn is_run_unique_manifest_name(name: &str) -> bool {
73 name.starts_with("manifest-") && name.ends_with(".json")
74}
75
76pub const DOCTOR_PROBE_FILENAME: &str = ".rivet_doctor_probe";
81
82pub fn join_key(dir: &str, key: &str) -> String {
88 let dir = dir.trim_end_matches('/');
89 if dir.is_empty() {
90 key.to_string()
91 } else {
92 format!("{dir}/{key}")
93 }
94}
95
96pub fn success_marker_body(manifest_bytes: &[u8]) -> String {
109 use xxhash_rust::xxh3::xxh3_64;
110 format!("xxh3:{:016x}\n", xxh3_64(manifest_bytes))
111}
112
113pub fn parse_success_marker(body: &str) -> Option<&str> {
123 let trimmed = body.trim_end_matches(|c: char| c.is_ascii_whitespace());
124 let hex = trimmed.strip_prefix("xxh3:")?;
132 if hex.len() != 16 {
133 return None;
134 }
135 if !hex
136 .chars()
137 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
138 {
139 return None;
140 }
141 Some(trimmed)
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150pub struct ColumnChecksum {
151 pub name: String,
152 pub checksum: String,
153}
154
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct RunManifest {
161 pub manifest_version: u32,
162 pub run_id: String,
163 pub export_name: String,
164 #[serde(default = "default_manifest_mode")]
170 pub mode: String,
171 pub started_at: String,
172 pub finished_at: String,
173 pub status: ManifestStatus,
174 pub source: ManifestSource,
175 pub destination: ManifestDestination,
176 pub format: String,
177 pub compression: String,
178 pub schema_fingerprint: String,
180 pub row_count: i64,
181 pub part_count: u32,
182 pub parts: Vec<ManifestPart>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub column_checksums: Option<Vec<ColumnChecksum>>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub checksum_key_column: Option<String>,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum ManifestStatus {
213 Running,
214 Success,
215 Failed,
216 Interrupted,
217}
218
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
220pub struct ManifestSource {
221 pub engine: String,
222 pub schema: Option<String>,
223 pub table: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub extraction: Option<ExtractionMetadata>,
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct ExtractionMetadata {
242 pub strategy: String,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub cursor_column: Option<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub cursor_type: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub cursor_low: Option<String>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub cursor_high: Option<String>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub source_row_count: Option<i64>,
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct ManifestDestination {
266 pub kind: String,
267 pub uri: String,
268}
269
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
275pub struct ManifestPart {
276 pub part_id: u32,
277 pub path: String,
278 pub rows: i64,
279 pub size_bytes: u64,
280 pub content_fingerprint: String,
284 #[serde(default)]
291 pub content_md5: String,
292 pub status: PartStatus,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(rename_all = "snake_case")]
297pub enum PartStatus {
298 Committed,
300 Quarantined,
302}
303
304impl RunManifest {
305 pub fn committed_rows(&self) -> i64 {
308 self.parts
309 .iter()
310 .filter(|p| p.status == PartStatus::Committed)
311 .map(|p| p.rows)
312 .sum()
313 }
314
315 pub fn committed_part_count(&self) -> usize {
317 self.parts
318 .iter()
319 .filter(|p| p.status == PartStatus::Committed)
320 .count()
321 }
322
323 pub fn validate_self_consistency(&self) -> std::result::Result<(), ManifestInconsistency> {
327 if self.manifest_version != MANIFEST_VERSION {
328 return Err(ManifestInconsistency::UnsupportedVersion {
329 found: self.manifest_version,
330 supported: MANIFEST_VERSION,
331 });
332 }
333 let actual_parts = self.committed_part_count();
334 if actual_parts != self.part_count as usize {
335 return Err(ManifestInconsistency::PartCountMismatch {
336 declared: self.part_count,
337 actual: actual_parts,
338 });
339 }
340 let actual_rows = self.committed_rows();
341 if actual_rows != self.row_count {
342 return Err(ManifestInconsistency::RowCountMismatch {
343 declared: self.row_count,
344 actual: actual_rows,
345 });
346 }
347 let mut ids: Vec<u32> = self.parts.iter().map(|p| p.part_id).collect();
349 ids.sort_unstable();
350 for w in ids.windows(2) {
351 if w[0] == w[1] {
352 return Err(ManifestInconsistency::DuplicatePartId(w[0]));
353 }
354 }
355 Ok(())
356 }
357}
358
359#[derive(Debug, PartialEq)]
364pub enum ManifestInconsistency {
365 UnsupportedVersion { found: u32, supported: u32 },
366 PartCountMismatch { declared: u32, actual: usize },
367 RowCountMismatch { declared: i64, actual: i64 },
368 DuplicatePartId(u32),
369}
370
371impl std::fmt::Display for ManifestInconsistency {
372 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373 match self {
374 Self::UnsupportedVersion { found, supported } => write!(
375 f,
376 "manifest_version {found} is not supported by this build (expected {supported})"
377 ),
378 Self::PartCountMismatch { declared, actual } => write!(
379 f,
380 "part_count declares {declared} parts but {actual} committed parts found"
381 ),
382 Self::RowCountMismatch { declared, actual } => write!(
383 f,
384 "row_count declares {declared} rows but committed parts sum to {actual}"
385 ),
386 Self::DuplicatePartId(id) => {
387 write!(f, "duplicate part_id {id} in manifest.parts")
388 }
389 }
390 }
391}
392
393impl std::error::Error for ManifestInconsistency {}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn part(id: u32, rows: i64, size: u64) -> ManifestPart {
400 ManifestPart {
401 part_id: id,
402 path: format!("part-{id:06}.parquet"),
403 rows,
404 size_bytes: size,
405 content_fingerprint: format!("xxh3:{:016x}", id as u64),
406 content_md5: String::new(),
407 status: PartStatus::Committed,
408 }
409 }
410
411 fn manifest_with_parts(parts: Vec<ManifestPart>) -> RunManifest {
412 let row_count = parts
413 .iter()
414 .filter(|p| p.status == PartStatus::Committed)
415 .map(|p| p.rows)
416 .sum();
417 let part_count = parts
418 .iter()
419 .filter(|p| p.status == PartStatus::Committed)
420 .count() as u32;
421 RunManifest {
422 mode: "batch".to_string(),
423 manifest_version: MANIFEST_VERSION,
424 run_id: "orders_20260521T120000.000".into(),
425 export_name: "public.orders".into(),
426 started_at: "2026-05-21T12:00:00Z".into(),
427 finished_at: "2026-05-21T12:14:33Z".into(),
428 status: ManifestStatus::Success,
429 source: ManifestSource {
430 engine: "postgres".into(),
431 schema: Some("public".into()),
432 table: Some("orders".into()),
433 extraction: None,
434 },
435 destination: ManifestDestination {
436 kind: "gcs".into(),
437 uri: "gs://rivet-exports/public.orders/run/".into(),
438 },
439 format: "parquet".into(),
440 compression: "zstd".into(),
441 schema_fingerprint: "xxh3:0123456789abcdef".into(),
442 row_count,
443 part_count,
444 parts,
445 column_checksums: None,
446 checksum_key_column: None,
447 }
448 }
449
450 #[test]
453 fn manifest_version_is_one() {
454 assert_eq!(MANIFEST_VERSION, 1);
455 }
456
457 #[test]
458 fn filenames_are_stable() {
459 assert_eq!(MANIFEST_FILENAME, "manifest.json");
460 assert_eq!(SUCCESS_FILENAME, "_SUCCESS");
461 assert_eq!(QUARANTINE_PREFIX, "_quarantine");
462 }
463
464 #[test]
465 fn run_unique_manifest_name_sanitizes_and_is_recognised() {
466 let n = run_unique_manifest_name("orders_2026-07-13T12:00:00+00:00");
469 assert_eq!(n, "manifest-orders_2026-07-13T12-00-00-00-00.json");
470 assert!(is_run_unique_manifest_name(&n));
471 assert!(!is_run_unique_manifest_name(MANIFEST_FILENAME));
474 }
475
476 #[test]
479 fn self_consistent_manifest_validates() {
480 let m = manifest_with_parts(vec![part(1, 100, 4096), part(2, 200, 8192)]);
481 assert_eq!(m.validate_self_consistency(), Ok(()));
482 }
483
484 #[test]
485 fn rejects_part_count_mismatch() {
486 let mut m = manifest_with_parts(vec![part(1, 100, 4096)]);
487 m.part_count = 5;
488 assert!(matches!(
489 m.validate_self_consistency(),
490 Err(ManifestInconsistency::PartCountMismatch {
491 declared: 5,
492 actual: 1
493 })
494 ));
495 }
496
497 #[test]
498 fn rejects_row_count_mismatch() {
499 let mut m = manifest_with_parts(vec![part(1, 100, 4096)]);
500 m.row_count = 999;
501 assert!(matches!(
502 m.validate_self_consistency(),
503 Err(ManifestInconsistency::RowCountMismatch {
504 declared: 999,
505 actual: 100
506 })
507 ));
508 }
509
510 #[test]
511 fn rejects_duplicate_part_id() {
512 let m = manifest_with_parts(vec![part(1, 100, 4096), part(1, 200, 8192)]);
513 let err = m.validate_self_consistency().unwrap_err();
514 assert_eq!(err, ManifestInconsistency::DuplicatePartId(1));
515 }
516
517 #[test]
518 fn rejects_unsupported_version() {
519 let mut m = manifest_with_parts(vec![]);
520 m.manifest_version = 999;
521 m.part_count = 0;
522 m.row_count = 0;
523 assert!(matches!(
524 m.validate_self_consistency(),
525 Err(ManifestInconsistency::UnsupportedVersion {
526 found: 999,
527 supported: 1
528 })
529 ));
530 }
531
532 #[test]
535 fn quarantined_parts_do_not_count_toward_row_or_part_totals() {
536 let mut p_q = part(2, 999, 8192);
537 p_q.status = PartStatus::Quarantined;
538 let m = manifest_with_parts(vec![part(1, 100, 4096), p_q]);
539
540 assert_eq!(m.validate_self_consistency(), Ok(()));
542 assert_eq!(m.committed_rows(), 100);
543 assert_eq!(m.committed_part_count(), 1);
544 }
545
546 #[test]
549 fn json_roundtrip_preserves_fields() {
550 let m = manifest_with_parts(vec![part(1, 100, 4096), part(2, 200, 8192)]);
551 let json = serde_json::to_string_pretty(&m).unwrap();
552 let parsed: RunManifest = serde_json::from_str(&json).unwrap();
553 assert_eq!(m, parsed);
554 }
555
556 #[test]
557 fn status_serializes_as_snake_case() {
558 let m = manifest_with_parts(vec![]);
559 let mut m = m;
562 m.part_count = 0;
563 m.row_count = 0;
564 let json = serde_json::to_string(&m).unwrap();
565 assert!(json.contains("\"status\":\"success\""));
566
567 m.status = ManifestStatus::Interrupted;
568 let json = serde_json::to_string(&m).unwrap();
569 assert!(json.contains("\"status\":\"interrupted\""));
570 }
571
572 #[test]
575 fn success_marker_body_is_xxh3_prefix_plus_16_hex_plus_newline() {
576 let body = success_marker_body(b"some manifest bytes");
577 assert!(body.starts_with("xxh3:"), "body = {body:?}");
578 assert!(body.ends_with('\n'), "body = {body:?}");
579 let trimmed = body.trim_end();
580 let hex = &trimmed["xxh3:".len()..];
581 assert_eq!(hex.len(), 16, "body = {body:?}");
582 assert!(
583 hex.chars()
584 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
585 );
586 }
587
588 #[test]
589 fn success_marker_body_is_deterministic_for_same_input() {
590 let a = success_marker_body(b"hello");
591 let b = success_marker_body(b"hello");
592 assert_eq!(a, b);
593 }
594
595 #[test]
596 fn success_marker_body_differs_for_different_manifest_bytes() {
597 let a = success_marker_body(b"manifest one");
598 let b = success_marker_body(b"manifest two");
599 assert_ne!(a, b);
600 }
601
602 #[test]
603 fn parse_success_marker_roundtrips_with_writer() {
604 let body = success_marker_body(b"some manifest bytes");
605 let fp = parse_success_marker(&body).expect("must parse");
606 assert!(fp.starts_with("xxh3:"));
607 assert_eq!(fp.len(), "xxh3:".len() + 16);
608 }
609
610 #[test]
611 fn parse_success_marker_rejects_malformed_bodies() {
612 assert_eq!(parse_success_marker(""), None);
613 assert_eq!(parse_success_marker("\n"), None);
614 assert_eq!(parse_success_marker("sha256:0123456789abcdef"), None);
615 assert_eq!(parse_success_marker("xxh3:0123\n"), None);
617 assert_eq!(parse_success_marker("xxh3:0123456789ABCDEF\n"), None);
619 assert_eq!(parse_success_marker("xxh3:zzzzzzzzzzzzzzzz\n"), None);
621 assert_eq!(parse_success_marker("0123456789abcdef\n"), None);
623 assert_eq!(parse_success_marker("aaa\u{0800}aaaaaaaaaaaaaaa"), None);
629 }
630
631 #[test]
632 fn parse_success_marker_tolerates_trailing_whitespace() {
633 let body = "xxh3:0123456789abcdef\n";
634 assert_eq!(parse_success_marker(body), Some("xxh3:0123456789abcdef"));
635 let body = "xxh3:0123456789abcdef\r\n";
637 assert_eq!(parse_success_marker(body), Some("xxh3:0123456789abcdef"));
638 }
639
640 #[test]
641 fn unknown_fields_are_ignored_by_reader() {
642 let json = r#"{
645 "manifest_version": 1,
646 "run_id": "r1",
647 "export_name": "t",
648 "started_at": "2026-01-01T00:00:00Z",
649 "finished_at": "2026-01-01T00:01:00Z",
650 "status": "success",
651 "source": {"engine": "postgres"},
652 "destination": {"kind": "local", "uri": "file:///tmp/out/"},
653 "format": "parquet",
654 "compression": "zstd",
655 "schema_fingerprint": "xxh3:0000000000000000",
656 "row_count": 0,
657 "part_count": 0,
658 "parts": [],
659 "future_field_added_in_v2": {"nested": true}
660 }"#;
661 let parsed: RunManifest = serde_json::from_str(json).unwrap();
662 assert_eq!(parsed.run_id, "r1");
663 assert_eq!(parsed.validate_self_consistency(), Ok(()));
664 }
665}
666
667fn default_manifest_mode() -> String {
668 "batch".to_string()
669}
670
671pub fn guard_manifest_mode(
677 dest: &dyn crate::destination::Destination,
678 new_mode: &str,
679) -> anyhow::Result<()> {
680 let Ok(bytes) = dest.read("manifest.json") else {
681 return Ok(());
682 };
683 let Ok(existing) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
684 return Ok(());
685 };
686 let Some(existing_mode) = existing.get("mode").and_then(|m| m.as_str()) else {
691 return Ok(());
692 };
693 if existing_mode != new_mode {
694 anyhow::bail!(
695 "destination already holds a '{existing_mode}' manifest (run_id {run}); refusing to \
696 overwrite it with a '{new_mode}' manifest — a batch export and a CDC export sharing \
697 one prefix silently destroy each other's audit trail. Give this export its own \
698 prefix (the scaffold now uses exports/<table>/cdc/ for CDC).",
699 run = existing
700 .get("run_id")
701 .and_then(|r| r.as_str())
702 .unwrap_or("unknown"),
703 );
704 }
705 Ok(())
706}
707
708#[cfg(test)]
709mod mode_guard_tests {
710 use super::*;
711
712 #[test]
713 fn pre_extraction_manifests_parse_with_none() {
714 let old = std::fs::read_to_string(
717 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
718 .join("tests/fixtures/compat/v0.16/cdc_manifest.json"),
719 )
720 .expect("compat fixture");
721 let m: RunManifest = serde_json::from_str(&old).expect("parses");
722 assert!(m.source.extraction.is_none());
723 }
724
725 #[test]
726 fn extraction_roundtrips_and_omits_none_fields() {
727 let ex = ExtractionMetadata {
728 strategy: "incremental".into(),
729 cursor_column: Some("id".into()),
730 cursor_type: None,
731 cursor_low: Some("1000".into()),
732 cursor_high: Some("2000".into()),
733 source_row_count: None,
734 };
735 let j = serde_json::to_string(&ex).unwrap();
736 assert!(!j.contains("cursor_type"), "None fields omitted: {j}");
738 assert!(!j.contains("source_row_count"), "{j}");
739 assert!(j.contains("\"cursor_low\":\"1000\""), "{j}");
740 let back: ExtractionMetadata = serde_json::from_str(&j).unwrap();
741 assert_eq!(back, ex);
742 }
743
744 #[test]
745 fn pre_mode_manifests_default_to_batch() {
746 let old = std::fs::read_to_string(
749 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
750 .join("tests/fixtures/compat/v0.16/cdc_manifest.json"),
751 )
752 .expect("compat fixture");
753 let m: RunManifest = serde_json::from_str(&old).expect("old manifests parse");
754 assert_eq!(m.mode, "batch");
755 }
756
757 #[test]
758 fn cross_shape_overwrite_is_refused_same_shape_allowed() {
759 let d = tempfile::tempdir().unwrap();
760 let dest = crate::destination::create_destination(&crate::config::DestinationConfig {
761 destination_type: crate::config::DestinationType::Local,
762 path: Some(d.path().to_str().unwrap().to_string()),
763 ..Default::default()
764 })
765 .unwrap();
766 guard_manifest_mode(dest.as_ref(), "batch").unwrap();
768 std::fs::write(
769 d.path().join("manifest.json"),
770 r#"{"mode":"batch","run_id":"r1"}"#,
771 )
772 .unwrap();
773 guard_manifest_mode(dest.as_ref(), "batch").expect("same shape re-run allowed");
774 let err = guard_manifest_mode(dest.as_ref(), "cdc")
775 .unwrap_err()
776 .to_string();
777 assert!(err.contains("refusing to"), "loud refusal: {err}");
778 assert!(
779 err.contains("exports/<table>/cdc/"),
780 "carries the recovery: {err}"
781 );
782 std::fs::write(d.path().join("manifest.json"), r#"{"run_id":"legacy"}"#).unwrap();
786 guard_manifest_mode(dest.as_ref(), "cdc").expect("legacy prefixes must keep resuming");
787 guard_manifest_mode(dest.as_ref(), "batch").expect("in either direction");
788 }
789}