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 if trimmed.len() != "xxh3:".len() + 16 {
125 return None;
126 }
127 let (prefix, hex) = trimmed.split_at("xxh3:".len());
128 if prefix != "xxh3:" {
129 return None;
130 }
131 if !hex
132 .chars()
133 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
134 {
135 return None;
136 }
137 Some(trimmed)
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct ColumnChecksum {
147 pub name: String,
148 pub checksum: String,
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct RunManifest {
157 pub manifest_version: u32,
158 pub run_id: String,
159 pub export_name: String,
160 #[serde(default = "default_manifest_mode")]
166 pub mode: String,
167 pub started_at: String,
168 pub finished_at: String,
169 pub status: ManifestStatus,
170 pub source: ManifestSource,
171 pub destination: ManifestDestination,
172 pub format: String,
173 pub compression: String,
174 pub schema_fingerprint: String,
176 pub row_count: i64,
177 pub part_count: u32,
178 pub parts: Vec<ManifestPart>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub column_checksums: Option<Vec<ColumnChecksum>>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub checksum_key_column: Option<String>,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum ManifestStatus {
203 Success,
204 Failed,
205 Interrupted,
206}
207
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct ManifestSource {
210 pub engine: String,
211 pub schema: Option<String>,
212 pub table: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub extraction: Option<ExtractionMetadata>,
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230pub struct ExtractionMetadata {
231 pub strategy: String,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub cursor_column: Option<String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub cursor_type: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub cursor_low: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub cursor_high: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub source_row_count: Option<i64>,
251}
252
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct ManifestDestination {
255 pub kind: String,
256 pub uri: String,
257}
258
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264pub struct ManifestPart {
265 pub part_id: u32,
266 pub path: String,
267 pub rows: i64,
268 pub size_bytes: u64,
269 pub content_fingerprint: String,
273 #[serde(default)]
280 pub content_md5: String,
281 pub status: PartStatus,
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286pub enum PartStatus {
287 Committed,
289 Quarantined,
291}
292
293impl RunManifest {
294 pub fn committed_rows(&self) -> i64 {
297 self.parts
298 .iter()
299 .filter(|p| p.status == PartStatus::Committed)
300 .map(|p| p.rows)
301 .sum()
302 }
303
304 pub fn committed_part_count(&self) -> usize {
306 self.parts
307 .iter()
308 .filter(|p| p.status == PartStatus::Committed)
309 .count()
310 }
311
312 pub fn validate_self_consistency(&self) -> std::result::Result<(), ManifestInconsistency> {
316 if self.manifest_version != MANIFEST_VERSION {
317 return Err(ManifestInconsistency::UnsupportedVersion {
318 found: self.manifest_version,
319 supported: MANIFEST_VERSION,
320 });
321 }
322 let actual_parts = self.committed_part_count();
323 if actual_parts != self.part_count as usize {
324 return Err(ManifestInconsistency::PartCountMismatch {
325 declared: self.part_count,
326 actual: actual_parts,
327 });
328 }
329 let actual_rows = self.committed_rows();
330 if actual_rows != self.row_count {
331 return Err(ManifestInconsistency::RowCountMismatch {
332 declared: self.row_count,
333 actual: actual_rows,
334 });
335 }
336 let mut ids: Vec<u32> = self.parts.iter().map(|p| p.part_id).collect();
338 ids.sort_unstable();
339 for w in ids.windows(2) {
340 if w[0] == w[1] {
341 return Err(ManifestInconsistency::DuplicatePartId(w[0]));
342 }
343 }
344 Ok(())
345 }
346}
347
348#[derive(Debug, PartialEq)]
353pub enum ManifestInconsistency {
354 UnsupportedVersion { found: u32, supported: u32 },
355 PartCountMismatch { declared: u32, actual: usize },
356 RowCountMismatch { declared: i64, actual: i64 },
357 DuplicatePartId(u32),
358}
359
360impl std::fmt::Display for ManifestInconsistency {
361 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362 match self {
363 Self::UnsupportedVersion { found, supported } => write!(
364 f,
365 "manifest_version {found} is not supported by this build (expected {supported})"
366 ),
367 Self::PartCountMismatch { declared, actual } => write!(
368 f,
369 "part_count declares {declared} parts but {actual} committed parts found"
370 ),
371 Self::RowCountMismatch { declared, actual } => write!(
372 f,
373 "row_count declares {declared} rows but committed parts sum to {actual}"
374 ),
375 Self::DuplicatePartId(id) => {
376 write!(f, "duplicate part_id {id} in manifest.parts")
377 }
378 }
379 }
380}
381
382impl std::error::Error for ManifestInconsistency {}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 fn part(id: u32, rows: i64, size: u64) -> ManifestPart {
389 ManifestPart {
390 part_id: id,
391 path: format!("part-{id:06}.parquet"),
392 rows,
393 size_bytes: size,
394 content_fingerprint: format!("xxh3:{:016x}", id as u64),
395 content_md5: String::new(),
396 status: PartStatus::Committed,
397 }
398 }
399
400 fn manifest_with_parts(parts: Vec<ManifestPart>) -> RunManifest {
401 let row_count = parts
402 .iter()
403 .filter(|p| p.status == PartStatus::Committed)
404 .map(|p| p.rows)
405 .sum();
406 let part_count = parts
407 .iter()
408 .filter(|p| p.status == PartStatus::Committed)
409 .count() as u32;
410 RunManifest {
411 mode: "batch".to_string(),
412 manifest_version: MANIFEST_VERSION,
413 run_id: "orders_20260521T120000.000".into(),
414 export_name: "public.orders".into(),
415 started_at: "2026-05-21T12:00:00Z".into(),
416 finished_at: "2026-05-21T12:14:33Z".into(),
417 status: ManifestStatus::Success,
418 source: ManifestSource {
419 engine: "postgres".into(),
420 schema: Some("public".into()),
421 table: Some("orders".into()),
422 extraction: None,
423 },
424 destination: ManifestDestination {
425 kind: "gcs".into(),
426 uri: "gs://rivet-exports/public.orders/run/".into(),
427 },
428 format: "parquet".into(),
429 compression: "zstd".into(),
430 schema_fingerprint: "xxh3:0123456789abcdef".into(),
431 row_count,
432 part_count,
433 parts,
434 column_checksums: None,
435 checksum_key_column: None,
436 }
437 }
438
439 #[test]
442 fn manifest_version_is_one() {
443 assert_eq!(MANIFEST_VERSION, 1);
444 }
445
446 #[test]
447 fn filenames_are_stable() {
448 assert_eq!(MANIFEST_FILENAME, "manifest.json");
449 assert_eq!(SUCCESS_FILENAME, "_SUCCESS");
450 assert_eq!(QUARANTINE_PREFIX, "_quarantine");
451 }
452
453 #[test]
454 fn run_unique_manifest_name_sanitizes_and_is_recognised() {
455 let n = run_unique_manifest_name("orders_2026-07-13T12:00:00+00:00");
458 assert_eq!(n, "manifest-orders_2026-07-13T12-00-00-00-00.json");
459 assert!(is_run_unique_manifest_name(&n));
460 assert!(!is_run_unique_manifest_name(MANIFEST_FILENAME));
463 }
464
465 #[test]
468 fn self_consistent_manifest_validates() {
469 let m = manifest_with_parts(vec![part(1, 100, 4096), part(2, 200, 8192)]);
470 assert_eq!(m.validate_self_consistency(), Ok(()));
471 }
472
473 #[test]
474 fn rejects_part_count_mismatch() {
475 let mut m = manifest_with_parts(vec![part(1, 100, 4096)]);
476 m.part_count = 5;
477 assert!(matches!(
478 m.validate_self_consistency(),
479 Err(ManifestInconsistency::PartCountMismatch {
480 declared: 5,
481 actual: 1
482 })
483 ));
484 }
485
486 #[test]
487 fn rejects_row_count_mismatch() {
488 let mut m = manifest_with_parts(vec![part(1, 100, 4096)]);
489 m.row_count = 999;
490 assert!(matches!(
491 m.validate_self_consistency(),
492 Err(ManifestInconsistency::RowCountMismatch {
493 declared: 999,
494 actual: 100
495 })
496 ));
497 }
498
499 #[test]
500 fn rejects_duplicate_part_id() {
501 let m = manifest_with_parts(vec![part(1, 100, 4096), part(1, 200, 8192)]);
502 let err = m.validate_self_consistency().unwrap_err();
503 assert_eq!(err, ManifestInconsistency::DuplicatePartId(1));
504 }
505
506 #[test]
507 fn rejects_unsupported_version() {
508 let mut m = manifest_with_parts(vec![]);
509 m.manifest_version = 999;
510 m.part_count = 0;
511 m.row_count = 0;
512 assert!(matches!(
513 m.validate_self_consistency(),
514 Err(ManifestInconsistency::UnsupportedVersion {
515 found: 999,
516 supported: 1
517 })
518 ));
519 }
520
521 #[test]
524 fn quarantined_parts_do_not_count_toward_row_or_part_totals() {
525 let mut p_q = part(2, 999, 8192);
526 p_q.status = PartStatus::Quarantined;
527 let m = manifest_with_parts(vec![part(1, 100, 4096), p_q]);
528
529 assert_eq!(m.validate_self_consistency(), Ok(()));
531 assert_eq!(m.committed_rows(), 100);
532 assert_eq!(m.committed_part_count(), 1);
533 }
534
535 #[test]
538 fn json_roundtrip_preserves_fields() {
539 let m = manifest_with_parts(vec![part(1, 100, 4096), part(2, 200, 8192)]);
540 let json = serde_json::to_string_pretty(&m).unwrap();
541 let parsed: RunManifest = serde_json::from_str(&json).unwrap();
542 assert_eq!(m, parsed);
543 }
544
545 #[test]
546 fn status_serializes_as_snake_case() {
547 let m = manifest_with_parts(vec![]);
548 let mut m = m;
551 m.part_count = 0;
552 m.row_count = 0;
553 let json = serde_json::to_string(&m).unwrap();
554 assert!(json.contains("\"status\":\"success\""));
555
556 m.status = ManifestStatus::Interrupted;
557 let json = serde_json::to_string(&m).unwrap();
558 assert!(json.contains("\"status\":\"interrupted\""));
559 }
560
561 #[test]
564 fn success_marker_body_is_xxh3_prefix_plus_16_hex_plus_newline() {
565 let body = success_marker_body(b"some manifest bytes");
566 assert!(body.starts_with("xxh3:"), "body = {body:?}");
567 assert!(body.ends_with('\n'), "body = {body:?}");
568 let trimmed = body.trim_end();
569 let hex = &trimmed["xxh3:".len()..];
570 assert_eq!(hex.len(), 16, "body = {body:?}");
571 assert!(
572 hex.chars()
573 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
574 );
575 }
576
577 #[test]
578 fn success_marker_body_is_deterministic_for_same_input() {
579 let a = success_marker_body(b"hello");
580 let b = success_marker_body(b"hello");
581 assert_eq!(a, b);
582 }
583
584 #[test]
585 fn success_marker_body_differs_for_different_manifest_bytes() {
586 let a = success_marker_body(b"manifest one");
587 let b = success_marker_body(b"manifest two");
588 assert_ne!(a, b);
589 }
590
591 #[test]
592 fn parse_success_marker_roundtrips_with_writer() {
593 let body = success_marker_body(b"some manifest bytes");
594 let fp = parse_success_marker(&body).expect("must parse");
595 assert!(fp.starts_with("xxh3:"));
596 assert_eq!(fp.len(), "xxh3:".len() + 16);
597 }
598
599 #[test]
600 fn parse_success_marker_rejects_malformed_bodies() {
601 assert_eq!(parse_success_marker(""), None);
602 assert_eq!(parse_success_marker("\n"), None);
603 assert_eq!(parse_success_marker("sha256:0123456789abcdef"), None);
604 assert_eq!(parse_success_marker("xxh3:0123\n"), None);
606 assert_eq!(parse_success_marker("xxh3:0123456789ABCDEF\n"), None);
608 assert_eq!(parse_success_marker("xxh3:zzzzzzzzzzzzzzzz\n"), None);
610 assert_eq!(parse_success_marker("0123456789abcdef\n"), None);
612 }
613
614 #[test]
615 fn parse_success_marker_tolerates_trailing_whitespace() {
616 let body = "xxh3:0123456789abcdef\n";
617 assert_eq!(parse_success_marker(body), Some("xxh3:0123456789abcdef"));
618 let body = "xxh3:0123456789abcdef\r\n";
620 assert_eq!(parse_success_marker(body), Some("xxh3:0123456789abcdef"));
621 }
622
623 #[test]
624 fn unknown_fields_are_ignored_by_reader() {
625 let json = r#"{
628 "manifest_version": 1,
629 "run_id": "r1",
630 "export_name": "t",
631 "started_at": "2026-01-01T00:00:00Z",
632 "finished_at": "2026-01-01T00:01:00Z",
633 "status": "success",
634 "source": {"engine": "postgres"},
635 "destination": {"kind": "local", "uri": "file:///tmp/out/"},
636 "format": "parquet",
637 "compression": "zstd",
638 "schema_fingerprint": "xxh3:0000000000000000",
639 "row_count": 0,
640 "part_count": 0,
641 "parts": [],
642 "future_field_added_in_v2": {"nested": true}
643 }"#;
644 let parsed: RunManifest = serde_json::from_str(json).unwrap();
645 assert_eq!(parsed.run_id, "r1");
646 assert_eq!(parsed.validate_self_consistency(), Ok(()));
647 }
648}
649
650fn default_manifest_mode() -> String {
651 "batch".to_string()
652}
653
654pub fn guard_manifest_mode(
660 dest: &dyn crate::destination::Destination,
661 new_mode: &str,
662) -> anyhow::Result<()> {
663 let Ok(bytes) = dest.read("manifest.json") else {
664 return Ok(());
665 };
666 let Ok(existing) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
667 return Ok(());
668 };
669 let Some(existing_mode) = existing.get("mode").and_then(|m| m.as_str()) else {
674 return Ok(());
675 };
676 if existing_mode != new_mode {
677 anyhow::bail!(
678 "destination already holds a '{existing_mode}' manifest (run_id {run}); refusing to \
679 overwrite it with a '{new_mode}' manifest — a batch export and a CDC export sharing \
680 one prefix silently destroy each other's audit trail. Give this export its own \
681 prefix (the scaffold now uses exports/<table>/cdc/ for CDC).",
682 run = existing
683 .get("run_id")
684 .and_then(|r| r.as_str())
685 .unwrap_or("unknown"),
686 );
687 }
688 Ok(())
689}
690
691#[cfg(test)]
692mod mode_guard_tests {
693 use super::*;
694
695 #[test]
696 fn pre_extraction_manifests_parse_with_none() {
697 let old = std::fs::read_to_string(
700 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
701 .join("tests/fixtures/compat/v0.16/cdc_manifest.json"),
702 )
703 .expect("compat fixture");
704 let m: RunManifest = serde_json::from_str(&old).expect("parses");
705 assert!(m.source.extraction.is_none());
706 }
707
708 #[test]
709 fn extraction_roundtrips_and_omits_none_fields() {
710 let ex = ExtractionMetadata {
711 strategy: "incremental".into(),
712 cursor_column: Some("id".into()),
713 cursor_type: None,
714 cursor_low: Some("1000".into()),
715 cursor_high: Some("2000".into()),
716 source_row_count: None,
717 };
718 let j = serde_json::to_string(&ex).unwrap();
719 assert!(!j.contains("cursor_type"), "None fields omitted: {j}");
721 assert!(!j.contains("source_row_count"), "{j}");
722 assert!(j.contains("\"cursor_low\":\"1000\""), "{j}");
723 let back: ExtractionMetadata = serde_json::from_str(&j).unwrap();
724 assert_eq!(back, ex);
725 }
726
727 #[test]
728 fn pre_mode_manifests_default_to_batch() {
729 let old = std::fs::read_to_string(
732 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
733 .join("tests/fixtures/compat/v0.16/cdc_manifest.json"),
734 )
735 .expect("compat fixture");
736 let m: RunManifest = serde_json::from_str(&old).expect("old manifests parse");
737 assert_eq!(m.mode, "batch");
738 }
739
740 #[test]
741 fn cross_shape_overwrite_is_refused_same_shape_allowed() {
742 let d = tempfile::tempdir().unwrap();
743 let dest = crate::destination::create_destination(&crate::config::DestinationConfig {
744 destination_type: crate::config::DestinationType::Local,
745 path: Some(d.path().to_str().unwrap().to_string()),
746 ..Default::default()
747 })
748 .unwrap();
749 guard_manifest_mode(dest.as_ref(), "batch").unwrap();
751 std::fs::write(
752 d.path().join("manifest.json"),
753 r#"{"mode":"batch","run_id":"r1"}"#,
754 )
755 .unwrap();
756 guard_manifest_mode(dest.as_ref(), "batch").expect("same shape re-run allowed");
757 let err = guard_manifest_mode(dest.as_ref(), "cdc")
758 .unwrap_err()
759 .to_string();
760 assert!(err.contains("refusing to"), "loud refusal: {err}");
761 assert!(
762 err.contains("exports/<table>/cdc/"),
763 "carries the recovery: {err}"
764 );
765 std::fs::write(d.path().join("manifest.json"), r#"{"run_id":"legacy"}"#).unwrap();
769 guard_manifest_mode(dest.as_ref(), "cdc").expect("legacy prefixes must keep resuming");
770 guard_manifest_mode(dest.as_ref(), "batch").expect("in either direction");
771 }
772}