1use crate::destination::gcs::GcsStore;
27use crate::manifest::{MANIFEST_FILENAME, ManifestStatus, RunManifest};
28use crate::pipeline::validate_manifest::MANIFEST_MAX_BYTES;
29use anyhow::{Context, Result, bail};
30
31#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct LoadIntegrity {
36 pub source_rows: Option<u64>,
41 pub file_rows: u64,
44 pub manifests: usize,
46}
47
48impl LoadIntegrity {
49 pub fn chain_prefix(&self) -> String {
53 let src = self
54 .source_rows
55 .map_or_else(|| "?".to_string(), |n| n.to_string());
56 format!("source {src} → files {}", self.file_rows)
57 }
58}
59
60#[allow(private_interfaces)]
77pub fn fetch_manifests_keyed(
78 store: &GcsStore,
79 gcs_prefix: &str,
80) -> Result<Vec<(String, RunManifest)>> {
81 let (_, base) = crate::load::split_gs_uri(gcs_prefix)?;
82 let keys = list_manifest_keys(store, base)?;
83 keys.into_iter()
84 .map(|key| {
85 let sz = store.stat_size(&key)?;
90 if sz > MANIFEST_MAX_BYTES {
91 bail!(
92 "manifest {key} is {sz} bytes, over the {MANIFEST_MAX_BYTES}-byte cap — \
93 refusing to read a possibly-hostile manifest into memory (CWE-400)"
94 );
95 }
96 let bytes = store.read(&key)?;
97 let m = serde_json::from_slice::<RunManifest>(&bytes)
98 .with_context(|| format!("parsing manifest {key}"))?;
99 Ok((key, m))
100 })
101 .collect()
102}
103
104#[allow(private_interfaces)]
108pub fn select_load_uris(
109 store: &GcsStore,
110 gcs_prefix: &str,
111 new: &[(String, RunManifest)],
112) -> Result<Vec<String>> {
113 let (bucket, base) = crate::load::split_gs_uri(gcs_prefix)?;
114 let all_parquet: Vec<String> = store
115 .list_files(base)?
116 .into_iter()
117 .filter(|k| k.ends_with(".parquet"))
118 .collect();
119 Ok(select_load_keys(new, &all_parquet)
120 .into_iter()
121 .map(|k| format!("gs://{bucket}/{k}"))
122 .collect())
123}
124
125fn resolve_parts<'a>(
131 manifest_key: &'a str,
132 m: &'a RunManifest,
133) -> impl Iterator<Item = String> + 'a {
134 let dir = manifest_key.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
135 m.parts.iter().map(move |p| {
136 if dir.is_empty() {
137 p.path.clone()
138 } else {
139 format!("{dir}/{}", p.path)
140 }
141 })
142}
143
144pub fn select_load_keys(new: &[(String, RunManifest)], all_parquet: &[String]) -> Vec<String> {
155 use std::collections::BTreeSet;
156 let present: std::collections::HashSet<&str> = all_parquet.iter().map(String::as_str).collect();
157 let mut selected: BTreeSet<String> = BTreeSet::new();
158 for (key, m) in new {
159 let mut resolved_any = false;
160 for full in resolve_parts(key, m) {
161 if present.contains(full.as_str()) {
162 selected.insert(full);
163 resolved_any = true;
164 }
165 }
166 if !resolved_any {
167 return all_parquet.to_vec();
169 }
170 }
171 selected.into_iter().collect()
172}
173
174#[allow(private_interfaces)]
188pub fn gc_orphans(
189 store: &GcsStore,
190 gcs_prefix: &str,
191 keyed: &[(String, RunManifest)],
192) -> Result<(usize, u64)> {
193 let (_bucket, base) = crate::load::split_gs_uri(gcs_prefix)?;
194 let keep: std::collections::HashSet<String> = keyed
195 .iter()
196 .filter(|(_, m)| m.status == ManifestStatus::Success)
197 .flat_map(|(key, m)| resolve_parts(key, m))
198 .collect();
199 let mut removed = 0usize;
200 let mut removed_bytes = 0u64;
201 for key in store.list_files(base)? {
202 if key.ends_with(".parquet") && !keep.contains(&key) {
203 removed_bytes += store.stat_size(&key).unwrap_or(0);
204 store.remove(&key)?;
205 removed += 1;
206 }
207 }
208 Ok((removed, removed_bytes))
209}
210
211pub fn latest_full(keyed: Vec<(String, RunManifest)>) -> Vec<(String, RunManifest)> {
224 keyed
225 .into_iter()
226 .max_by(|a, b| {
227 match (
228 chrono::DateTime::parse_from_rfc3339(&a.1.finished_at).ok(),
229 chrono::DateTime::parse_from_rfc3339(&b.1.finished_at).ok(),
230 ) {
231 (Some(x), Some(y)) => x.cmp(&y),
232 _ => a.1.finished_at.cmp(&b.1.finished_at),
233 }
234 })
235 .into_iter()
236 .collect()
237}
238
239pub fn select_runs(
249 keyed: Vec<(String, RunManifest)>,
250 loaded: &std::collections::HashSet<String>,
251 mode: crate::load::plan::LoadMode,
252) -> Vec<(String, RunManifest)> {
253 match mode {
254 crate::load::plan::LoadMode::Full => latest_full(keyed),
255 _ => keyed
256 .into_iter()
257 .filter(|(_, m)| !loaded.contains(&m.run_id))
258 .collect(),
259 }
260}
261
262fn list_manifest_keys(store: &GcsStore, base: &str) -> Result<Vec<String>> {
264 let all: Vec<String> = store
265 .list_files(base)?
266 .into_iter()
267 .filter(|k| is_manifest_key(k))
268 .collect();
269 let run_unique: Vec<String> = all
277 .iter()
278 .filter(|k| is_run_unique_manifest(k.rsplit('/').next().unwrap_or("")))
279 .cloned()
280 .collect();
281 Ok(if run_unique.is_empty() {
282 all
283 } else {
284 run_unique
285 })
286}
287
288fn is_manifest_key(key: &str) -> bool {
293 let base = key.rsplit('/').next().unwrap_or("");
294 base == MANIFEST_FILENAME || is_run_unique_manifest(base)
295}
296
297fn is_run_unique_manifest(base: &str) -> bool {
300 base.starts_with("manifest-") && base.ends_with(".json")
301}
302
303pub fn ensure_single_export(keyed: &[(String, RunManifest)]) -> Result<()> {
314 let mut names: std::collections::BTreeSet<&str> = keyed
315 .iter()
316 .map(|(_, m)| m.export_name.as_str())
317 .filter(|n| !n.is_empty())
318 .collect();
319 if names.len() > 1 {
320 let listed: Vec<&str> = std::mem::take(&mut names).into_iter().collect();
321 bail!(
322 "the load prefix holds manifests from {} distinct exports ({}) — the load sums every \
323 manifest under the prefix and cleanup wipes it recursively, so loading a shared \
324 prefix would cross-contaminate the row count and could delete a sibling export's \
325 parts. Give each export a DISTINCT destination prefix (or scope the load prefix to a \
326 single export) and re-run.",
327 listed.len(),
328 listed.join(", ")
329 );
330 }
331 Ok(())
332}
333
334pub fn reconcile(manifests: &[RunManifest], allow_source_drift: bool) -> Result<LoadIntegrity> {
349 if manifests.is_empty() {
350 bail!(
351 "no `{MANIFEST_FILENAME}` found under the export prefix — refusing to load \
352 unverified files. A rivet export writes a manifest on success; its absence \
353 means the run never completed (or points at the wrong prefix)."
354 );
355 }
356
357 let mut file_rows: u64 = 0;
358 let mut source_rows: u64 = 0;
359 let mut any_source = false;
360
361 for m in manifests {
362 if m.status != ManifestStatus::Success {
364 bail!(
365 "manifest for run `{}` (export `{}`) is {:?}, not Success — refusing to load a \
366 partial export",
367 m.run_id,
368 m.export_name,
369 m.status
370 );
371 }
372 m.validate_self_consistency().map_err(|e| {
375 anyhow::anyhow!(
376 "manifest for run `{}` (export `{}`) is internally inconsistent: {e} — refusing \
377 to load",
378 m.run_id,
379 m.export_name
380 )
381 })?;
382
383 let rows = u64::try_from(m.row_count).with_context(|| {
384 format!(
385 "manifest for run `{}` has a negative row_count ({})",
386 m.run_id, m.row_count
387 )
388 })?;
389 file_rows += rows;
390
391 if let Some(src) = m
394 .source
395 .extraction
396 .as_ref()
397 .and_then(|x| x.source_row_count)
398 {
399 let src = u64::try_from(src).with_context(|| {
400 format!(
401 "manifest for run `{}` has a negative source_row_count ({src})",
402 m.run_id
403 )
404 })?;
405 any_source = true;
406 source_rows += src;
407 if src != rows {
408 if allow_source_drift {
409 eprintln!(
410 "warning: source→file drift for run `{}` (export `{}`): source had {src} \
411 rows, extracted {rows} (--allow-source-drift)",
412 m.run_id, m.export_name
413 );
414 } else {
415 bail!(
416 "source→file mismatch for run `{}` (export `{}`): source had {src} rows \
417 but {rows} were extracted — the extract dropped {} row(s). Investigate \
418 before loading, or pass --allow-source-drift to override.",
419 m.run_id,
420 m.export_name,
421 src.abs_diff(rows)
422 );
423 }
424 }
425 }
426 }
427
428 Ok(LoadIntegrity {
429 source_rows: any_source.then_some(source_rows),
430 file_rows,
431 manifests: manifests.len(),
432 })
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::manifest::{
439 ExtractionMetadata, ManifestDestination, ManifestPart, ManifestSource, PartStatus,
440 };
441
442 fn manifest(run: &str, rows: i64, source: Option<i64>) -> RunManifest {
445 RunManifest {
446 manifest_version: crate::manifest::MANIFEST_VERSION,
447 run_id: run.into(),
448 export_name: "orders".into(),
449 mode: "batch".into(),
450 started_at: "t".into(),
451 finished_at: "t".into(),
452 status: ManifestStatus::Success,
453 source: ManifestSource {
454 engine: "pg".into(),
455 schema: None,
456 table: None,
457 extraction: source.map(|n| ExtractionMetadata {
458 strategy: "full".into(),
459 cursor_column: None,
460 cursor_type: None,
461 cursor_low: None,
462 cursor_high: None,
463 source_row_count: Some(n),
464 }),
465 },
466 destination: ManifestDestination {
467 kind: "gcs".into(),
468 uri: "gs://b/p".into(),
469 },
470 format: "parquet".into(),
471 compression: "zstd".into(),
472 schema_fingerprint: "xxh3:0".into(),
473 row_count: rows,
474 part_count: 1,
475 parts: vec![ManifestPart {
476 part_id: 0,
477 path: "part-000000.parquet".into(),
478 rows,
479 size_bytes: 1,
480 content_fingerprint: "xxh3:0".into(),
481 content_md5: String::new(),
482 status: PartStatus::Committed,
483 }],
484 column_checksums: None,
485 checksum_key_column: None,
486 }
487 }
488
489 #[test]
490 fn sums_file_and_source_rows_across_manifests() {
491 let ms = vec![manifest("r1", 100, Some(100)), manifest("r2", 40, Some(40))];
492 let got = reconcile(&ms, false).unwrap();
493 assert_eq!(got.file_rows, 140);
494 assert_eq!(got.source_rows, Some(140));
495 assert_eq!(got.manifests, 2);
496 }
497
498 #[test]
499 fn ensure_single_export_refuses_a_prefix_shared_by_two_exports() {
500 let keyed = |m: RunManifest| ("gs://b/p/manifest-x.json".to_string(), m);
501 let orders = keyed(manifest("r1", 10, None)); let mut cust_m = manifest("r2", 10, None);
503 cust_m.export_name = "customers".into();
504 assert!(ensure_single_export(&[orders.clone(), keyed(cust_m)]).is_err());
507 assert!(ensure_single_export(&[orders.clone(), keyed(manifest("r3", 5, None))]).is_ok());
509 let mut legacy = manifest("r0", 3, None);
512 legacy.export_name = String::new();
513 assert!(ensure_single_export(&[orders, keyed(legacy)]).is_ok());
514 }
515
516 #[test]
517 fn source_rows_is_none_when_no_manifest_probed_the_source() {
518 let ms = vec![manifest("r1", 100, None), manifest("r2", 40, None)];
519 let got = reconcile(&ms, false).unwrap();
520 assert_eq!(got.file_rows, 140);
521 assert_eq!(
522 got.source_rows, None,
523 "unprobed source is unknown, not zero"
524 );
525 }
526
527 #[test]
528 fn source_rows_present_even_if_only_some_manifests_probed() {
529 let ms = vec![manifest("r1", 100, Some(100)), manifest("r2", 40, None)];
530 let got = reconcile(&ms, false).unwrap();
531 assert_eq!(got.source_rows, Some(100));
532 }
533
534 #[test]
535 fn empty_manifests_refuses_to_load() {
536 let err = reconcile(&[], false).unwrap_err().to_string();
537 assert!(err.contains("refusing to load"), "{err}");
538 }
539
540 #[test]
541 fn non_success_manifest_refuses_to_load() {
542 let mut m = manifest("r1", 100, Some(100));
543 m.status = ManifestStatus::Interrupted;
544 let err = reconcile(&[m], false).unwrap_err().to_string();
545 assert!(err.contains("not Success"), "{err}");
546 }
547
548 #[test]
549 fn self_inconsistent_manifest_refuses_to_load() {
550 let mut m = manifest("r1", 100, Some(100));
553 m.row_count = 999; let err = reconcile(&[m], false).unwrap_err().to_string();
555 assert!(err.contains("inconsistent"), "{err}");
556 }
557
558 #[test]
559 fn source_file_mismatch_hard_fails_by_default() {
560 let m = manifest("r1", 100, Some(120));
562 let err = reconcile(&[m], false).unwrap_err().to_string();
563 assert!(err.contains("source→file mismatch"), "{err}");
564 assert!(err.contains("dropped 20"), "{err}");
565 }
566
567 #[test]
568 fn source_file_mismatch_is_allowed_under_the_override() {
569 let m = manifest("r1", 100, Some(120));
570 let got = reconcile(&[m], true).expect("--allow-source-drift proceeds");
571 assert_eq!(got.file_rows, 100);
572 assert_eq!(
573 got.source_rows,
574 Some(120),
575 "the probed source count is still surfaced"
576 );
577 }
578
579 fn keyed(key: &str, run: &str, part: &str) -> (String, RunManifest) {
581 let mut m = manifest(run, 10, Some(10));
582 m.parts[0].path = part.into();
583 (key.to_string(), m)
584 }
585
586 #[test]
587 fn select_load_keys_picks_only_the_new_runs_parts() {
588 let all = vec![
590 "base/r1-000.parquet".to_string(),
591 "base/r2-000.parquet".to_string(),
592 ];
593 let new = vec![keyed("base/manifest-r2.json", "r2", "r2-000.parquet")];
594 assert_eq!(
595 select_load_keys(&new, &all),
596 vec!["base/r2-000.parquet".to_string()],
597 "loads r2's part only — not r1's already-loaded file"
598 );
599 }
600
601 #[test]
602 fn select_load_uris_lists_and_re_prefixes_selected_keys_with_the_source_bucket() {
603 let (store, _g) = fs_store(&[
609 ("base/r1-000.parquet", b"a".to_vec()),
610 ("base/manifest-r1.json", b"{}".to_vec()), ]);
612 let new = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
613 assert_eq!(
614 select_load_uris(&store, "gs://my-bucket/base", &new).unwrap(),
615 vec!["gs://my-bucket/base/r1-000.parquet".to_string()],
616 "the selected key, re-prefixed with the source bucket — never the manifest"
617 );
618 }
619
620 #[test]
621 fn select_load_keys_resolves_a_snapshot_subprefix_manifest() {
622 let all = vec!["base/snapshot/snap-000.parquet".to_string()];
625 let new = vec![keyed(
626 "base/snapshot/manifest-r1.json",
627 "r1",
628 "snap-000.parquet",
629 )];
630 assert_eq!(
631 select_load_keys(&new, &all),
632 vec!["base/snapshot/snap-000.parquet".to_string()]
633 );
634 }
635
636 #[test]
637 fn select_load_keys_falls_back_to_full_listing_when_a_manifest_has_no_present_part() {
638 let all = vec!["base/a.parquet".to_string(), "base/b.parquet".to_string()];
641 let new = vec![keyed("base/manifest-r1.json", "r1", "missing.parquet")];
642 assert_eq!(
643 select_load_keys(&new, &all),
644 all,
645 "unresolvable part → blanket fallback"
646 );
647 }
648
649 #[test]
650 fn select_load_keys_empty_new_set_selects_nothing_never_the_full_listing() {
651 let all = vec![
656 "base/r1-000.parquet".to_string(),
657 "base/r2-000.parquet".to_string(),
658 ];
659 assert!(
660 select_load_keys(&[], &all).is_empty(),
661 "no new runs ⇒ load nothing, not everything"
662 );
663 }
664
665 #[test]
666 fn gc_orphans_removes_unmanifested_parquet_only() {
667 let (store, _g) = fs_store(&[
668 ("base/r1-000.parquet", b"aa".to_vec()), ("base/orphan.parquet", b"junk".to_vec()), ("base/manifest.json", b"{}".to_vec()), ("base/_SUCCESS", b"".to_vec()), ]);
673 let keyed = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
674 let (removed, bytes) = gc_orphans(&store, "gs://b/base", &keyed).unwrap();
675 assert_eq!(removed, 1, "only the unmanifested part is removed");
676 assert_eq!(bytes, 4, "'junk' is 4 bytes");
677 let mut left = store.list_files("base").unwrap();
678 left.sort();
679 assert_eq!(
680 left,
681 vec![
682 "base/_SUCCESS".to_string(),
683 "base/manifest.json".to_string(),
684 "base/r1-000.parquet".to_string(),
685 ],
686 "the manifested part, the manifest, and _SUCCESS all survive"
687 );
688 }
689
690 #[test]
691 fn gc_orphans_of_an_all_manifested_prefix_removes_nothing() {
692 let (store, _g) = fs_store(&[("base/r1-000.parquet", b"a".to_vec())]);
693 let keyed = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
694 assert_eq!(gc_orphans(&store, "gs://b/base", &keyed).unwrap().0, 0);
695 }
696
697 #[test]
698 fn gc_orphans_keeps_a_snapshot_subprefix_manifested_part() {
699 let (store, _g) = fs_store(&[
700 ("base/snapshot/s-000.parquet", b"a".to_vec()), ("base/orphan.parquet", b"x".to_vec()), ]);
703 let keyed = vec![keyed("base/snapshot/manifest-s.json", "s", "s-000.parquet")];
704 let (removed, _) = gc_orphans(&store, "gs://b/base", &keyed).unwrap();
705 assert_eq!(removed, 1, "the top-level orphan goes");
706 assert_eq!(
707 store.list_files("base/snapshot").unwrap(),
708 vec!["base/snapshot/s-000.parquet".to_string()],
709 "the snapshot-subprefix manifested part is kept"
710 );
711 }
712
713 #[test]
714 fn gc_orphans_does_not_protect_a_failed_runs_parts() {
715 let (store, _g) = fs_store(&[("base/f-000.parquet", b"x".to_vec())]);
718 let mut kv = keyed("base/manifest-f.json", "f", "f-000.parquet");
719 kv.1.status = ManifestStatus::Failed;
720 assert_eq!(gc_orphans(&store, "gs://b/base", &[kv]).unwrap().0, 1);
721 }
722
723 fn keyed_at(run: &str, finished_at: &str) -> (String, RunManifest) {
724 let mut m = manifest(run, 100, None);
725 m.finished_at = finished_at.into();
726 (format!("base/manifest-{run}.json"), m)
727 }
728
729 #[test]
730 fn latest_full_picks_the_newest_snapshot_not_all() {
731 let keyed = vec![
734 keyed_at("r1", "2026-01-01T00:00:00Z"),
735 keyed_at("r3", "2026-01-03T00:00:00Z"),
736 keyed_at("r2", "2026-01-02T00:00:00Z"),
737 ];
738 let sel = latest_full(keyed);
739 assert_eq!(sel.len(), 1, "exactly one snapshot, never all");
740 assert_eq!(sel[0].1.run_id, "r3", "the newest by finished_at");
741 }
742
743 #[test]
744 fn latest_full_re_materializes_even_when_the_latest_is_already_loaded() {
745 let keyed = vec![
750 keyed_at("r1", "2026-01-01T00:00:00Z"),
751 keyed_at("r2", "2026-01-02T00:00:00Z"),
752 ];
753 let sel = latest_full(keyed);
755 assert_eq!(sel.len(), 1);
756 assert_eq!(sel[0].1.run_id, "r2", "always the latest, loaded or not");
757 }
758
759 #[test]
760 fn latest_full_of_no_staged_runs_is_empty_so_the_caller_no_ops_without_truncating() {
761 assert!(latest_full(Vec::new()).is_empty());
764 }
765
766 #[test]
767 fn latest_full_orders_by_parsed_instant_not_lexical_bytes() {
768 let keyed = vec![
771 keyed_at("older", "2026-01-01T00:00:00Z"),
772 keyed_at("newer", "2026-01-01T00:00:00.500Z"),
773 ];
774 let sel = latest_full(keyed);
775 assert_eq!(sel.len(), 1);
776 assert_eq!(
777 sel[0].1.run_id, "newer",
778 "the fractional-second run is the newer instant"
779 );
780 }
781
782 #[test]
783 fn select_runs_full_picks_the_latest_even_when_loaded_and_even_when_stateless() {
784 use crate::load::plan::LoadMode;
785 use std::collections::HashSet;
786 let keyed = vec![
787 keyed_at("r1", "2026-01-01T00:00:00Z"),
788 keyed_at("r2", "2026-01-02T00:00:00Z"),
789 ];
790 let loaded = HashSet::from(["r2".to_string()]);
792 let sel = select_runs(keyed.clone(), &loaded, LoadMode::Full);
793 assert_eq!(sel.len(), 1);
794 assert_eq!(sel[0].1.run_id, "r2", "Full picks latest, loaded or not");
795 let sel = select_runs(keyed, &HashSet::new(), LoadMode::Full);
798 assert_eq!(sel.len(), 1, "stateless Full is not a blanket load");
799 assert_eq!(sel[0].1.run_id, "r2");
800 }
801
802 #[test]
803 fn select_runs_append_modes_filter_loaded_and_load_all_when_stateless() {
804 use crate::load::plan::LoadMode;
805 use std::collections::HashSet;
806 let keyed = vec![
807 keyed_at("r1", "2026-01-01T00:00:00Z"),
808 keyed_at("r2", "2026-01-02T00:00:00Z"),
809 ];
810 let loaded = HashSet::from(["r1".to_string()]);
811 for mode in [LoadMode::Incremental, LoadMode::Cdc] {
812 let sel = select_runs(keyed.clone(), &loaded, mode);
813 assert_eq!(sel.len(), 1, "{mode:?}: only the unloaded run");
814 assert_eq!(sel[0].1.run_id, "r2");
815 assert_eq!(
817 select_runs(keyed.clone(), &HashSet::new(), mode).len(),
818 2,
819 "{mode:?}: stateless loads every run"
820 );
821 }
822 }
823
824 #[test]
825 fn is_run_unique_manifest_needs_both_prefix_and_json() {
826 assert!(is_run_unique_manifest("manifest-20260101T000000.json"));
827 assert!(!is_run_unique_manifest("manifest.json")); assert!(!is_run_unique_manifest("manifest-abc.txt")); assert!(!is_run_unique_manifest("data.json")); }
831
832 #[test]
833 fn chain_prefix_renders_source_and_files() {
834 let known = LoadIntegrity {
835 source_rows: Some(100),
836 file_rows: 100,
837 manifests: 1,
838 };
839 assert_eq!(known.chain_prefix(), "source 100 → files 100");
840 let unknown = LoadIntegrity {
841 source_rows: None,
842 file_rows: 40,
843 manifests: 1,
844 };
845 assert_eq!(unknown.chain_prefix(), "source ? → files 40");
846 }
847
848 #[test]
849 fn is_manifest_key_matches_only_the_final_segment() {
850 assert!(is_manifest_key("gs://b/p/manifest.json"));
851 assert!(is_manifest_key("manifest.json"));
852 assert!(!is_manifest_key("gs://b/p/part-0.parquet"));
853 assert!(!is_manifest_key("gs://b/p/x_manifest.json"));
854 }
855
856 fn fs_store(files: &[(&str, Vec<u8>)]) -> (GcsStore, tempfile::TempDir) {
862 let dir = tempfile::tempdir().unwrap();
863 for (rel, bytes) in files {
864 let p = dir.path().join(rel);
865 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
866 std::fs::write(p, bytes).unwrap();
867 }
868 let store = GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap();
869 (store, dir)
870 }
871
872 fn manifest_bytes(run: &str, rows: i64, source: Option<i64>) -> Vec<u8> {
873 serde_json::to_vec(&manifest(run, rows, source)).unwrap()
874 }
875
876 #[test]
877 fn list_manifest_keys_prefers_run_unique_copies_over_the_canonical_pointer() {
878 let (store, _g) = fs_store(&[
882 ("base/manifest.json", b"{}".to_vec()),
883 ("base/manifest-r1.json", b"{}".to_vec()),
884 ("base/manifest-r2.json", b"{}".to_vec()),
885 ("base/part-0.parquet", b"x".to_vec()), ]);
887 let mut keys = list_manifest_keys(&store, "base").unwrap();
888 keys.sort();
889 assert_eq!(
890 keys,
891 vec![
892 "base/manifest-r1.json".to_string(),
893 "base/manifest-r2.json".to_string(),
894 ]
895 );
896 }
897
898 #[test]
899 fn list_manifest_keys_falls_back_to_the_canonical_name_for_a_single_run() {
900 let (store, _g) = fs_store(&[("base/manifest.json", b"{}".to_vec())]);
903 assert_eq!(
904 list_manifest_keys(&store, "base").unwrap(),
905 vec!["base/manifest.json".to_string()]
906 );
907 }
908
909 #[test]
910 fn fetch_manifests_keyed_reads_and_parses_every_run_copy_under_the_prefix() {
911 let (store, _g) = fs_store(&[
915 ("base/manifest.json", manifest_bytes("r2", 40, Some(40))),
916 (
917 "base/manifest-r1.json",
918 manifest_bytes("r1", 100, Some(100)),
919 ),
920 ("base/manifest-r2.json", manifest_bytes("r2", 40, Some(40))),
921 ]);
922 let manifests: Vec<_> = fetch_manifests_keyed(&store, "gs://my-bucket/base")
923 .unwrap()
924 .into_iter()
925 .map(|(_, m)| m)
926 .collect();
927 assert_eq!(manifests.len(), 2);
928 let integrity = reconcile(&manifests, false).unwrap();
929 assert_eq!(integrity.file_rows, 140);
930 assert_eq!(integrity.manifests, 2);
931 }
932
933 #[test]
934 fn fetch_manifests_keyed_names_the_key_when_a_manifest_is_unparseable() {
935 let (store, _g) = fs_store(&[("base/manifest.json", b"{ not json".to_vec())]);
936 let err = fetch_manifests_keyed(&store, "gs://my-bucket/base")
937 .unwrap_err()
938 .to_string();
939 assert!(
940 err.contains("parsing manifest") && err.contains("base/manifest.json"),
941 "error should name the offending key: {err}"
942 );
943 }
944
945 const FAKE_GCS_ENDPOINT: &str = "http://127.0.0.1:4443";
956 const FAKE_GCS_BUCKET: &str = "rivet-load-emulator";
957
958 fn fake_gcs_store() -> GcsStore {
963 let created = std::process::Command::new("curl")
964 .args([
965 "-s",
966 "-X",
967 "POST",
968 &format!("{FAKE_GCS_ENDPOINT}/storage/v1/b?project=rivet-test"),
969 "-H",
970 "Content-Type: application/json",
971 "-d",
972 &format!("{{\"name\":\"{FAKE_GCS_BUCKET}\"}}"),
973 ])
974 .output();
975 assert!(
976 created.is_ok_and(|o| o.status.success()),
977 "could not reach fake-gcs to create the bucket — is `docker compose up -d fake-gcs` running on :4443?"
978 );
979 let cfg = crate::config::DestinationConfig {
980 destination_type: crate::config::DestinationType::Gcs,
981 bucket: Some(FAKE_GCS_BUCKET.into()),
982 endpoint: Some(FAKE_GCS_ENDPOINT.into()),
983 allow_anonymous: true,
984 ..Default::default()
985 };
986 GcsStore::new(&cfg).expect("build GcsStore against fake-gcs")
987 }
988
989 fn drain(store: &GcsStore, prefix: &str) {
997 for key in store.list_files(prefix).unwrap() {
998 store.remove(&key).unwrap();
999 }
1000 }
1001
1002 #[test]
1003 #[ignore = "emulator: needs `docker compose up -d fake-gcs` (fsouza/fake-gcs-server :4443)"]
1004 fn storage_contract_over_fake_gcs() {
1005 let store = fake_gcs_store();
1006 let prefix = "load-contract/orders";
1009 drain(&store, prefix);
1010 let gs = format!("gs://{FAKE_GCS_BUCKET}/{prefix}");
1011
1012 store
1015 .put(
1016 &format!("{prefix}/manifest-r1.json"),
1017 &manifest_bytes("r1", 100, Some(100)),
1018 )
1019 .unwrap();
1020 store
1021 .put(&format!("{prefix}/part-000000.parquet"), b"rows-of-r1")
1022 .unwrap();
1023 store
1024 .put(&format!("{prefix}/orphan.parquet"), b"crash-leftover")
1025 .unwrap();
1026
1027 let keyed = fetch_manifests_keyed(&store, &gs).unwrap();
1029 assert_eq!(keyed.len(), 1, "the run's manifest, read back over GCS");
1030
1031 let manifests: Vec<_> = keyed.iter().map(|(_, m)| m.clone()).collect();
1033 assert_eq!(
1034 reconcile(&manifests, false).unwrap().file_rows,
1035 100,
1036 "file_rows drives the count-gate; a bad GCS read would corrupt it"
1037 );
1038
1039 assert_eq!(
1041 select_load_uris(&store, &gs, &keyed).unwrap(),
1042 vec![format!(
1043 "gs://{FAKE_GCS_BUCKET}/{prefix}/part-000000.parquet"
1044 )],
1045 "load pulls the manifested part, not the unmanifested crash orphan"
1046 );
1047
1048 let (removed, _bytes) = gc_orphans(&store, &gs, &keyed).unwrap();
1051 assert_eq!(
1052 removed, 1,
1053 "exactly the orphan parquet is GC'd over real GCS"
1054 );
1055 let mut left = store.list_files(prefix).unwrap();
1056 left.sort();
1057 assert_eq!(
1058 left,
1059 vec![
1060 format!("{prefix}/manifest-r1.json"),
1061 format!("{prefix}/part-000000.parquet"),
1062 ],
1063 "the manifested part + its manifest survive the orphan GC"
1064 );
1065
1066 drain(&store, prefix);
1070 assert!(
1071 store.list_files(prefix).unwrap().is_empty(),
1072 "teardown left the prefix clean over real GCS"
1073 );
1074 }
1075}