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)]
205#[serde(rename_all = "snake_case")]
206pub enum ManifestStatus {
207 Success,
208 Failed,
209 Interrupted,
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213pub struct ManifestSource {
214 pub engine: String,
215 pub schema: Option<String>,
216 pub table: Option<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub extraction: Option<ExtractionMetadata>,
227}
228
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234pub struct ExtractionMetadata {
235 pub strategy: String,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub cursor_column: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub cursor_type: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub cursor_low: Option<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub cursor_high: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub source_row_count: Option<i64>,
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258pub struct ManifestDestination {
259 pub kind: String,
260 pub uri: String,
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
268pub struct ManifestPart {
269 pub part_id: u32,
270 pub path: String,
271 pub rows: i64,
272 pub size_bytes: u64,
273 pub content_fingerprint: String,
277 #[serde(default)]
284 pub content_md5: String,
285 pub status: PartStatus,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum PartStatus {
291 Committed,
293 Quarantined,
295}
296
297impl RunManifest {
298 pub fn committed_rows(&self) -> i64 {
301 self.parts
302 .iter()
303 .filter(|p| p.status == PartStatus::Committed)
304 .map(|p| p.rows)
305 .sum()
306 }
307
308 pub fn committed_part_count(&self) -> usize {
310 self.parts
311 .iter()
312 .filter(|p| p.status == PartStatus::Committed)
313 .count()
314 }
315
316 pub fn validate_self_consistency(&self) -> std::result::Result<(), ManifestInconsistency> {
320 if self.manifest_version != MANIFEST_VERSION {
321 return Err(ManifestInconsistency::UnsupportedVersion {
322 found: self.manifest_version,
323 supported: MANIFEST_VERSION,
324 });
325 }
326 let actual_parts = self.committed_part_count();
327 if actual_parts != self.part_count as usize {
328 return Err(ManifestInconsistency::PartCountMismatch {
329 declared: self.part_count,
330 actual: actual_parts,
331 });
332 }
333 let actual_rows = self.committed_rows();
334 if actual_rows != self.row_count {
335 return Err(ManifestInconsistency::RowCountMismatch {
336 declared: self.row_count,
337 actual: actual_rows,
338 });
339 }
340 let mut ids: Vec<u32> = self.parts.iter().map(|p| p.part_id).collect();
342 ids.sort_unstable();
343 for w in ids.windows(2) {
344 if w[0] == w[1] {
345 return Err(ManifestInconsistency::DuplicatePartId(w[0]));
346 }
347 }
348 Ok(())
349 }
350}
351
352#[derive(Debug, PartialEq)]
357pub enum ManifestInconsistency {
358 UnsupportedVersion { found: u32, supported: u32 },
359 PartCountMismatch { declared: u32, actual: usize },
360 RowCountMismatch { declared: i64, actual: i64 },
361 DuplicatePartId(u32),
362}
363
364impl std::fmt::Display for ManifestInconsistency {
365 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366 match self {
367 Self::UnsupportedVersion { found, supported } => write!(
368 f,
369 "manifest_version {found} is not supported by this build (expected {supported})"
370 ),
371 Self::PartCountMismatch { declared, actual } => write!(
372 f,
373 "part_count declares {declared} parts but {actual} committed parts found"
374 ),
375 Self::RowCountMismatch { declared, actual } => write!(
376 f,
377 "row_count declares {declared} rows but committed parts sum to {actual}"
378 ),
379 Self::DuplicatePartId(id) => {
380 write!(f, "duplicate part_id {id} in manifest.parts")
381 }
382 }
383 }
384}
385
386impl std::error::Error for ManifestInconsistency {}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 fn part(id: u32, rows: i64, size: u64) -> ManifestPart {
393 ManifestPart {
394 part_id: id,
395 path: format!("part-{id:06}.parquet"),
396 rows,
397 size_bytes: size,
398 content_fingerprint: format!("xxh3:{:016x}", id as u64),
399 content_md5: String::new(),
400 status: PartStatus::Committed,
401 }
402 }
403
404 fn manifest_with_parts(parts: Vec<ManifestPart>) -> RunManifest {
405 let row_count = parts
406 .iter()
407 .filter(|p| p.status == PartStatus::Committed)
408 .map(|p| p.rows)
409 .sum();
410 let part_count = parts
411 .iter()
412 .filter(|p| p.status == PartStatus::Committed)
413 .count() as u32;
414 RunManifest {
415 mode: "batch".to_string(),
416 manifest_version: MANIFEST_VERSION,
417 run_id: "orders_20260521T120000.000".into(),
418 export_name: "public.orders".into(),
419 started_at: "2026-05-21T12:00:00Z".into(),
420 finished_at: "2026-05-21T12:14:33Z".into(),
421 status: ManifestStatus::Success,
422 source: ManifestSource {
423 engine: "postgres".into(),
424 schema: Some("public".into()),
425 table: Some("orders".into()),
426 extraction: None,
427 },
428 destination: ManifestDestination {
429 kind: "gcs".into(),
430 uri: "gs://rivet-exports/public.orders/run/".into(),
431 },
432 format: "parquet".into(),
433 compression: "zstd".into(),
434 schema_fingerprint: "xxh3:0123456789abcdef".into(),
435 row_count,
436 part_count,
437 parts,
438 column_checksums: None,
439 checksum_key_column: None,
440 }
441 }
442
443 #[test]
446 fn manifest_version_is_one() {
447 assert_eq!(MANIFEST_VERSION, 1);
448 }
449
450 #[test]
451 fn filenames_are_stable() {
452 assert_eq!(MANIFEST_FILENAME, "manifest.json");
453 assert_eq!(SUCCESS_FILENAME, "_SUCCESS");
454 assert_eq!(QUARANTINE_PREFIX, "_quarantine");
455 }
456
457 #[test]
458 fn run_unique_manifest_name_sanitizes_and_is_recognised() {
459 let n = run_unique_manifest_name("orders_2026-07-13T12:00:00+00:00");
462 assert_eq!(n, "manifest-orders_2026-07-13T12-00-00-00-00.json");
463 assert!(is_run_unique_manifest_name(&n));
464 assert!(!is_run_unique_manifest_name(MANIFEST_FILENAME));
467 }
468
469 #[test]
472 fn self_consistent_manifest_validates() {
473 let m = manifest_with_parts(vec![part(1, 100, 4096), part(2, 200, 8192)]);
474 assert_eq!(m.validate_self_consistency(), Ok(()));
475 }
476
477 #[test]
478 fn rejects_part_count_mismatch() {
479 let mut m = manifest_with_parts(vec![part(1, 100, 4096)]);
480 m.part_count = 5;
481 assert!(matches!(
482 m.validate_self_consistency(),
483 Err(ManifestInconsistency::PartCountMismatch {
484 declared: 5,
485 actual: 1
486 })
487 ));
488 }
489
490 #[test]
491 fn rejects_row_count_mismatch() {
492 let mut m = manifest_with_parts(vec![part(1, 100, 4096)]);
493 m.row_count = 999;
494 assert!(matches!(
495 m.validate_self_consistency(),
496 Err(ManifestInconsistency::RowCountMismatch {
497 declared: 999,
498 actual: 100
499 })
500 ));
501 }
502
503 #[test]
504 fn rejects_duplicate_part_id() {
505 let m = manifest_with_parts(vec![part(1, 100, 4096), part(1, 200, 8192)]);
506 let err = m.validate_self_consistency().unwrap_err();
507 assert_eq!(err, ManifestInconsistency::DuplicatePartId(1));
508 }
509
510 #[test]
511 fn rejects_unsupported_version() {
512 let mut m = manifest_with_parts(vec![]);
513 m.manifest_version = 999;
514 m.part_count = 0;
515 m.row_count = 0;
516 assert!(matches!(
517 m.validate_self_consistency(),
518 Err(ManifestInconsistency::UnsupportedVersion {
519 found: 999,
520 supported: 1
521 })
522 ));
523 }
524
525 #[test]
528 fn quarantined_parts_do_not_count_toward_row_or_part_totals() {
529 let mut p_q = part(2, 999, 8192);
530 p_q.status = PartStatus::Quarantined;
531 let m = manifest_with_parts(vec![part(1, 100, 4096), p_q]);
532
533 assert_eq!(m.validate_self_consistency(), Ok(()));
535 assert_eq!(m.committed_rows(), 100);
536 assert_eq!(m.committed_part_count(), 1);
537 }
538
539 #[test]
542 fn json_roundtrip_preserves_fields() {
543 let m = manifest_with_parts(vec![part(1, 100, 4096), part(2, 200, 8192)]);
544 let json = serde_json::to_string_pretty(&m).unwrap();
545 let parsed: RunManifest = serde_json::from_str(&json).unwrap();
546 assert_eq!(m, parsed);
547 }
548
549 #[test]
550 fn status_serializes_as_snake_case() {
551 let m = manifest_with_parts(vec![]);
552 let mut m = m;
555 m.part_count = 0;
556 m.row_count = 0;
557 let json = serde_json::to_string(&m).unwrap();
558 assert!(json.contains("\"status\":\"success\""));
559
560 m.status = ManifestStatus::Interrupted;
561 let json = serde_json::to_string(&m).unwrap();
562 assert!(json.contains("\"status\":\"interrupted\""));
563 }
564
565 #[test]
568 fn success_marker_body_is_xxh3_prefix_plus_16_hex_plus_newline() {
569 let body = success_marker_body(b"some manifest bytes");
570 assert!(body.starts_with("xxh3:"), "body = {body:?}");
571 assert!(body.ends_with('\n'), "body = {body:?}");
572 let trimmed = body.trim_end();
573 let hex = &trimmed["xxh3:".len()..];
574 assert_eq!(hex.len(), 16, "body = {body:?}");
575 assert!(
576 hex.chars()
577 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
578 );
579 }
580
581 #[test]
582 fn success_marker_body_is_deterministic_for_same_input() {
583 let a = success_marker_body(b"hello");
584 let b = success_marker_body(b"hello");
585 assert_eq!(a, b);
586 }
587
588 #[test]
589 fn success_marker_body_differs_for_different_manifest_bytes() {
590 let a = success_marker_body(b"manifest one");
591 let b = success_marker_body(b"manifest two");
592 assert_ne!(a, b);
593 }
594
595 #[test]
596 fn parse_success_marker_roundtrips_with_writer() {
597 let body = success_marker_body(b"some manifest bytes");
598 let fp = parse_success_marker(&body).expect("must parse");
599 assert!(fp.starts_with("xxh3:"));
600 assert_eq!(fp.len(), "xxh3:".len() + 16);
601 }
602
603 #[test]
604 fn parse_success_marker_rejects_malformed_bodies() {
605 assert_eq!(parse_success_marker(""), None);
606 assert_eq!(parse_success_marker("\n"), None);
607 assert_eq!(parse_success_marker("sha256:0123456789abcdef"), None);
608 assert_eq!(parse_success_marker("xxh3:0123\n"), None);
610 assert_eq!(parse_success_marker("xxh3:0123456789ABCDEF\n"), None);
612 assert_eq!(parse_success_marker("xxh3:zzzzzzzzzzzzzzzz\n"), None);
614 assert_eq!(parse_success_marker("0123456789abcdef\n"), None);
616 assert_eq!(parse_success_marker("aaa\u{0800}aaaaaaaaaaaaaaa"), None);
622 }
623
624 #[test]
625 fn parse_success_marker_tolerates_trailing_whitespace() {
626 let body = "xxh3:0123456789abcdef\n";
627 assert_eq!(parse_success_marker(body), Some("xxh3:0123456789abcdef"));
628 let body = "xxh3:0123456789abcdef\r\n";
630 assert_eq!(parse_success_marker(body), Some("xxh3:0123456789abcdef"));
631 }
632
633 #[test]
634 fn unknown_fields_are_ignored_by_reader() {
635 let json = r#"{
638 "manifest_version": 1,
639 "run_id": "r1",
640 "export_name": "t",
641 "started_at": "2026-01-01T00:00:00Z",
642 "finished_at": "2026-01-01T00:01:00Z",
643 "status": "success",
644 "source": {"engine": "postgres"},
645 "destination": {"kind": "local", "uri": "file:///tmp/out/"},
646 "format": "parquet",
647 "compression": "zstd",
648 "schema_fingerprint": "xxh3:0000000000000000",
649 "row_count": 0,
650 "part_count": 0,
651 "parts": [],
652 "future_field_added_in_v2": {"nested": true}
653 }"#;
654 let parsed: RunManifest = serde_json::from_str(json).unwrap();
655 assert_eq!(parsed.run_id, "r1");
656 assert_eq!(parsed.validate_self_consistency(), Ok(()));
657 }
658}
659
660fn default_manifest_mode() -> String {
661 "batch".to_string()
662}
663
664pub fn guard_manifest_mode(
670 dest: &dyn crate::destination::Destination,
671 new_mode: &str,
672) -> anyhow::Result<()> {
673 let Ok(bytes) = dest.read("manifest.json") else {
674 return Ok(());
675 };
676 let Ok(existing) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
677 return Ok(());
678 };
679 let Some(existing_mode) = existing.get("mode").and_then(|m| m.as_str()) else {
684 return Ok(());
685 };
686 if existing_mode != new_mode {
687 anyhow::bail!(
688 "destination already holds a '{existing_mode}' manifest (run_id {run}); refusing to \
689 overwrite it with a '{new_mode}' manifest — a batch export and a CDC export sharing \
690 one prefix silently destroy each other's audit trail. Give this export its own \
691 prefix (the scaffold now uses exports/<table>/cdc/ for CDC).",
692 run = existing
693 .get("run_id")
694 .and_then(|r| r.as_str())
695 .unwrap_or("unknown"),
696 );
697 }
698 Ok(())
699}
700
701#[cfg(test)]
702mod mode_guard_tests {
703 use super::*;
704
705 #[test]
706 fn pre_extraction_manifests_parse_with_none() {
707 let old = std::fs::read_to_string(
710 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
711 .join("tests/fixtures/compat/v0.16/cdc_manifest.json"),
712 )
713 .expect("compat fixture");
714 let m: RunManifest = serde_json::from_str(&old).expect("parses");
715 assert!(m.source.extraction.is_none());
716 }
717
718 #[test]
719 fn extraction_roundtrips_and_omits_none_fields() {
720 let ex = ExtractionMetadata {
721 strategy: "incremental".into(),
722 cursor_column: Some("id".into()),
723 cursor_type: None,
724 cursor_low: Some("1000".into()),
725 cursor_high: Some("2000".into()),
726 source_row_count: None,
727 };
728 let j = serde_json::to_string(&ex).unwrap();
729 assert!(!j.contains("cursor_type"), "None fields omitted: {j}");
731 assert!(!j.contains("source_row_count"), "{j}");
732 assert!(j.contains("\"cursor_low\":\"1000\""), "{j}");
733 let back: ExtractionMetadata = serde_json::from_str(&j).unwrap();
734 assert_eq!(back, ex);
735 }
736
737 #[test]
738 fn pre_mode_manifests_default_to_batch() {
739 let old = std::fs::read_to_string(
742 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
743 .join("tests/fixtures/compat/v0.16/cdc_manifest.json"),
744 )
745 .expect("compat fixture");
746 let m: RunManifest = serde_json::from_str(&old).expect("old manifests parse");
747 assert_eq!(m.mode, "batch");
748 }
749
750 #[test]
751 fn cross_shape_overwrite_is_refused_same_shape_allowed() {
752 let d = tempfile::tempdir().unwrap();
753 let dest = crate::destination::create_destination(&crate::config::DestinationConfig {
754 destination_type: crate::config::DestinationType::Local,
755 path: Some(d.path().to_str().unwrap().to_string()),
756 ..Default::default()
757 })
758 .unwrap();
759 guard_manifest_mode(dest.as_ref(), "batch").unwrap();
761 std::fs::write(
762 d.path().join("manifest.json"),
763 r#"{"mode":"batch","run_id":"r1"}"#,
764 )
765 .unwrap();
766 guard_manifest_mode(dest.as_ref(), "batch").expect("same shape re-run allowed");
767 let err = guard_manifest_mode(dest.as_ref(), "cdc")
768 .unwrap_err()
769 .to_string();
770 assert!(err.contains("refusing to"), "loud refusal: {err}");
771 assert!(
772 err.contains("exports/<table>/cdc/"),
773 "carries the recovery: {err}"
774 );
775 std::fs::write(d.path().join("manifest.json"), r#"{"run_id":"legacy"}"#).unwrap();
779 guard_manifest_mode(dest.as_ref(), "cdc").expect("legacy prefixes must keep resuming");
780 guard_manifest_mode(dest.as_ref(), "batch").expect("in either direction");
781 }
782}