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 that accumulate.
176/// Keeps every manifest, `_SUCCESS`, and every manifested part (including a
177/// `snapshot/` sub-prefix's, since `keyed` is fetched recursively). Strictly
178/// gentler than `cleanup_source`, which wipes the whole prefix.
179///
180/// A candidate falls into three classes:
181/// - referenced by a **`Success`** manifest → KEEP (live data).
182/// - referenced by a **`Failed`/`Interrupted`** manifest → DELETE unconditionally.
183///   A run that WROTE a manifest is terminal — no live extract is still streaming
184///   it — so its leftovers are unambiguous crash debris.
185/// - referenced by **NO manifest at all** → AMBIGUOUS: a crash-BEFORE-manifest
186///   orphan (delete) OR a concurrent extract's committed-but-not-yet-manifested
187///   part (must NOT delete). `active` decides — the caller's answer to "is a run
188///   currently WRITING this prefix?", read from the central run-status ledger
189///   (`StateStore::has_active_run_on_prefix`): authoritative and CLOCK-FREE.
190///   `active` → spare every unmanifested part; `!active` → no run is live, so an
191///   unmanifested part is dead crash debris → delete.
192///
193/// The ledger read is the SEAM. A co-located / shared-Postgres load gets a
194/// precise `active`; a stateless or foreign-host load passes `active = true`
195/// (conservative — spare rather than risk a live cross-host extract's parts),
196/// which the bucket-manifest `running`-status projection later refines.
197/// Returns `(removed, bytes)`.
198#[allow(private_interfaces)]
199pub fn gc_orphans(
200    store: &GcsStore,
201    gcs_prefix: &str,
202    keyed: &[(String, RunManifest)],
203    active: bool,
204) -> Result<(usize, u64)> {
205    let (_bucket, base) = crate::load::split_gs_uri(gcs_prefix)?;
206    // Success parts → keep. Failed/Interrupted parts → terminal (their run is
207    // done, so deletable regardless of `active`). A part in NEITHER set has no
208    // manifest at all — the ambiguous case `active` gates.
209    let mut keep: std::collections::HashSet<String> = std::collections::HashSet::new();
210    let mut terminal: std::collections::HashSet<String> = std::collections::HashSet::new();
211    for (key, m) in keyed {
212        match m.status {
213            ManifestStatus::Success => keep.extend(resolve_parts(key, m)),
214            // A `running` manifest is a LIVE run's marker (schema-less, no parts).
215            // Its parts (if any) must NOT be treated as terminal debris — its
216            // activeness is what makes `active` true (see the callsite), which
217            // spares the run's unmanifested in-flight parts below.
218            ManifestStatus::Running => {}
219            ManifestStatus::Failed | ManifestStatus::Interrupted => {
220                terminal.extend(resolve_parts(key, m))
221            }
222        }
223    }
224    let mut removed = 0usize;
225    let mut removed_bytes = 0u64;
226    for key in store.list_files(base)? {
227        if !key.ends_with(".parquet") || keep.contains(&key) {
228            continue;
229        }
230        // A live run on this prefix + a part with NO manifest → maybe its
231        // in-flight write → spare. A terminal-manifested part is deleted even
232        // while a (different) run is active.
233        if active && !terminal.contains(&key) {
234            log::warn!(
235                "gc_orphans: sparing unmanifested `{key}` — a run is active on this prefix \
236                 (run-status ledger); it is GC'd once no run is active or it gets a manifest"
237            );
238            continue;
239        }
240        removed_bytes += store.stat_size(&key).unwrap_or(0);
241        store.remove(&key)?;
242        removed += 1;
243    }
244    // Second pass: a SUPERSEDED `running` MARKER manifest (its run was overtaken by
245    // a newer run of the same export) is a dead crash marker — never the live
246    // signal — so its lingering `.json` is safe to remove. gc otherwise never
247    // touches it (the parquet pass above only lists `.parquet`), so a hard-crashed
248    // run's marker would accumulate forever. A NON-superseded running manifest is
249    // the ACTIVE signal and MUST survive — deletion here is gated on SUPERSESSION,
250    // never on `active`.
251    for (key, m) in keyed {
252        if m.status == ManifestStatus::Running && is_superseded(m, keyed) {
253            removed_bytes += store.stat_size(key).unwrap_or(0);
254            store.remove(key)?;
255            removed += 1;
256        }
257    }
258    Ok((removed, removed_bytes))
259}
260
261/// Is a LIVE run's `running` MARKER manifest present under the prefix — the
262/// bucket-side projection of the run-status ledger, for a cross-boundary load
263/// (Airflow / a foreign-host `rivet load`) that cannot read the extract's state
264/// DB? True iff some `running` manifest is NOT superseded by a NEWER manifest
265/// (any status) of the SAME export — the same clock-free supersession the ledger
266/// uses (a newer `started_at` means the old running run crashed and its successor
267/// already re-ran). `gc_orphans`'s `active` is `ledger_active OR this`, so the two
268/// signals are belt-and-suspenders: the ledger is precise co-located, the marker
269/// covers cross-host.
270pub fn has_active_running_manifest(keyed: &[(String, RunManifest)]) -> bool {
271    keyed
272        .iter()
273        .any(|(_, m)| m.status == ManifestStatus::Running && !is_superseded(m, keyed))
274}
275
276/// A `running` manifest is SUPERSEDED when a NEWER run of the SAME export exists
277/// (a higher `started_at`) — it crashed and its successor already re-ran, so it
278/// no longer protects anything. The ONE clock-free staleness predicate, shared by
279/// [`has_active_running_manifest`] (spare the non-superseded) and `gc_orphans`'s
280/// marker-GC sweep (delete the superseded). The ledger enforces the same rule in
281/// SQL — that copy cannot share this Rust.
282fn is_superseded(m: &RunManifest, keyed: &[(String, RunManifest)]) -> bool {
283    keyed
284        .iter()
285        .any(|(_, o)| o.export_name == m.export_name && o.started_at > m.started_at)
286}
287
288/// Full/chunked loads care only about the LATEST snapshot: from `keyed` (all run
289/// manifests under the prefix) pick the newest by `finished_at`. Full loads
290/// OVERWRITE, so exactly ONE snapshot may be loaded (loading every accumulated
291/// run would duplicate rows). Deliberately **not** ledger-gated: full
292/// re-materializes the latest snapshot on *every* load, so a re-load self-heals a
293/// drifted target and stays resilient to hidden in-place source updates. An empty
294/// input (no staged run — e.g. the staging was cleaned) yields an empty
295/// selection, so the caller no-ops WITHOUT truncating the target.
296///
297/// Ordering parses `finished_at` as an instant — a lexical byte compare mis-picks
298/// on mixed RFC3339 precision (`…00.5Z` sorts before `…00Z`) — and falls back to
299/// lexical only if a timestamp fails to parse, so a malformed manifest can't panic.
300pub fn latest_full(keyed: Vec<(String, RunManifest)>) -> Vec<(String, RunManifest)> {
301    keyed
302        .into_iter()
303        .max_by(|a, b| {
304            match (
305                chrono::DateTime::parse_from_rfc3339(&a.1.finished_at).ok(),
306                chrono::DateTime::parse_from_rfc3339(&b.1.finished_at).ok(),
307            ) {
308                (Some(x), Some(y)) => x.cmp(&y),
309                _ => a.1.finished_at.cmp(&b.1.finished_at),
310            }
311        })
312        .into_iter()
313        .collect()
314}
315
316/// Which run manifests to load for `mode`, given the ledger's already-`loaded`
317/// run_ids. The single mode→selection decision, pure and testable so the load's
318/// central invariant isn't buried in dispatch I/O:
319/// - `Full` → the single LATEST run ([`latest_full`]) — ledger-INDEPENDENT, so a
320///   re-load re-materializes the snapshot (self-heal) and, crucially, the
321///   STATELESS path (empty `loaded`) still picks the latest instead of blanket-
322///   loading every accumulated snapshot (the duplicate-rows bug).
323/// - `Incremental`/`Cdc` → every run not yet in `loaded` (append). Stateless →
324///   `loaded` empty → load all; at-least-once, absorbed by the dedup view.
325pub fn select_runs(
326    keyed: Vec<(String, RunManifest)>,
327    loaded: &std::collections::HashSet<String>,
328    mode: crate::load::plan::LoadMode,
329) -> Vec<(String, RunManifest)> {
330    // A `running` manifest is a LIVE run's in-flight marker — never loadable (no
331    // committed parts). Drop it BEFORE selection so it neither becomes the
332    // "latest" full snapshot nor an incremental delta, either of which would
333    // make `reconcile` refuse the whole load on a non-Success run.
334    let keyed: Vec<(String, RunManifest)> = keyed
335        .into_iter()
336        .filter(|(_, m)| m.status != ManifestStatus::Running)
337        .collect();
338    match mode {
339        crate::load::plan::LoadMode::Full => latest_full(keyed),
340        _ => keyed
341            .into_iter()
342            .filter(|(_, m)| !loaded.contains(&m.run_id))
343            .collect(),
344    }
345}
346
347/// Bucket-relative keys of every run manifest under `base` (recursive).
348fn list_manifest_keys(store: &GcsStore, base: &str) -> Result<Vec<String>> {
349    let all: Vec<String> = store
350        .list_files(base)?
351        .into_iter()
352        .filter(|k| is_manifest_key(k))
353        .collect();
354    // A run into a shared prefix leaves BOTH the canonical `manifest.json`
355    // (last-writer-wins — a pointer to the LATEST run) and an immutable
356    // `manifest-<run_id>.json` copy (one per run). Sum the per-run copies so a
357    // prefix that accumulated several CDC/incremental cycles counts EVERY run;
358    // counting the canonical pointer too would double-count the latest. Fall
359    // back to the canonical name only when no per-run copy exists (a single
360    // batch run, or a legacy prefix predating the run-unique copy).
361    let run_unique: Vec<String> = all
362        .iter()
363        .filter(|k| is_run_unique_manifest(k.rsplit('/').next().unwrap_or("")))
364        .cloned()
365        .collect();
366    Ok(if run_unique.is_empty() {
367        all
368    } else {
369        run_unique
370    })
371}
372
373/// A listed key is a run manifest iff its final path segment is the canonical
374/// [`MANIFEST_FILENAME`] (`manifest.json`) or a run-unique copy
375/// (`manifest-<run_id>.json`) — so a data file merely *named* like it (an
376/// unlikely `…/x_manifest.json`) is not mistaken for one.
377fn is_manifest_key(key: &str) -> bool {
378    let base = key.rsplit('/').next().unwrap_or("");
379    base == MANIFEST_FILENAME || is_run_unique_manifest(base)
380}
381
382/// A per-run manifest copy: `manifest-<token>.json` (the sidecar the OSS sink
383/// writes alongside the canonical pointer so cross-run reconcile can sum it).
384fn is_run_unique_manifest(base: &str) -> bool {
385    // The sidecar naming scheme lives once, in `manifest.rs` (the writer's home).
386    crate::manifest::is_run_unique_manifest_name(base)
387}
388
389/// Refuse a load whose prefix holds MORE THAN ONE export's manifests. The load
390/// sums EVERY manifest under `gcs_prefix` (fetched recursively) and cleanup
391/// removes the prefix recursively, so a shared base prefix — two `partition_by`
392/// exports whose prefix-before-`{partition}` coincides — would silently cross-
393/// contaminate the reconciled row count AND delete the sibling export's un-loaded
394/// parts. The `LoadPlan` carries no source `export_name` to disambiguate which
395/// export the operator meant, so fail LOUDLY rather than load-and-delete the
396/// wrong data. Legacy manifests with no recorded `export_name` (pre-0.x) are
397/// tolerated — an empty name matches any single named export, so a prefix that
398/// mixes old and new runs of the SAME export still loads.
399pub fn ensure_single_export(keyed: &[(String, RunManifest)]) -> Result<()> {
400    let mut names: std::collections::BTreeSet<&str> = keyed
401        .iter()
402        .map(|(_, m)| m.export_name.as_str())
403        .filter(|n| !n.is_empty())
404        .collect();
405    if names.len() > 1 {
406        let listed: Vec<&str> = std::mem::take(&mut names).into_iter().collect();
407        bail!(
408            "the load prefix holds manifests from {} distinct exports ({}) — the load sums every \
409             manifest under the prefix and cleanup wipes it recursively, so loading a shared \
410             prefix would cross-contaminate the row count and could delete a sibling export's \
411             parts. Give each export a DISTINCT destination prefix (or scope the load prefix to a \
412             single export) and re-run.",
413            listed.len(),
414            listed.join(", ")
415        );
416    }
417    Ok(())
418}
419
420/// Reconcile a run's manifests into the authoritative expected warehouse row
421/// count, refusing to load anything that is not provably complete.
422///
423/// Every manifest must be a **`Success`** run (a `Failed` / `Interrupted`
424/// manifest describes a partial, untrustworthy export) and **self-consistent**
425/// (`row_count` == sum of committed parts — a mismatch is a writer bug). When a
426/// manifest recorded a `source_row_count`, the **source→file** leg must
427/// reconcile too: `source_row_count == row_count`, or the extract silently
428/// dropped rows. `allow_source_drift` downgrades only that last check to a
429/// warning (e.g. an incremental cursor window whose source moved under it);
430/// the completeness and self-consistency gates never yield.
431///
432/// Returns [`LoadIntegrity`] with the summed `file_rows` the loader's
433/// `expected_rows` gate then checks against the warehouse's `COUNT(*)`.
434pub fn reconcile(manifests: &[RunManifest], allow_source_drift: bool) -> Result<LoadIntegrity> {
435    if manifests.is_empty() {
436        bail!(
437            "no `{MANIFEST_FILENAME}` found under the export prefix — refusing to load \
438             unverified files. A rivet export writes a manifest on success; its absence \
439             means the run never completed (or points at the wrong prefix)."
440        );
441    }
442
443    let mut file_rows: u64 = 0;
444    let mut source_rows: u64 = 0;
445    let mut any_source = false;
446
447    for m in manifests {
448        // Completeness: only a `Success` run may be loaded.
449        if m.status != ManifestStatus::Success {
450            bail!(
451                "manifest for run `{}` (export `{}`) is {:?}, not Success — refusing to load a \
452                 partial export",
453                m.run_id,
454                m.export_name,
455                m.status
456            );
457        }
458        // Self-consistency: the recorded aggregates must match the committed
459        // parts (a divergence is a writer bug, per OSS `validate_self_consistency`).
460        m.validate_self_consistency().map_err(|e| {
461            anyhow::anyhow!(
462                "manifest for run `{}` (export `{}`) is internally inconsistent: {e} — refusing \
463                 to load",
464                m.run_id,
465                m.export_name
466            )
467        })?;
468
469        let rows = u64::try_from(m.row_count).with_context(|| {
470            format!(
471                "manifest for run `{}` has a negative row_count ({})",
472                m.run_id, m.row_count
473            )
474        })?;
475        file_rows += rows;
476
477        // Source→file: reconcile the extract against the source when the run
478        // probed it. `None` = not probed (unverifiable, not a failure).
479        if let Some(src) = m
480            .source
481            .extraction
482            .as_ref()
483            .and_then(|x| x.source_row_count)
484        {
485            let src = u64::try_from(src).with_context(|| {
486                format!(
487                    "manifest for run `{}` has a negative source_row_count ({src})",
488                    m.run_id
489                )
490            })?;
491            any_source = true;
492            source_rows += src;
493            if src != rows {
494                if allow_source_drift {
495                    eprintln!(
496                        "warning: source→file drift for run `{}` (export `{}`): source had {src} \
497                         rows, extracted {rows} (--allow-source-drift)",
498                        m.run_id, m.export_name
499                    );
500                } else {
501                    bail!(
502                        "source→file mismatch for run `{}` (export `{}`): source had {src} rows \
503                         but {rows} were extracted — the extract dropped {} row(s). Investigate \
504                         before loading, or pass --allow-source-drift to override.",
505                        m.run_id,
506                        m.export_name,
507                        src.abs_diff(rows)
508                    );
509                }
510            }
511        }
512    }
513
514    Ok(LoadIntegrity {
515        source_rows: any_source.then_some(source_rows),
516        file_rows,
517        manifests: manifests.len(),
518    })
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::manifest::{
525        ExtractionMetadata, ManifestDestination, ManifestPart, ManifestSource, PartStatus,
526    };
527
528    /// A minimal `Success` manifest with one committed part of `rows` rows and,
529    /// optionally, a probed `source_row_count`.
530    fn manifest(run: &str, rows: i64, source: Option<i64>) -> RunManifest {
531        RunManifest {
532            manifest_version: crate::manifest::MANIFEST_VERSION,
533            run_id: run.into(),
534            export_name: "orders".into(),
535            mode: "batch".into(),
536            started_at: "t".into(),
537            finished_at: "t".into(),
538            status: ManifestStatus::Success,
539            source: ManifestSource {
540                engine: "pg".into(),
541                schema: None,
542                table: None,
543                extraction: source.map(|n| ExtractionMetadata {
544                    strategy: "full".into(),
545                    cursor_column: None,
546                    cursor_type: None,
547                    cursor_low: None,
548                    cursor_high: None,
549                    source_row_count: Some(n),
550                }),
551            },
552            destination: ManifestDestination {
553                kind: "gcs".into(),
554                uri: "gs://b/p".into(),
555            },
556            format: "parquet".into(),
557            compression: "zstd".into(),
558            schema_fingerprint: "xxh3:0".into(),
559            row_count: rows,
560            part_count: 1,
561            parts: vec![ManifestPart {
562                part_id: 0,
563                path: "part-000000.parquet".into(),
564                rows,
565                size_bytes: 1,
566                content_fingerprint: "xxh3:0".into(),
567                content_md5: String::new(),
568                status: PartStatus::Committed,
569            }],
570            column_checksums: None,
571            checksum_key_column: None,
572        }
573    }
574
575    #[test]
576    fn sums_file_and_source_rows_across_manifests() {
577        let ms = vec![manifest("r1", 100, Some(100)), manifest("r2", 40, Some(40))];
578        let got = reconcile(&ms, false).unwrap();
579        assert_eq!(got.file_rows, 140);
580        assert_eq!(got.source_rows, Some(140));
581        assert_eq!(got.manifests, 2);
582    }
583
584    #[test]
585    fn ensure_single_export_refuses_a_prefix_shared_by_two_exports() {
586        let keyed = |m: RunManifest| ("gs://b/p/manifest-x.json".to_string(), m);
587        let orders = keyed(manifest("r1", 10, None)); // export_name "orders"
588        let mut cust_m = manifest("r2", 10, None);
589        cust_m.export_name = "customers".into();
590        // Two DISTINCT exports under one load prefix → loud refusal (else the load
591        // sums both and cleanup deletes the sibling's parts).
592        assert!(ensure_single_export(&[orders.clone(), keyed(cust_m)]).is_err());
593        // Many runs of ONE export → fine.
594        assert!(ensure_single_export(&[orders.clone(), keyed(manifest("r3", 5, None))]).is_ok());
595        // A legacy (no export_name) run mixed with the SAME named export → still
596        // one logical export, tolerated (an upgrade mid-prefix must not brick load).
597        let mut legacy = manifest("r0", 3, None);
598        legacy.export_name = String::new();
599        assert!(ensure_single_export(&[orders, keyed(legacy)]).is_ok());
600    }
601
602    #[test]
603    fn source_rows_is_none_when_no_manifest_probed_the_source() {
604        let ms = vec![manifest("r1", 100, None), manifest("r2", 40, None)];
605        let got = reconcile(&ms, false).unwrap();
606        assert_eq!(got.file_rows, 140);
607        assert_eq!(
608            got.source_rows, None,
609            "unprobed source is unknown, not zero"
610        );
611    }
612
613    #[test]
614    fn source_rows_present_even_if_only_some_manifests_probed() {
615        let ms = vec![manifest("r1", 100, Some(100)), manifest("r2", 40, None)];
616        let got = reconcile(&ms, false).unwrap();
617        assert_eq!(got.source_rows, Some(100));
618    }
619
620    #[test]
621    fn empty_manifests_refuses_to_load() {
622        let err = reconcile(&[], false).unwrap_err().to_string();
623        assert!(err.contains("refusing to load"), "{err}");
624    }
625
626    #[test]
627    fn non_success_manifest_refuses_to_load() {
628        let mut m = manifest("r1", 100, Some(100));
629        m.status = ManifestStatus::Interrupted;
630        let err = reconcile(&[m], false).unwrap_err().to_string();
631        assert!(err.contains("not Success"), "{err}");
632    }
633
634    #[test]
635    fn self_inconsistent_manifest_refuses_to_load() {
636        // row_count claims 100 but the only committed part holds 100 → make the
637        // aggregate lie by bumping the recorded row_count only.
638        let mut m = manifest("r1", 100, Some(100));
639        m.row_count = 999; // no longer equals committed parts' sum (100)
640        let err = reconcile(&[m], false).unwrap_err().to_string();
641        assert!(err.contains("inconsistent"), "{err}");
642    }
643
644    #[test]
645    fn source_file_mismatch_hard_fails_by_default() {
646        // Source had 120, only 100 extracted → 20 rows silently dropped.
647        let m = manifest("r1", 100, Some(120));
648        let err = reconcile(&[m], false).unwrap_err().to_string();
649        assert!(err.contains("source→file mismatch"), "{err}");
650        assert!(err.contains("dropped 20"), "{err}");
651    }
652
653    #[test]
654    fn source_file_mismatch_is_allowed_under_the_override() {
655        let m = manifest("r1", 100, Some(120));
656        let got = reconcile(&[m], true).expect("--allow-source-drift proceeds");
657        assert_eq!(got.file_rows, 100);
658        assert_eq!(
659            got.source_rows,
660            Some(120),
661            "the probed source count is still surfaced"
662        );
663    }
664
665    /// A `(manifest_key, manifest)` pair with a single part named `part`.
666    fn keyed(key: &str, run: &str, part: &str) -> (String, RunManifest) {
667        let mut m = manifest(run, 10, Some(10));
668        m.parts[0].path = part.into();
669        (key.to_string(), m)
670    }
671
672    #[test]
673    fn select_load_keys_picks_only_the_new_runs_parts() {
674        // Two runs' files sit under the prefix; only r2 is "new".
675        let all = vec![
676            "base/r1-000.parquet".to_string(),
677            "base/r2-000.parquet".to_string(),
678        ];
679        let new = vec![keyed("base/manifest-r2.json", "r2", "r2-000.parquet")];
680        assert_eq!(
681            select_load_keys(&new, &all),
682            vec!["base/r2-000.parquet".to_string()],
683            "loads r2's part only — not r1's already-loaded file"
684        );
685    }
686
687    #[test]
688    fn select_load_uris_lists_and_re_prefixes_selected_keys_with_the_source_bucket() {
689        // select_load_keys (the pure selection) is unit-tested throughout; this
690        // pins the store WRAPPER around it — list `.parquet` under the base, then
691        // reconstruct each `gs://<bucket>/<key>` the loader COPYs from. A mangled
692        // wrapper (empty/garbage URIs) would send the warehouse load at nothing,
693        // or the wrong object — invisible to the pure-selection tests.
694        let (store, _g) = fs_store(&[
695            ("base/r1-000.parquet", b"a".to_vec()),
696            ("base/manifest-r1.json", b"{}".to_vec()), // not .parquet — must be skipped
697        ]);
698        let new = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
699        assert_eq!(
700            select_load_uris(&store, "gs://my-bucket/base", &new).unwrap(),
701            vec!["gs://my-bucket/base/r1-000.parquet".to_string()],
702            "the selected key, re-prefixed with the source bucket — never the manifest"
703        );
704    }
705
706    #[test]
707    fn select_load_keys_resolves_a_snapshot_subprefix_manifest() {
708        // A snapshot manifest lives under `base/snapshot/`; its part is relative
709        // to that dir. Resolution must reconstruct the full key.
710        let all = vec!["base/snapshot/snap-000.parquet".to_string()];
711        let new = vec![keyed(
712            "base/snapshot/manifest-r1.json",
713            "r1",
714            "snap-000.parquet",
715        )];
716        assert_eq!(
717            select_load_keys(&new, &all),
718            vec!["base/snapshot/snap-000.parquet".to_string()]
719        );
720    }
721
722    #[test]
723    fn select_load_keys_falls_back_to_full_listing_when_a_manifest_has_no_present_part() {
724        // A manifest whose part isn't in the listing (legacy / renamed) → don't
725        // risk a partial selection; load everything under the prefix.
726        let all = vec!["base/a.parquet".to_string(), "base/b.parquet".to_string()];
727        let new = vec![keyed("base/manifest-r1.json", "r1", "missing.parquet")];
728        assert_eq!(
729            select_load_keys(&new, &all),
730            all,
731            "unresolvable part → blanket fallback"
732        );
733    }
734
735    #[test]
736    fn select_load_keys_empty_new_set_selects_nothing_never_the_full_listing() {
737        // When every run is already in the ledger the "new" set is empty. That
738        // must resolve to ZERO uris — NOT the blanket fallback, which would
739        // re-load the whole prefix on every up-to-date run (double-load). The
740        // fallback fires only for an unresolvable NON-empty manifest.
741        let all = vec![
742            "base/r1-000.parquet".to_string(),
743            "base/r2-000.parquet".to_string(),
744        ];
745        assert!(
746            select_load_keys(&[], &all).is_empty(),
747            "no new runs ⇒ load nothing, not everything"
748        );
749    }
750
751    #[test]
752    fn gc_orphans_removes_unmanifested_parquet_only() {
753        let (store, _g) = fs_store(&[
754            ("base/r1-000.parquet", b"aa".to_vec()),   // manifested
755            ("base/orphan.parquet", b"junk".to_vec()), // crash leftover — no manifest
756            ("base/manifest.json", b"{}".to_vec()),    // kept — not a .parquet
757            ("base/_SUCCESS", b"".to_vec()),           // kept — not a .parquet
758        ]);
759        let keyed = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
760        // No run is active on this prefix → the unmanifested part is dead debris.
761        let (removed, bytes) = gc_orphans(&store, "gs://b/base", &keyed, false).unwrap();
762        assert_eq!(removed, 1, "only the unmanifested part is removed");
763        assert_eq!(bytes, 4, "'junk' is 4 bytes");
764        let mut left = store.list_files("base").unwrap();
765        left.sort();
766        assert_eq!(
767            left,
768            vec![
769                "base/_SUCCESS".to_string(),
770                "base/manifest.json".to_string(),
771                "base/r1-000.parquet".to_string(),
772            ],
773            "the manifested part, the manifest, and _SUCCESS all survive"
774        );
775    }
776
777    #[test]
778    fn gc_orphans_of_an_all_manifested_prefix_removes_nothing() {
779        let (store, _g) = fs_store(&[("base/r1-000.parquet", b"a".to_vec())]);
780        let keyed = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
781        assert_eq!(
782            gc_orphans(&store, "gs://b/base", &keyed, false).unwrap().0,
783            0
784        );
785    }
786
787    #[test]
788    fn gc_orphans_keeps_a_snapshot_subprefix_manifested_part() {
789        let (store, _g) = fs_store(&[
790            ("base/snapshot/s-000.parquet", b"a".to_vec()), // manifested under snapshot/
791            ("base/orphan.parquet", b"x".to_vec()),         // top-level orphan
792        ]);
793        let keyed = vec![keyed("base/snapshot/manifest-s.json", "s", "s-000.parquet")];
794        let (removed, _) = gc_orphans(&store, "gs://b/base", &keyed, false).unwrap();
795        assert_eq!(removed, 1, "the top-level orphan goes");
796        assert_eq!(
797            store.list_files("base/snapshot").unwrap(),
798            vec!["base/snapshot/s-000.parquet".to_string()],
799            "the snapshot-subprefix manifested part is kept"
800        );
801    }
802
803    #[test]
804    fn gc_orphans_deletes_a_terminal_runs_parts_even_while_a_run_is_active() {
805        // A Failed/Interrupted run's parts are terminal crash debris — deleted
806        // regardless of `active`, since no LIVE run is streaming THEM. Passing
807        // active=true proves the `active` gate applies only to UNmanifested parts.
808        let (store, _g) = fs_store(&[("base/f-000.parquet", b"x".to_vec())]);
809        let mut kv = keyed("base/manifest-f.json", "f", "f-000.parquet");
810        kv.1.status = ManifestStatus::Failed;
811        assert_eq!(gc_orphans(&store, "gs://b/base", &[kv], true).unwrap().0, 1);
812    }
813
814    #[test]
815    fn gc_orphans_spares_an_unmanifested_part_while_a_run_is_active() {
816        // The concurrent-extract guard. A part with NO manifest, while a run is
817        // ACTIVE on this prefix (the run-status ledger says so), is probably that
818        // run's committed-but-not-yet-manifested write — deleting it would be
819        // silent data loss on the live extract. RED against a mutant that ignores
820        // `active` (deletes every non-Success-manifested `.parquet`).
821        let (store, _g) = fs_store(&[
822            ("base/r1-000.parquet", b"aa".to_vec()),  // manifested (kept)
823            ("base/inflight.parquet", b"x".to_vec()), // unmanifested, a live run's part
824        ]);
825        let keyed = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
826        let (removed, _) = gc_orphans(&store, "gs://b/base", &keyed, true).unwrap();
827        assert_eq!(
828            removed, 0,
829            "an unmanifested part is spared while a run is active"
830        );
831        assert!(
832            store
833                .list_files("base")
834                .unwrap()
835                .iter()
836                .any(|k| k.ends_with("inflight.parquet")),
837            "the live run's in-flight part must survive gc_orphans"
838        );
839    }
840
841    #[test]
842    fn gc_orphans_collects_an_unmanifested_part_when_no_run_is_active() {
843        // The other half: with NO run active, an unmanifested part is a dead
844        // crash orphan and IS collected — the gate DEFERS cleanup, never abandons.
845        let (store, _g) = fs_store(&[
846            ("base/r1-000.parquet", b"aa".to_vec()),
847            ("base/dead-orphan.parquet", b"x".to_vec()),
848        ]);
849        let keyed = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
850        assert_eq!(
851            gc_orphans(&store, "gs://b/base", &keyed, false).unwrap().0,
852            1,
853            "with no active run, an unmanifested orphan is collected"
854        );
855    }
856
857    /// A `running` MARKER manifest (schema-less, no parts) started at `started_at`.
858    fn running(run: &str, started_at: &str) -> (String, RunManifest) {
859        let mut m = manifest(run, 0, None);
860        m.status = ManifestStatus::Running;
861        m.started_at = started_at.into();
862        m.finished_at = String::new();
863        m.parts.clear();
864        (format!("base/manifest-{run}.json"), m)
865    }
866
867    #[test]
868    fn has_active_running_manifest_true_for_a_lone_running_marker() {
869        assert!(has_active_running_manifest(&[running(
870            "r1",
871            "2026-01-01T00:00:00Z"
872        )]));
873    }
874
875    #[test]
876    fn has_active_running_manifest_false_when_none_is_running() {
877        // A Success manifest is not a live-run marker.
878        assert!(!has_active_running_manifest(&[keyed(
879            "k",
880            "r1",
881            "p.parquet"
882        )]));
883    }
884
885    #[test]
886    fn has_active_running_manifest_false_for_a_superseded_running_marker() {
887        // r1 crashed leaving a `running` marker; r2 (newer started_at, SAME
888        // export) already ran → r1 is stale and must NOT count as active. Same
889        // clock-free supersession the ledger uses.
890        let r1 = running("r1", "2026-01-01T00:00:00Z");
891        let mut r2 = manifest("r2", 10, Some(10)); // Success, same export ("orders")
892        r2.started_at = "2026-01-02T00:00:00Z".into();
893        assert!(!has_active_running_manifest(&[
894            r1,
895            ("base/manifest-r2.json".into(), r2)
896        ]));
897    }
898
899    #[test]
900    fn select_runs_drops_a_running_manifest() {
901        // A `running` (in-flight) manifest must NEVER be selected for load — else
902        // reconcile would refuse the whole load on a non-Success run. Incremental
903        // mode would otherwise include it (not yet loaded).
904        let run_marker = running("r_running", "2026-01-02T00:00:00Z");
905        let ok = keyed("base/manifest-r_ok.json", "r_ok", "r_ok-000.parquet"); // Success
906        let sel = select_runs(
907            vec![run_marker, ok],
908            &std::collections::HashSet::new(),
909            crate::load::plan::LoadMode::Incremental,
910        );
911        assert_eq!(sel.len(), 1, "only the Success run is selected");
912        assert_eq!(sel[0].1.run_id, "r_ok");
913        assert!(sel.iter().all(|(_, m)| m.status != ManifestStatus::Running));
914    }
915
916    #[test]
917    fn gc_orphans_removes_a_superseded_running_marker_but_spares_a_live_one() {
918        // r1 crashed leaving a `running` marker; r2 (newer, same export) is the
919        // live run. The DEAD superseded marker must be GC'd (else it accumulates
920        // forever — gc otherwise only lists `.parquet`); the LIVE one must survive
921        // (it is the active signal). Gated on supersession, so `active=true`.
922        let (store, _g) = fs_store(&[
923            ("base/manifest-r1.json", b"{}".to_vec()), // superseded running marker
924            ("base/manifest-r2.json", b"{}".to_vec()), // live running marker (newest)
925        ]);
926        let r1 = running("r1", "2026-01-01T00:00:01Z");
927        let r2 = running("r2", "2026-01-01T00:00:02Z");
928        let (removed, _) = gc_orphans(&store, "gs://b/base", &[r1, r2], true).unwrap();
929        assert_eq!(removed, 1, "only the superseded running marker is removed");
930        let left = store.list_files("base").unwrap();
931        assert!(
932            !left.iter().any(|k| k.ends_with("manifest-r1.json")),
933            "the superseded (dead) running marker is deleted"
934        );
935        assert!(
936            left.iter().any(|k| k.ends_with("manifest-r2.json")),
937            "the live (non-superseded) running marker survives — it is the active signal"
938        );
939    }
940
941    fn keyed_at(run: &str, finished_at: &str) -> (String, RunManifest) {
942        let mut m = manifest(run, 100, None);
943        m.finished_at = finished_at.into();
944        (format!("base/manifest-{run}.json"), m)
945    }
946
947    #[test]
948    fn latest_full_picks_the_newest_snapshot_not_all() {
949        // Full loads OVERWRITE, so only the LATEST snapshot may be loaded —
950        // selecting all accumulated runs would load duplicate snapshots.
951        let keyed = vec![
952            keyed_at("r1", "2026-01-01T00:00:00Z"),
953            keyed_at("r3", "2026-01-03T00:00:00Z"),
954            keyed_at("r2", "2026-01-02T00:00:00Z"),
955        ];
956        let sel = latest_full(keyed);
957        assert_eq!(sel.len(), 1, "exactly one snapshot, never all");
958        assert_eq!(sel[0].1.run_id, "r3", "the newest by finished_at");
959    }
960
961    #[test]
962    fn latest_full_re_materializes_even_when_the_latest_is_already_loaded() {
963        // Full is NOT ledger-skipped: a re-load re-OVERWRITEs from the latest
964        // snapshot, self-healing a drifted target and staying resilient to hidden
965        // in-place updates. The ledger must NOT suppress the re-materialization —
966        // full has to stay full (the old `latest_unloaded_full` skip was the bug).
967        let keyed = vec![
968            keyed_at("r1", "2026-01-01T00:00:00Z"),
969            keyed_at("r2", "2026-01-02T00:00:00Z"),
970        ];
971        // r2 is "already loaded" in the caller's ledger, yet full still selects it.
972        let sel = latest_full(keyed);
973        assert_eq!(sel.len(), 1);
974        assert_eq!(sel[0].1.run_id, "r2", "always the latest, loaded or not");
975    }
976
977    #[test]
978    fn latest_full_of_no_staged_runs_is_empty_so_the_caller_no_ops_without_truncating() {
979        // Empty staging (e.g. cleaned, no fresh extract) → empty selection → the
980        // caller returns None and never truncates the target to empty.
981        assert!(latest_full(Vec::new()).is_empty());
982    }
983
984    #[test]
985    fn latest_full_orders_by_parsed_instant_not_lexical_bytes() {
986        // `…00.500Z` is LATER than `…00Z` but sorts EARLIER lexically ('.' < 'Z').
987        // Parsing to an instant picks the truly-newest run, not the lexical max.
988        let keyed = vec![
989            keyed_at("older", "2026-01-01T00:00:00Z"),
990            keyed_at("newer", "2026-01-01T00:00:00.500Z"),
991        ];
992        let sel = latest_full(keyed);
993        assert_eq!(sel.len(), 1);
994        assert_eq!(
995            sel[0].1.run_id, "newer",
996            "the fractional-second run is the newer instant"
997        );
998    }
999
1000    #[test]
1001    fn select_runs_full_picks_the_latest_even_when_loaded_and_even_when_stateless() {
1002        use crate::load::plan::LoadMode;
1003        use std::collections::HashSet;
1004        let keyed = vec![
1005            keyed_at("r1", "2026-01-01T00:00:00Z"),
1006            keyed_at("r2", "2026-01-02T00:00:00Z"),
1007        ];
1008        // Stateful, r2 already loaded → still selects r2 (re-materialize/self-heal).
1009        let loaded = HashSet::from(["r2".to_string()]);
1010        let sel = select_runs(keyed.clone(), &loaded, LoadMode::Full);
1011        assert_eq!(sel.len(), 1);
1012        assert_eq!(sel[0].1.run_id, "r2", "Full picks latest, loaded or not");
1013        // STATELESS (empty loaded) → the latest, NEVER a blanket load of both
1014        // snapshots (the duplicate-rows bug this fix closes).
1015        let sel = select_runs(keyed, &HashSet::new(), LoadMode::Full);
1016        assert_eq!(sel.len(), 1, "stateless Full is not a blanket load");
1017        assert_eq!(sel[0].1.run_id, "r2");
1018    }
1019
1020    #[test]
1021    fn select_runs_append_modes_filter_loaded_and_load_all_when_stateless() {
1022        use crate::load::plan::LoadMode;
1023        use std::collections::HashSet;
1024        let keyed = vec![
1025            keyed_at("r1", "2026-01-01T00:00:00Z"),
1026            keyed_at("r2", "2026-01-02T00:00:00Z"),
1027        ];
1028        let loaded = HashSet::from(["r1".to_string()]);
1029        for mode in [LoadMode::Incremental, LoadMode::Cdc] {
1030            let sel = select_runs(keyed.clone(), &loaded, mode);
1031            assert_eq!(sel.len(), 1, "{mode:?}: only the unloaded run");
1032            assert_eq!(sel[0].1.run_id, "r2");
1033            // Stateless → empty loaded → load all (at-least-once, dedup absorbs).
1034            assert_eq!(
1035                select_runs(keyed.clone(), &HashSet::new(), mode).len(),
1036                2,
1037                "{mode:?}: stateless loads every run"
1038            );
1039        }
1040    }
1041
1042    #[test]
1043    fn is_run_unique_manifest_needs_both_prefix_and_json() {
1044        assert!(is_run_unique_manifest("manifest-20260101T000000.json"));
1045        assert!(!is_run_unique_manifest("manifest.json")); // no `-` after manifest
1046        assert!(!is_run_unique_manifest("manifest-abc.txt")); // not .json
1047        assert!(!is_run_unique_manifest("data.json")); // wrong prefix
1048    }
1049
1050    #[test]
1051    fn chain_prefix_renders_source_and_files() {
1052        let known = LoadIntegrity {
1053            source_rows: Some(100),
1054            file_rows: 100,
1055            manifests: 1,
1056        };
1057        assert_eq!(known.chain_prefix(), "source 100 → files 100");
1058        let unknown = LoadIntegrity {
1059            source_rows: None,
1060            file_rows: 40,
1061            manifests: 1,
1062        };
1063        assert_eq!(unknown.chain_prefix(), "source ? → files 40");
1064    }
1065
1066    #[test]
1067    fn is_manifest_key_matches_only_the_final_segment() {
1068        assert!(is_manifest_key("gs://b/p/manifest.json"));
1069        assert!(is_manifest_key("manifest.json"));
1070        assert!(!is_manifest_key("gs://b/p/part-0.parquet"));
1071        assert!(!is_manifest_key("gs://b/p/x_manifest.json"));
1072    }
1073
1074    // ---- offline (filesystem-backed store) transport tests ----
1075
1076    /// Build an fs-backed [`GcsStore`] over a fresh tempdir seeded with
1077    /// `(bucket-relative-key, bytes)` objects. Returns the store and the guard —
1078    /// hold the `TempDir` for the store's lifetime.
1079    fn fs_store(files: &[(&str, Vec<u8>)]) -> (GcsStore, tempfile::TempDir) {
1080        let dir = tempfile::tempdir().unwrap();
1081        for (rel, bytes) in files {
1082            let p = dir.path().join(rel);
1083            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1084            std::fs::write(p, bytes).unwrap();
1085        }
1086        let store = GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap();
1087        (store, dir)
1088    }
1089
1090    fn manifest_bytes(run: &str, rows: i64, source: Option<i64>) -> Vec<u8> {
1091        serde_json::to_vec(&manifest(run, rows, source)).unwrap()
1092    }
1093
1094    #[test]
1095    fn list_manifest_keys_prefers_run_unique_copies_over_the_canonical_pointer() {
1096        // A shared prefix that accumulated two runs holds the last-writer-wins
1097        // `manifest.json` pointer AND one immutable per-run copy each. Summing
1098        // must count the two run copies, never the pointer (double-count guard).
1099        let (store, _g) = fs_store(&[
1100            ("base/manifest.json", b"{}".to_vec()),
1101            ("base/manifest-r1.json", b"{}".to_vec()),
1102            ("base/manifest-r2.json", b"{}".to_vec()),
1103            ("base/part-0.parquet", b"x".to_vec()), // data file: not a manifest
1104        ]);
1105        let mut keys = list_manifest_keys(&store, "base").unwrap();
1106        keys.sort();
1107        assert_eq!(
1108            keys,
1109            vec![
1110                "base/manifest-r1.json".to_string(),
1111                "base/manifest-r2.json".to_string(),
1112            ]
1113        );
1114    }
1115
1116    #[test]
1117    fn list_manifest_keys_falls_back_to_the_canonical_name_for_a_single_run() {
1118        // A single batch run (or a legacy prefix) has only the canonical name —
1119        // with no per-run copy, it must still be found.
1120        let (store, _g) = fs_store(&[("base/manifest.json", b"{}".to_vec())]);
1121        assert_eq!(
1122            list_manifest_keys(&store, "base").unwrap(),
1123            vec!["base/manifest.json".to_string()]
1124        );
1125    }
1126
1127    #[test]
1128    fn fetch_manifests_keyed_reads_and_parses_every_run_copy_under_the_prefix() {
1129        // Two runs' copies plus a canonical pointer to the latest: fetch returns
1130        // exactly the two run copies, parsed — so reconcile sums 100 + 40 = 140
1131        // without double-counting the pointer.
1132        let (store, _g) = fs_store(&[
1133            ("base/manifest.json", manifest_bytes("r2", 40, Some(40))),
1134            (
1135                "base/manifest-r1.json",
1136                manifest_bytes("r1", 100, Some(100)),
1137            ),
1138            ("base/manifest-r2.json", manifest_bytes("r2", 40, Some(40))),
1139        ]);
1140        let manifests: Vec<_> = fetch_manifests_keyed(&store, "gs://my-bucket/base")
1141            .unwrap()
1142            .into_iter()
1143            .map(|(_, m)| m)
1144            .collect();
1145        assert_eq!(manifests.len(), 2);
1146        let integrity = reconcile(&manifests, false).unwrap();
1147        assert_eq!(integrity.file_rows, 140);
1148        assert_eq!(integrity.manifests, 2);
1149    }
1150
1151    #[test]
1152    fn fetch_manifests_keyed_names_the_key_when_a_manifest_is_unparseable() {
1153        let (store, _g) = fs_store(&[("base/manifest.json", b"{ not json".to_vec())]);
1154        let err = fetch_manifests_keyed(&store, "gs://my-bucket/base")
1155            .unwrap_err()
1156            .to_string();
1157        assert!(
1158            err.contains("parsing manifest") && err.contains("base/manifest.json"),
1159            "error should name the offending key: {err}"
1160        );
1161    }
1162
1163    // ── fake-gcs-server: the storage contract over the REAL opendal-GCS transport
1164    //
1165    // Every test above runs the storage-contract fns over an Fs-backed GcsStore —
1166    // proving the LOGIC, but not that opendal's GCS client speaks the same
1167    // list/read/remove semantics a real bucket answers with. This cell closes that
1168    // gap against fsouza/fake-gcs-server (the GCS JSON API emulator the extract
1169    // side already uses), so a GCS-only regression — listing pagination, prefix
1170    // handling, `remove_all` recursion — fails HERE, not in production. The
1171    // warehouse half (bq/snow COPY) can't be emulated; it stays the live BigQuery
1172    // matrix (`smoke_batch_mysql` / `StagingWiped`). This owns the BUCKET half.
1173    const FAKE_GCS_ENDPOINT: &str = "http://127.0.0.1:4443";
1174    const FAKE_GCS_BUCKET: &str = "rivet-load-emulator";
1175
1176    /// A real GCS-transport [`GcsStore`] against the local emulator, scoped to
1177    /// `FAKE_GCS_BUCKET`. Ensures the bucket first via the emulator's JSON API
1178    /// (fake-gcs does not auto-create on write) — an idempotent POST, so a re-run
1179    /// needs no teardown. curl, not a new HTTP dep: this is a dev-only cell.
1180    fn fake_gcs_store() -> GcsStore {
1181        let created = std::process::Command::new("curl")
1182            .args([
1183                "-s",
1184                "-X",
1185                "POST",
1186                &format!("{FAKE_GCS_ENDPOINT}/storage/v1/b?project=rivet-test"),
1187                "-H",
1188                "Content-Type: application/json",
1189                "-d",
1190                &format!("{{\"name\":\"{FAKE_GCS_BUCKET}\"}}"),
1191            ])
1192            .output();
1193        assert!(
1194            created.is_ok_and(|o| o.status.success()),
1195            "could not reach fake-gcs to create the bucket — is `docker compose up -d fake-gcs` running on :4443?"
1196        );
1197        let cfg = crate::config::DestinationConfig {
1198            destination_type: crate::config::DestinationType::Gcs,
1199            bucket: Some(FAKE_GCS_BUCKET.into()),
1200            endpoint: Some(FAKE_GCS_ENDPOINT.into()),
1201            allow_anonymous: true,
1202            ..Default::default()
1203        };
1204        GcsStore::new(&cfg).expect("build GcsStore against fake-gcs")
1205    }
1206
1207    /// Per-object drain of `prefix` — list + single `remove` each. fake-gcs
1208    /// implements single-object DELETE but NOT the GCS batch-delete endpoint that
1209    /// opendal's `remove_all` issues for 2+ objects (that 400s with `deleted: 0`),
1210    /// so the emulator can't run the `cleanup_source` batch wipe. That wipe
1211    /// (`delete_under` → `remove_all`) is verified against a REAL bucket by the
1212    /// live BigQuery `StagingWiped` matrix, and over the Fs seam above; here we
1213    /// drain per-object for idempotent setup/teardown.
1214    fn drain(store: &GcsStore, prefix: &str) {
1215        for key in store.list_files(prefix).unwrap() {
1216            store.remove(&key).unwrap();
1217        }
1218    }
1219
1220    #[test]
1221    #[ignore = "emulator: needs `docker compose up -d fake-gcs` (fsouza/fake-gcs-server :4443)"]
1222    fn storage_contract_over_fake_gcs() {
1223        let store = fake_gcs_store();
1224        // Test-owned prefix, drained first so a re-run starts clean even though the
1225        // emulator bucket persists (no cross-run bleed).
1226        let prefix = "load-contract/orders";
1227        drain(&store, prefix);
1228        let gs = format!("gs://{FAKE_GCS_BUCKET}/{prefix}");
1229
1230        // Seed one Success run (its manifest + committed part) plus a crash orphan
1231        // — an unmanifested `.parquet` an interrupted extract would leave behind.
1232        store
1233            .put(
1234                &format!("{prefix}/manifest-r1.json"),
1235                &manifest_bytes("r1", 100, Some(100)),
1236            )
1237            .unwrap();
1238        store
1239            .put(&format!("{prefix}/part-000000.parquet"), b"rows-of-r1")
1240            .unwrap();
1241        store
1242            .put(&format!("{prefix}/orphan.parquet"), b"crash-leftover")
1243            .unwrap();
1244
1245        // 1. fetch + parse the manifest back over real GCS list+read.
1246        let keyed = fetch_manifests_keyed(&store, &gs).unwrap();
1247        assert_eq!(keyed.len(), 1, "the run's manifest, read back over GCS");
1248
1249        // 2. reconcile → file_rows, the value the warehouse COUNT(*) gate enforces.
1250        let manifests: Vec<_> = keyed.iter().map(|(_, m)| m.clone()).collect();
1251        assert_eq!(
1252            reconcile(&manifests, false).unwrap().file_rows,
1253            100,
1254            "file_rows drives the count-gate; a bad GCS read would corrupt it"
1255        );
1256
1257        // 3. select_load_uris resolves the MANIFESTED part only — never the orphan.
1258        assert_eq!(
1259            select_load_uris(&store, &gs, &keyed).unwrap(),
1260            vec![format!(
1261                "gs://{FAKE_GCS_BUCKET}/{prefix}/part-000000.parquet"
1262            )],
1263            "load pulls the manifested part, not the unmanifested crash orphan"
1264        );
1265
1266        // 4. gc_orphans deletes the orphan over real GCS — a REAL single-object
1267        // DELETE against the emulator — keeping the manifested part + manifest.
1268        let (removed, _bytes) = gc_orphans(&store, &gs, &keyed, false).unwrap();
1269        assert_eq!(
1270            removed, 1,
1271            "exactly the orphan parquet is GC'd over real GCS"
1272        );
1273        let mut left = store.list_files(prefix).unwrap();
1274        left.sort();
1275        assert_eq!(
1276            left,
1277            vec![
1278                format!("{prefix}/manifest-r1.json"),
1279                format!("{prefix}/part-000000.parquet"),
1280            ],
1281            "the manifested part + its manifest survive the orphan GC"
1282        );
1283
1284        // cleanup_source's batch wipe (`remove_all`) is NOT emulatable here — see
1285        // `drain`. Teardown per-object; the empty listing confirms the deletes
1286        // landed over real GCS transport.
1287        drain(&store, prefix);
1288        assert!(
1289            store.list_files(prefix).unwrap().is_empty(),
1290            "teardown left the prefix clean over real GCS"
1291        );
1292    }
1293}