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 const DOCTOR_PROBE_FILENAME: &str = ".rivet_doctor_probe";
52
53pub fn join_key(dir: &str, key: &str) -> String {
59 let dir = dir.trim_end_matches('/');
60 if dir.is_empty() {
61 key.to_string()
62 } else {
63 format!("{dir}/{key}")
64 }
65}
66
67pub fn success_marker_body(manifest_bytes: &[u8]) -> String {
80 use xxhash_rust::xxh3::xxh3_64;
81 format!("xxh3:{:016x}\n", xxh3_64(manifest_bytes))
82}
83
84pub fn parse_success_marker(body: &str) -> Option<&str> {
94 let trimmed = body.trim_end_matches(|c: char| c.is_ascii_whitespace());
95 if trimmed.len() != "xxh3:".len() + 16 {
96 return None;
97 }
98 let (prefix, hex) = trimmed.split_at("xxh3:".len());
99 if prefix != "xxh3:" {
100 return None;
101 }
102 if !hex
103 .chars()
104 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
105 {
106 return None;
107 }
108 Some(trimmed)
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct ColumnChecksum {
118 pub name: String,
119 pub checksum: String,
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct RunManifest {
128 pub manifest_version: u32,
129 pub run_id: String,
130 pub export_name: String,
131 #[serde(default = "default_manifest_mode")]
137 pub mode: String,
138 pub started_at: String,
139 pub finished_at: String,
140 pub status: ManifestStatus,
141 pub source: ManifestSource,
142 pub destination: ManifestDestination,
143 pub format: String,
144 pub compression: String,
145 pub schema_fingerprint: String,
147 pub row_count: i64,
148 pub part_count: u32,
149 pub parts: Vec<ManifestPart>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub column_checksums: Option<Vec<ColumnChecksum>>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub checksum_key_column: Option<String>,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum ManifestStatus {
174 Success,
175 Failed,
176 Interrupted,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct ManifestSource {
181 pub engine: String,
182 pub schema: Option<String>,
183 pub table: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub extraction: Option<ExtractionMetadata>,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct ExtractionMetadata {
202 pub strategy: String,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub cursor_column: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub cursor_type: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub cursor_low: Option<String>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub cursor_high: Option<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub source_row_count: Option<i64>,
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225pub struct ManifestDestination {
226 pub kind: String,
227 pub uri: String,
228}
229
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235pub struct ManifestPart {
236 pub part_id: u32,
237 pub path: String,
238 pub rows: i64,
239 pub size_bytes: u64,
240 pub content_fingerprint: String,
244 #[serde(default)]
251 pub content_md5: String,
252 pub status: PartStatus,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum PartStatus {
258 Committed,
260 Quarantined,
262}
263
264impl RunManifest {
265 pub fn committed_rows(&self) -> i64 {
268 self.parts
269 .iter()
270 .filter(|p| p.status == PartStatus::Committed)
271 .map(|p| p.rows)
272 .sum()
273 }
274
275 pub fn committed_part_count(&self) -> usize {
277 self.parts
278 .iter()
279 .filter(|p| p.status == PartStatus::Committed)
280 .count()
281 }
282
283 pub fn validate_self_consistency(&self) -> std::result::Result<(), ManifestInconsistency> {
287 if self.manifest_version != MANIFEST_VERSION {
288 return Err(ManifestInconsistency::UnsupportedVersion {
289 found: self.manifest_version,
290 supported: MANIFEST_VERSION,
291 });
292 }
293 let actual_parts = self.committed_part_count();
294 if actual_parts != self.part_count as usize {
295 return Err(ManifestInconsistency::PartCountMismatch {
296 declared: self.part_count,
297 actual: actual_parts,
298 });
299 }
300 let actual_rows = self.committed_rows();
301 if actual_rows != self.row_count {
302 return Err(ManifestInconsistency::RowCountMismatch {
303 declared: self.row_count,
304 actual: actual_rows,
305 });
306 }
307 let mut ids: Vec<u32> = self.parts.iter().map(|p| p.part_id).collect();
309 ids.sort_unstable();
310 for w in ids.windows(2) {
311 if w[0] == w[1] {
312 return Err(ManifestInconsistency::DuplicatePartId(w[0]));
313 }
314 }
315 Ok(())
316 }
317}
318
319#[derive(Debug, PartialEq)]
324pub enum ManifestInconsistency {
325 UnsupportedVersion { found: u32, supported: u32 },
326 PartCountMismatch { declared: u32, actual: usize },
327 RowCountMismatch { declared: i64, actual: i64 },
328 DuplicatePartId(u32),
329}
330
331impl std::fmt::Display for ManifestInconsistency {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 match self {
334 Self::UnsupportedVersion { found, supported } => write!(
335 f,
336 "manifest_version {found} is not supported by this build (expected {supported})"
337 ),
338 Self::PartCountMismatch { declared, actual } => write!(
339 f,
340 "part_count declares {declared} parts but {actual} committed parts found"
341 ),
342 Self::RowCountMismatch { declared, actual } => write!(
343 f,
344 "row_count declares {declared} rows but committed parts sum to {actual}"
345 ),
346 Self::DuplicatePartId(id) => {
347 write!(f, "duplicate part_id {id} in manifest.parts")
348 }
349 }
350 }
351}
352
353impl std::error::Error for ManifestInconsistency {}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 fn part(id: u32, rows: i64, size: u64) -> ManifestPart {
360 ManifestPart {
361 part_id: id,
362 path: format!("part-{id:06}.parquet"),
363 rows,
364 size_bytes: size,
365 content_fingerprint: format!("xxh3:{:016x}", id as u64),
366 content_md5: String::new(),
367 status: PartStatus::Committed,
368 }
369 }
370
371 fn manifest_with_parts(parts: Vec<ManifestPart>) -> RunManifest {
372 let row_count = parts
373 .iter()
374 .filter(|p| p.status == PartStatus::Committed)
375 .map(|p| p.rows)
376 .sum();
377 let part_count = parts
378 .iter()
379 .filter(|p| p.status == PartStatus::Committed)
380 .count() as u32;
381 RunManifest {
382 mode: "batch".to_string(),
383 manifest_version: MANIFEST_VERSION,
384 run_id: "orders_20260521T120000.000".into(),
385 export_name: "public.orders".into(),
386 started_at: "2026-05-21T12:00:00Z".into(),
387 finished_at: "2026-05-21T12:14:33Z".into(),
388 status: ManifestStatus::Success,
389 source: ManifestSource {
390 engine: "postgres".into(),
391 schema: Some("public".into()),
392 table: Some("orders".into()),
393 extraction: None,
394 },
395 destination: ManifestDestination {
396 kind: "gcs".into(),
397 uri: "gs://rivet-exports/public.orders/run/".into(),
398 },
399 format: "parquet".into(),
400 compression: "zstd".into(),
401 schema_fingerprint: "xxh3:0123456789abcdef".into(),
402 row_count,
403 part_count,
404 parts,
405 column_checksums: None,
406 checksum_key_column: None,
407 }
408 }
409
410 #[test]
413 fn manifest_version_is_one() {
414 assert_eq!(MANIFEST_VERSION, 1);
415 }
416
417 #[test]
418 fn filenames_are_stable() {
419 assert_eq!(MANIFEST_FILENAME, "manifest.json");
420 assert_eq!(SUCCESS_FILENAME, "_SUCCESS");
421 assert_eq!(QUARANTINE_PREFIX, "_quarantine");
422 }
423
424 #[test]
427 fn self_consistent_manifest_validates() {
428 let m = manifest_with_parts(vec![part(1, 100, 4096), part(2, 200, 8192)]);
429 assert_eq!(m.validate_self_consistency(), Ok(()));
430 }
431
432 #[test]
433 fn rejects_part_count_mismatch() {
434 let mut m = manifest_with_parts(vec![part(1, 100, 4096)]);
435 m.part_count = 5;
436 assert!(matches!(
437 m.validate_self_consistency(),
438 Err(ManifestInconsistency::PartCountMismatch {
439 declared: 5,
440 actual: 1
441 })
442 ));
443 }
444
445 #[test]
446 fn rejects_row_count_mismatch() {
447 let mut m = manifest_with_parts(vec![part(1, 100, 4096)]);
448 m.row_count = 999;
449 assert!(matches!(
450 m.validate_self_consistency(),
451 Err(ManifestInconsistency::RowCountMismatch {
452 declared: 999,
453 actual: 100
454 })
455 ));
456 }
457
458 #[test]
459 fn rejects_duplicate_part_id() {
460 let m = manifest_with_parts(vec![part(1, 100, 4096), part(1, 200, 8192)]);
461 let err = m.validate_self_consistency().unwrap_err();
462 assert_eq!(err, ManifestInconsistency::DuplicatePartId(1));
463 }
464
465 #[test]
466 fn rejects_unsupported_version() {
467 let mut m = manifest_with_parts(vec![]);
468 m.manifest_version = 999;
469 m.part_count = 0;
470 m.row_count = 0;
471 assert!(matches!(
472 m.validate_self_consistency(),
473 Err(ManifestInconsistency::UnsupportedVersion {
474 found: 999,
475 supported: 1
476 })
477 ));
478 }
479
480 #[test]
483 fn quarantined_parts_do_not_count_toward_row_or_part_totals() {
484 let mut p_q = part(2, 999, 8192);
485 p_q.status = PartStatus::Quarantined;
486 let m = manifest_with_parts(vec![part(1, 100, 4096), p_q]);
487
488 assert_eq!(m.validate_self_consistency(), Ok(()));
490 assert_eq!(m.committed_rows(), 100);
491 assert_eq!(m.committed_part_count(), 1);
492 }
493
494 #[test]
497 fn json_roundtrip_preserves_fields() {
498 let m = manifest_with_parts(vec![part(1, 100, 4096), part(2, 200, 8192)]);
499 let json = serde_json::to_string_pretty(&m).unwrap();
500 let parsed: RunManifest = serde_json::from_str(&json).unwrap();
501 assert_eq!(m, parsed);
502 }
503
504 #[test]
505 fn status_serializes_as_snake_case() {
506 let m = manifest_with_parts(vec![]);
507 let mut m = m;
510 m.part_count = 0;
511 m.row_count = 0;
512 let json = serde_json::to_string(&m).unwrap();
513 assert!(json.contains("\"status\":\"success\""));
514
515 m.status = ManifestStatus::Interrupted;
516 let json = serde_json::to_string(&m).unwrap();
517 assert!(json.contains("\"status\":\"interrupted\""));
518 }
519
520 #[test]
523 fn success_marker_body_is_xxh3_prefix_plus_16_hex_plus_newline() {
524 let body = success_marker_body(b"some manifest bytes");
525 assert!(body.starts_with("xxh3:"), "body = {body:?}");
526 assert!(body.ends_with('\n'), "body = {body:?}");
527 let trimmed = body.trim_end();
528 let hex = &trimmed["xxh3:".len()..];
529 assert_eq!(hex.len(), 16, "body = {body:?}");
530 assert!(
531 hex.chars()
532 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
533 );
534 }
535
536 #[test]
537 fn success_marker_body_is_deterministic_for_same_input() {
538 let a = success_marker_body(b"hello");
539 let b = success_marker_body(b"hello");
540 assert_eq!(a, b);
541 }
542
543 #[test]
544 fn success_marker_body_differs_for_different_manifest_bytes() {
545 let a = success_marker_body(b"manifest one");
546 let b = success_marker_body(b"manifest two");
547 assert_ne!(a, b);
548 }
549
550 #[test]
551 fn parse_success_marker_roundtrips_with_writer() {
552 let body = success_marker_body(b"some manifest bytes");
553 let fp = parse_success_marker(&body).expect("must parse");
554 assert!(fp.starts_with("xxh3:"));
555 assert_eq!(fp.len(), "xxh3:".len() + 16);
556 }
557
558 #[test]
559 fn parse_success_marker_rejects_malformed_bodies() {
560 assert_eq!(parse_success_marker(""), None);
561 assert_eq!(parse_success_marker("\n"), None);
562 assert_eq!(parse_success_marker("sha256:0123456789abcdef"), None);
563 assert_eq!(parse_success_marker("xxh3:0123\n"), None);
565 assert_eq!(parse_success_marker("xxh3:0123456789ABCDEF\n"), None);
567 assert_eq!(parse_success_marker("xxh3:zzzzzzzzzzzzzzzz\n"), None);
569 assert_eq!(parse_success_marker("0123456789abcdef\n"), None);
571 }
572
573 #[test]
574 fn parse_success_marker_tolerates_trailing_whitespace() {
575 let body = "xxh3:0123456789abcdef\n";
576 assert_eq!(parse_success_marker(body), Some("xxh3:0123456789abcdef"));
577 let body = "xxh3:0123456789abcdef\r\n";
579 assert_eq!(parse_success_marker(body), Some("xxh3:0123456789abcdef"));
580 }
581
582 #[test]
583 fn unknown_fields_are_ignored_by_reader() {
584 let json = r#"{
587 "manifest_version": 1,
588 "run_id": "r1",
589 "export_name": "t",
590 "started_at": "2026-01-01T00:00:00Z",
591 "finished_at": "2026-01-01T00:01:00Z",
592 "status": "success",
593 "source": {"engine": "postgres"},
594 "destination": {"kind": "local", "uri": "file:///tmp/out/"},
595 "format": "parquet",
596 "compression": "zstd",
597 "schema_fingerprint": "xxh3:0000000000000000",
598 "row_count": 0,
599 "part_count": 0,
600 "parts": [],
601 "future_field_added_in_v2": {"nested": true}
602 }"#;
603 let parsed: RunManifest = serde_json::from_str(json).unwrap();
604 assert_eq!(parsed.run_id, "r1");
605 assert_eq!(parsed.validate_self_consistency(), Ok(()));
606 }
607}
608
609fn default_manifest_mode() -> String {
610 "batch".to_string()
611}
612
613pub fn guard_manifest_mode(
619 dest: &dyn crate::destination::Destination,
620 new_mode: &str,
621) -> anyhow::Result<()> {
622 let Ok(bytes) = dest.read("manifest.json") else {
623 return Ok(());
624 };
625 let Ok(existing) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
626 return Ok(());
627 };
628 let Some(existing_mode) = existing.get("mode").and_then(|m| m.as_str()) else {
633 return Ok(());
634 };
635 if existing_mode != new_mode {
636 anyhow::bail!(
637 "destination already holds a '{existing_mode}' manifest (run_id {run}); refusing to \
638 overwrite it with a '{new_mode}' manifest — a batch export and a CDC export sharing \
639 one prefix silently destroy each other's audit trail. Give this export its own \
640 prefix (the scaffold now uses exports/<table>/cdc/ for CDC).",
641 run = existing
642 .get("run_id")
643 .and_then(|r| r.as_str())
644 .unwrap_or("unknown"),
645 );
646 }
647 Ok(())
648}
649
650#[cfg(test)]
651mod mode_guard_tests {
652 use super::*;
653
654 #[test]
655 fn pre_extraction_manifests_parse_with_none() {
656 let old = std::fs::read_to_string(
659 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
660 .join("tests/fixtures/compat/v0.16/cdc_manifest.json"),
661 )
662 .expect("compat fixture");
663 let m: RunManifest = serde_json::from_str(&old).expect("parses");
664 assert!(m.source.extraction.is_none());
665 }
666
667 #[test]
668 fn extraction_roundtrips_and_omits_none_fields() {
669 let ex = ExtractionMetadata {
670 strategy: "incremental".into(),
671 cursor_column: Some("id".into()),
672 cursor_type: None,
673 cursor_low: Some("1000".into()),
674 cursor_high: Some("2000".into()),
675 source_row_count: None,
676 };
677 let j = serde_json::to_string(&ex).unwrap();
678 assert!(!j.contains("cursor_type"), "None fields omitted: {j}");
680 assert!(!j.contains("source_row_count"), "{j}");
681 assert!(j.contains("\"cursor_low\":\"1000\""), "{j}");
682 let back: ExtractionMetadata = serde_json::from_str(&j).unwrap();
683 assert_eq!(back, ex);
684 }
685
686 #[test]
687 fn pre_mode_manifests_default_to_batch() {
688 let old = std::fs::read_to_string(
691 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
692 .join("tests/fixtures/compat/v0.16/cdc_manifest.json"),
693 )
694 .expect("compat fixture");
695 let m: RunManifest = serde_json::from_str(&old).expect("old manifests parse");
696 assert_eq!(m.mode, "batch");
697 }
698
699 #[test]
700 fn cross_shape_overwrite_is_refused_same_shape_allowed() {
701 let d = tempfile::tempdir().unwrap();
702 let dest = crate::destination::create_destination(&crate::config::DestinationConfig {
703 destination_type: crate::config::DestinationType::Local,
704 path: Some(d.path().to_str().unwrap().to_string()),
705 ..Default::default()
706 })
707 .unwrap();
708 guard_manifest_mode(dest.as_ref(), "batch").unwrap();
710 std::fs::write(
711 d.path().join("manifest.json"),
712 r#"{"mode":"batch","run_id":"r1"}"#,
713 )
714 .unwrap();
715 guard_manifest_mode(dest.as_ref(), "batch").expect("same shape re-run allowed");
716 let err = guard_manifest_mode(dest.as_ref(), "cdc")
717 .unwrap_err()
718 .to_string();
719 assert!(err.contains("refusing to"), "loud refusal: {err}");
720 assert!(
721 err.contains("exports/<table>/cdc/"),
722 "carries the recovery: {err}"
723 );
724 std::fs::write(d.path().join("manifest.json"), r#"{"run_id":"legacy"}"#).unwrap();
728 guard_manifest_mode(dest.as_ref(), "cdc").expect("legacy prefixes must keep resuming");
729 guard_manifest_mode(dest.as_ref(), "batch").expect("in either direction");
730 }
731}