Skip to main content

rivet/load/
reconcile.rs

1//! End-to-end load integrity — reconcile **source → file → warehouse** row
2//! counts before (and after) a load.
3//!
4//! The OSS engine records, in each run's `manifest.json`, two of the three legs
5//! of the chain: how many rows the source held at extraction time
6//! ([`ExtractionMetadata::source_row_count`](crate::manifest::ExtractionMetadata),
7//! when cheaply probed) and how many rows were actually written to files
8//! ([`RunManifest::row_count`]). The warehouse leg — how many rows the load
9//! landed — is the loaders' post-load `COUNT(*)`, enforced by their
10//! `expected_rows` gate.
11//!
12//! Until now that gate was dead in the production path: nothing read the
13//! manifest, so `rivet load` trusted whatever Parquet happened to sit under
14//! the prefix ("file in a bucket"). This module closes the loop:
15//!
16//! 1. read every `manifest.json` under the export's GCS prefix,
17//! 2. **refuse** to load unless each is a self-consistent `Success` run whose
18//!    source count (when known) matches what it extracted, and
19//! 3. return the summed, authoritative `file_rows` the loader's count gate then
20//!    checks against the warehouse.
21//!
22//! It is the value the OSS core deliberately stops short of — file-level
23//! integrity is free (`rivet validate`); reconciling that the rows *arrived in
24//! the warehouse* is the paid last mile.
25
26use 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/// The reconciled row-count chain for one export's load, derived from the run
32/// manifests under its GCS prefix. `file_rows` is what the warehouse must end
33/// up holding; the loader's `expected_rows` gate enforces `warehouse == file`.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct LoadIntegrity {
36    /// Rows the source held at extraction time, summed over the manifests that
37    /// probed it. `None` when *no* contributing manifest carried a
38    /// `source_row_count` — the source→file leg is then unverifiable from the
39    /// manifest alone (e.g. a full snapshot that did not count the source).
40    pub source_rows: Option<u64>,
41    /// Rows written to files — the sum of the trustworthy manifests'
42    /// `row_count`. This is the authoritative expected warehouse row count.
43    pub file_rows: u64,
44    /// How many run manifests contributed to the totals.
45    pub manifests: usize,
46}
47
48impl LoadIntegrity {
49    /// A one-line human summary of the chain, e.g.
50    /// `source 1000 → files 1000 → (warehouse pending)`. The warehouse leg is
51    /// filled in by the caller once the load's `COUNT(*)` is known.
52    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/// Fetch and parse every `manifest.json` under `gcs_prefix` (recursive), keeping
61/// each manifest's bucket-relative storage key — needed to resolve a manifest's
62/// (relative) part paths back to full object keys for per-run loading (see
63/// [`select_load_uris`]).
64///
65/// A rivet export writes one manifest per `run_id`; a prefix that has
66/// accumulated several incremental / CDC runs holds several. Transport is the
67/// native opendal client the extraction destination already uses (`store`), so
68/// auth is the export's own GCS credentials and this is offline-testable over a
69/// filesystem-backed store.
70///
71/// `pub` (a public-API root the lib keeps alive) even though its only caller is
72/// the binary-only `cli::dispatch`; `#[allow(private_interfaces)]` because the
73/// injected `GcsStore` is deliberately an internal (`pub(crate)`) type — the
74/// `destination` module stays crate-private. Same rationale as the `preflight`
75/// module note in `lib.rs`.
76#[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            // Round-5 (CWE-400): cap the manifest body before reading it whole into
86            // memory — a planted multi-GB manifest.json under the load prefix would
87            // otherwise OOM the loader. Mirrors the V21 cap the export-side manifest
88            // readers already enforce; this was the 4th, uncapped, read path.
89            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/// Full `gs://` URIs of the parquet to load for `new` (the not-yet-loaded run
105/// manifests), preferring each manifest's own parts over a blanket listing.
106/// See [`select_load_keys`] for the selection rule.
107#[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
125/// The bucket-relative part keys a manifest declares, each resolved against the
126/// manifest's own directory: `<dir(manifest_key)>/<part.path>`. Shared by
127/// [`select_load_keys`] (which intersects them with what's present) and
128/// [`gc_orphans`] (which treats them as the keep-set), so the two can't drift on
129/// how a manifest maps to its files.
130fn 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
144/// Pure selection: which bucket-relative parquet keys to load for the given
145/// (not-yet-loaded) run manifests.
146///
147/// Prefers each manifest's own parts (via [`resolve_parts`]) intersected with
148/// `all_parquet` — so a load pulls exactly the new runs' files, not every object
149/// under the prefix (the key to incremental loads once `cleanup_source` no
150/// longer wipes the bucket). Falls back to the whole `all_parquet` listing when
151/// ANY new manifest resolves to no present part (legacy/part-less manifests
152/// still load, at the cost of not pruning); the row-count gate then still guards
153/// correctness.
154pub 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            // Can't resolve this run to files — don't risk a partial selection.
168            return all_parquet.to_vec();
169        }
170    }
171    selected.into_iter().collect()
172}
173
174/// Delete every `.parquet` under `gcs_prefix` that no **`Success`** manifest
175/// references — crash leftovers from an interrupted extract (a run killed before
176/// it wrote its manifest leaves orphan parts the load already ignores, but which
177/// accumulate). Keeps every manifest, `_SUCCESS`, and every manifested part
178/// (including a `snapshot/` sub-prefix's, since `keyed` is fetched recursively).
179/// Strictly gentler than `cleanup_source`, which wipes the whole prefix.
180///
181/// ⚠️ INVARIANT: it cannot distinguish a crash orphan from a *live* extract's
182/// committed-but-not-yet-manifested parts (both are unmanifested `.parquet`), and
183/// there is NO age/lease guard. Only run a load with `gc_orphans` when no extract
184/// is writing the same prefix — the normal pipeline (a load AFTER a completed
185/// extract) satisfies this; a load fired while a `rivet run` streams into the same
186/// prefix would delete its in-flight parts. Returns `(removed_count, removed_bytes)`.
187#[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
211/// Full/chunked loads care only about the LATEST snapshot: from `keyed` (all run
212/// manifests under the prefix) pick the newest by `finished_at`. Full loads
213/// OVERWRITE, so exactly ONE snapshot may be loaded (loading every accumulated
214/// run would duplicate rows). Deliberately **not** ledger-gated: full
215/// re-materializes the latest snapshot on *every* load, so a re-load self-heals a
216/// drifted target and stays resilient to hidden in-place source updates. An empty
217/// input (no staged run — e.g. the staging was cleaned) yields an empty
218/// selection, so the caller no-ops WITHOUT truncating the target.
219///
220/// Ordering parses `finished_at` as an instant — a lexical byte compare mis-picks
221/// on mixed RFC3339 precision (`…00.5Z` sorts before `…00Z`) — and falls back to
222/// lexical only if a timestamp fails to parse, so a malformed manifest can't panic.
223pub 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
239/// Which run manifests to load for `mode`, given the ledger's already-`loaded`
240/// run_ids. The single mode→selection decision, pure and testable so the load's
241/// central invariant isn't buried in dispatch I/O:
242/// - `Full` → the single LATEST run ([`latest_full`]) — ledger-INDEPENDENT, so a
243///   re-load re-materializes the snapshot (self-heal) and, crucially, the
244///   STATELESS path (empty `loaded`) still picks the latest instead of blanket-
245///   loading every accumulated snapshot (the duplicate-rows bug).
246/// - `Incremental`/`Cdc` → every run not yet in `loaded` (append). Stateless →
247///   `loaded` empty → load all; at-least-once, absorbed by the dedup view.
248pub 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
262/// Bucket-relative keys of every run manifest under `base` (recursive).
263fn 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    // A run into a shared prefix leaves BOTH the canonical `manifest.json`
270    // (last-writer-wins — a pointer to the LATEST run) and an immutable
271    // `manifest-<run_id>.json` copy (one per run). Sum the per-run copies so a
272    // prefix that accumulated several CDC/incremental cycles counts EVERY run;
273    // counting the canonical pointer too would double-count the latest. Fall
274    // back to the canonical name only when no per-run copy exists (a single
275    // batch run, or a legacy prefix predating the run-unique copy).
276    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
288/// A listed key is a run manifest iff its final path segment is the canonical
289/// [`MANIFEST_FILENAME`] (`manifest.json`) or a run-unique copy
290/// (`manifest-<run_id>.json`) — so a data file merely *named* like it (an
291/// unlikely `…/x_manifest.json`) is not mistaken for one.
292fn 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
297/// A per-run manifest copy: `manifest-<token>.json` (the sidecar the OSS sink
298/// writes alongside the canonical pointer so cross-run reconcile can sum it).
299fn is_run_unique_manifest(base: &str) -> bool {
300    base.starts_with("manifest-") && base.ends_with(".json")
301}
302
303/// Refuse a load whose prefix holds MORE THAN ONE export's manifests. The load
304/// sums EVERY manifest under `gcs_prefix` (fetched recursively) and cleanup
305/// removes the prefix recursively, so a shared base prefix — two `partition_by`
306/// exports whose prefix-before-`{partition}` coincides — would silently cross-
307/// contaminate the reconciled row count AND delete the sibling export's un-loaded
308/// parts. The `LoadPlan` carries no source `export_name` to disambiguate which
309/// export the operator meant, so fail LOUDLY rather than load-and-delete the
310/// wrong data. Legacy manifests with no recorded `export_name` (pre-0.x) are
311/// tolerated — an empty name matches any single named export, so a prefix that
312/// mixes old and new runs of the SAME export still loads.
313pub 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
334/// Reconcile a run's manifests into the authoritative expected warehouse row
335/// count, refusing to load anything that is not provably complete.
336///
337/// Every manifest must be a **`Success`** run (a `Failed` / `Interrupted`
338/// manifest describes a partial, untrustworthy export) and **self-consistent**
339/// (`row_count` == sum of committed parts — a mismatch is a writer bug). When a
340/// manifest recorded a `source_row_count`, the **source→file** leg must
341/// reconcile too: `source_row_count == row_count`, or the extract silently
342/// dropped rows. `allow_source_drift` downgrades only that last check to a
343/// warning (e.g. an incremental cursor window whose source moved under it);
344/// the completeness and self-consistency gates never yield.
345///
346/// Returns [`LoadIntegrity`] with the summed `file_rows` the loader's
347/// `expected_rows` gate then checks against the warehouse's `COUNT(*)`.
348pub 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        // Completeness: only a `Success` run may be loaded.
363        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        // Self-consistency: the recorded aggregates must match the committed
373        // parts (a divergence is a writer bug, per OSS `validate_self_consistency`).
374        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        // Source→file: reconcile the extract against the source when the run
392        // probed it. `None` = not probed (unverifiable, not a failure).
393        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    /// A minimal `Success` manifest with one committed part of `rows` rows and,
443    /// optionally, a probed `source_row_count`.
444    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)); // export_name "orders"
502        let mut cust_m = manifest("r2", 10, None);
503        cust_m.export_name = "customers".into();
504        // Two DISTINCT exports under one load prefix → loud refusal (else the load
505        // sums both and cleanup deletes the sibling's parts).
506        assert!(ensure_single_export(&[orders.clone(), keyed(cust_m)]).is_err());
507        // Many runs of ONE export → fine.
508        assert!(ensure_single_export(&[orders.clone(), keyed(manifest("r3", 5, None))]).is_ok());
509        // A legacy (no export_name) run mixed with the SAME named export → still
510        // one logical export, tolerated (an upgrade mid-prefix must not brick load).
511        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        // row_count claims 100 but the only committed part holds 100 → make the
551        // aggregate lie by bumping the recorded row_count only.
552        let mut m = manifest("r1", 100, Some(100));
553        m.row_count = 999; // no longer equals committed parts' sum (100)
554        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        // Source had 120, only 100 extracted → 20 rows silently dropped.
561        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    /// A `(manifest_key, manifest)` pair with a single part named `part`.
580    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        // Two runs' files sit under the prefix; only r2 is "new".
589        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        // select_load_keys (the pure selection) is unit-tested throughout; this
604        // pins the store WRAPPER around it — list `.parquet` under the base, then
605        // reconstruct each `gs://<bucket>/<key>` the loader COPYs from. A mangled
606        // wrapper (empty/garbage URIs) would send the warehouse load at nothing,
607        // or the wrong object — invisible to the pure-selection tests.
608        let (store, _g) = fs_store(&[
609            ("base/r1-000.parquet", b"a".to_vec()),
610            ("base/manifest-r1.json", b"{}".to_vec()), // not .parquet — must be skipped
611        ]);
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        // A snapshot manifest lives under `base/snapshot/`; its part is relative
623        // to that dir. Resolution must reconstruct the full key.
624        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        // A manifest whose part isn't in the listing (legacy / renamed) → don't
639        // risk a partial selection; load everything under the prefix.
640        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        // When every run is already in the ledger the "new" set is empty. That
652        // must resolve to ZERO uris — NOT the blanket fallback, which would
653        // re-load the whole prefix on every up-to-date run (double-load). The
654        // fallback fires only for an unresolvable NON-empty manifest.
655        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()),   // manifested
669            ("base/orphan.parquet", b"junk".to_vec()), // crash leftover — no manifest
670            ("base/manifest.json", b"{}".to_vec()),    // kept — not a .parquet
671            ("base/_SUCCESS", b"".to_vec()),           // kept — not a .parquet
672        ]);
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()), // manifested under snapshot/
701            ("base/orphan.parquet", b"x".to_vec()),         // top-level orphan
702        ]);
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        // Only a Success manifest keeps its parts — a Failed/Interrupted run's
716        // files are themselves crash leftovers.
717        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        // Full loads OVERWRITE, so only the LATEST snapshot may be loaded —
732        // selecting all accumulated runs would load duplicate snapshots.
733        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        // Full is NOT ledger-skipped: a re-load re-OVERWRITEs from the latest
746        // snapshot, self-healing a drifted target and staying resilient to hidden
747        // in-place updates. The ledger must NOT suppress the re-materialization —
748        // full has to stay full (the old `latest_unloaded_full` skip was the bug).
749        let keyed = vec![
750            keyed_at("r1", "2026-01-01T00:00:00Z"),
751            keyed_at("r2", "2026-01-02T00:00:00Z"),
752        ];
753        // r2 is "already loaded" in the caller's ledger, yet full still selects it.
754        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        // Empty staging (e.g. cleaned, no fresh extract) → empty selection → the
762        // caller returns None and never truncates the target to empty.
763        assert!(latest_full(Vec::new()).is_empty());
764    }
765
766    #[test]
767    fn latest_full_orders_by_parsed_instant_not_lexical_bytes() {
768        // `…00.500Z` is LATER than `…00Z` but sorts EARLIER lexically ('.' < 'Z').
769        // Parsing to an instant picks the truly-newest run, not the lexical max.
770        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        // Stateful, r2 already loaded → still selects r2 (re-materialize/self-heal).
791        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        // STATELESS (empty loaded) → the latest, NEVER a blanket load of both
796        // snapshots (the duplicate-rows bug this fix closes).
797        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            // Stateless → empty loaded → load all (at-least-once, dedup absorbs).
816            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")); // no `-` after manifest
828        assert!(!is_run_unique_manifest("manifest-abc.txt")); // not .json
829        assert!(!is_run_unique_manifest("data.json")); // wrong prefix
830    }
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    // ---- offline (filesystem-backed store) transport tests ----
857
858    /// Build an fs-backed [`GcsStore`] over a fresh tempdir seeded with
859    /// `(bucket-relative-key, bytes)` objects. Returns the store and the guard —
860    /// hold the `TempDir` for the store's lifetime.
861    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        // A shared prefix that accumulated two runs holds the last-writer-wins
879        // `manifest.json` pointer AND one immutable per-run copy each. Summing
880        // must count the two run copies, never the pointer (double-count guard).
881        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()), // data file: not a manifest
886        ]);
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        // A single batch run (or a legacy prefix) has only the canonical name —
901        // with no per-run copy, it must still be found.
902        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        // Two runs' copies plus a canonical pointer to the latest: fetch returns
912        // exactly the two run copies, parsed — so reconcile sums 100 + 40 = 140
913        // without double-counting the pointer.
914        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    // ── fake-gcs-server: the storage contract over the REAL opendal-GCS transport
946    //
947    // Every test above runs the storage-contract fns over an Fs-backed GcsStore —
948    // proving the LOGIC, but not that opendal's GCS client speaks the same
949    // list/read/remove semantics a real bucket answers with. This cell closes that
950    // gap against fsouza/fake-gcs-server (the GCS JSON API emulator the extract
951    // side already uses), so a GCS-only regression — listing pagination, prefix
952    // handling, `remove_all` recursion — fails HERE, not in production. The
953    // warehouse half (bq/snow COPY) can't be emulated; it stays the live BigQuery
954    // matrix (`smoke_batch_mysql` / `StagingWiped`). This owns the BUCKET half.
955    const FAKE_GCS_ENDPOINT: &str = "http://127.0.0.1:4443";
956    const FAKE_GCS_BUCKET: &str = "rivet-load-emulator";
957
958    /// A real GCS-transport [`GcsStore`] against the local emulator, scoped to
959    /// `FAKE_GCS_BUCKET`. Ensures the bucket first via the emulator's JSON API
960    /// (fake-gcs does not auto-create on write) — an idempotent POST, so a re-run
961    /// needs no teardown. curl, not a new HTTP dep: this is a dev-only cell.
962    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    /// Per-object drain of `prefix` — list + single `remove` each. fake-gcs
990    /// implements single-object DELETE but NOT the GCS batch-delete endpoint that
991    /// opendal's `remove_all` issues for 2+ objects (that 400s with `deleted: 0`),
992    /// so the emulator can't run the `cleanup_source` batch wipe. That wipe
993    /// (`delete_under` → `remove_all`) is verified against a REAL bucket by the
994    /// live BigQuery `StagingWiped` matrix, and over the Fs seam above; here we
995    /// drain per-object for idempotent setup/teardown.
996    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        // Test-owned prefix, drained first so a re-run starts clean even though the
1007        // emulator bucket persists (no cross-run bleed).
1008        let prefix = "load-contract/orders";
1009        drain(&store, prefix);
1010        let gs = format!("gs://{FAKE_GCS_BUCKET}/{prefix}");
1011
1012        // Seed one Success run (its manifest + committed part) plus a crash orphan
1013        // — an unmanifested `.parquet` an interrupted extract would leave behind.
1014        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        // 1. fetch + parse the manifest back over real GCS list+read.
1028        let keyed = fetch_manifests_keyed(&store, &gs).unwrap();
1029        assert_eq!(keyed.len(), 1, "the run's manifest, read back over GCS");
1030
1031        // 2. reconcile → file_rows, the value the warehouse COUNT(*) gate enforces.
1032        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        // 3. select_load_uris resolves the MANIFESTED part only — never the orphan.
1040        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        // 4. gc_orphans deletes the orphan over real GCS — a REAL single-object
1049        // DELETE against the emulator — keeping the manifested part + manifest.
1050        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        // cleanup_source's batch wipe (`remove_all`) is NOT emulatable here — see
1067        // `drain`. Teardown per-object; the empty listing confirms the deletes
1068        // landed over real GCS transport.
1069        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}