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            row_hash: None,
533            manifest_version: crate::manifest::MANIFEST_VERSION,
534            run_id: run.into(),
535            export_name: "orders".into(),
536            mode: "batch".into(),
537            started_at: "t".into(),
538            finished_at: "t".into(),
539            status: ManifestStatus::Success,
540            source: ManifestSource {
541                engine: "pg".into(),
542                schema: None,
543                table: None,
544                extraction: source.map(|n| ExtractionMetadata {
545                    strategy: "full".into(),
546                    cursor_column: None,
547                    cursor_type: None,
548                    cursor_low: None,
549                    cursor_high: None,
550                    source_row_count: Some(n),
551                }),
552            },
553            destination: ManifestDestination {
554                kind: "gcs".into(),
555                uri: "gs://b/p".into(),
556            },
557            format: "parquet".into(),
558            compression: "zstd".into(),
559            schema_fingerprint: "xxh3:0".into(),
560            row_count: rows,
561            part_count: 1,
562            parts: vec![ManifestPart {
563                part_id: 0,
564                path: "part-000000.parquet".into(),
565                rows,
566                size_bytes: 1,
567                content_fingerprint: "xxh3:0".into(),
568                content_md5: String::new(),
569                status: PartStatus::Committed,
570            }],
571            column_checksums: None,
572            checksum_key_column: None,
573        }
574    }
575
576    #[test]
577    fn sums_file_and_source_rows_across_manifests() {
578        let ms = vec![manifest("r1", 100, Some(100)), manifest("r2", 40, Some(40))];
579        let got = reconcile(&ms, false).unwrap();
580        assert_eq!(got.file_rows, 140);
581        assert_eq!(got.source_rows, Some(140));
582        assert_eq!(got.manifests, 2);
583    }
584
585    #[test]
586    fn ensure_single_export_refuses_a_prefix_shared_by_two_exports() {
587        let keyed = |m: RunManifest| ("gs://b/p/manifest-x.json".to_string(), m);
588        let orders = keyed(manifest("r1", 10, None)); // export_name "orders"
589        let mut cust_m = manifest("r2", 10, None);
590        cust_m.export_name = "customers".into();
591        // Two DISTINCT exports under one load prefix → loud refusal (else the load
592        // sums both and cleanup deletes the sibling's parts).
593        assert!(ensure_single_export(&[orders.clone(), keyed(cust_m)]).is_err());
594        // Many runs of ONE export → fine.
595        assert!(ensure_single_export(&[orders.clone(), keyed(manifest("r3", 5, None))]).is_ok());
596        // A legacy (no export_name) run mixed with the SAME named export → still
597        // one logical export, tolerated (an upgrade mid-prefix must not brick load).
598        let mut legacy = manifest("r0", 3, None);
599        legacy.export_name = String::new();
600        assert!(ensure_single_export(&[orders, keyed(legacy)]).is_ok());
601    }
602
603    #[test]
604    fn source_rows_is_none_when_no_manifest_probed_the_source() {
605        let ms = vec![manifest("r1", 100, None), manifest("r2", 40, None)];
606        let got = reconcile(&ms, false).unwrap();
607        assert_eq!(got.file_rows, 140);
608        assert_eq!(
609            got.source_rows, None,
610            "unprobed source is unknown, not zero"
611        );
612    }
613
614    #[test]
615    fn source_rows_present_even_if_only_some_manifests_probed() {
616        let ms = vec![manifest("r1", 100, Some(100)), manifest("r2", 40, None)];
617        let got = reconcile(&ms, false).unwrap();
618        assert_eq!(got.source_rows, Some(100));
619    }
620
621    #[test]
622    fn empty_manifests_refuses_to_load() {
623        let err = reconcile(&[], false).unwrap_err().to_string();
624        assert!(err.contains("refusing to load"), "{err}");
625    }
626
627    #[test]
628    fn non_success_manifest_refuses_to_load() {
629        let mut m = manifest("r1", 100, Some(100));
630        m.status = ManifestStatus::Interrupted;
631        let err = reconcile(&[m], false).unwrap_err().to_string();
632        assert!(err.contains("not Success"), "{err}");
633    }
634
635    #[test]
636    fn self_inconsistent_manifest_refuses_to_load() {
637        // row_count claims 100 but the only committed part holds 100 → make the
638        // aggregate lie by bumping the recorded row_count only.
639        let mut m = manifest("r1", 100, Some(100));
640        m.row_count = 999; // no longer equals committed parts' sum (100)
641        let err = reconcile(&[m], false).unwrap_err().to_string();
642        assert!(err.contains("inconsistent"), "{err}");
643    }
644
645    #[test]
646    fn source_file_mismatch_hard_fails_by_default() {
647        // Source had 120, only 100 extracted → 20 rows silently dropped.
648        let m = manifest("r1", 100, Some(120));
649        let err = reconcile(&[m], false).unwrap_err().to_string();
650        assert!(err.contains("source→file mismatch"), "{err}");
651        assert!(err.contains("dropped 20"), "{err}");
652    }
653
654    #[test]
655    fn source_file_mismatch_is_allowed_under_the_override() {
656        let m = manifest("r1", 100, Some(120));
657        let got = reconcile(&[m], true).expect("--allow-source-drift proceeds");
658        assert_eq!(got.file_rows, 100);
659        assert_eq!(
660            got.source_rows,
661            Some(120),
662            "the probed source count is still surfaced"
663        );
664    }
665
666    /// A `(manifest_key, manifest)` pair with a single part named `part`.
667    fn keyed(key: &str, run: &str, part: &str) -> (String, RunManifest) {
668        let mut m = manifest(run, 10, Some(10));
669        m.parts[0].path = part.into();
670        (key.to_string(), m)
671    }
672
673    #[test]
674    fn select_load_keys_picks_only_the_new_runs_parts() {
675        // Two runs' files sit under the prefix; only r2 is "new".
676        let all = vec![
677            "base/r1-000.parquet".to_string(),
678            "base/r2-000.parquet".to_string(),
679        ];
680        let new = vec![keyed("base/manifest-r2.json", "r2", "r2-000.parquet")];
681        assert_eq!(
682            select_load_keys(&new, &all),
683            vec!["base/r2-000.parquet".to_string()],
684            "loads r2's part only — not r1's already-loaded file"
685        );
686    }
687
688    #[test]
689    fn select_load_uris_lists_and_re_prefixes_selected_keys_with_the_source_bucket() {
690        // select_load_keys (the pure selection) is unit-tested throughout; this
691        // pins the store WRAPPER around it — list `.parquet` under the base, then
692        // reconstruct each `gs://<bucket>/<key>` the loader COPYs from. A mangled
693        // wrapper (empty/garbage URIs) would send the warehouse load at nothing,
694        // or the wrong object — invisible to the pure-selection tests.
695        let (store, _g) = fs_store(&[
696            ("base/r1-000.parquet", b"a".to_vec()),
697            ("base/manifest-r1.json", b"{}".to_vec()), // not .parquet — must be skipped
698        ]);
699        let new = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
700        assert_eq!(
701            select_load_uris(&store, "gs://my-bucket/base", &new).unwrap(),
702            vec!["gs://my-bucket/base/r1-000.parquet".to_string()],
703            "the selected key, re-prefixed with the source bucket — never the manifest"
704        );
705    }
706
707    #[test]
708    fn select_load_keys_resolves_a_snapshot_subprefix_manifest() {
709        // A snapshot manifest lives under `base/snapshot/`; its part is relative
710        // to that dir. Resolution must reconstruct the full key.
711        let all = vec!["base/snapshot/snap-000.parquet".to_string()];
712        let new = vec![keyed(
713            "base/snapshot/manifest-r1.json",
714            "r1",
715            "snap-000.parquet",
716        )];
717        assert_eq!(
718            select_load_keys(&new, &all),
719            vec!["base/snapshot/snap-000.parquet".to_string()]
720        );
721    }
722
723    #[test]
724    fn select_load_keys_falls_back_to_full_listing_when_a_manifest_has_no_present_part() {
725        // A manifest whose part isn't in the listing (legacy / renamed) → don't
726        // risk a partial selection; load everything under the prefix.
727        let all = vec!["base/a.parquet".to_string(), "base/b.parquet".to_string()];
728        let new = vec![keyed("base/manifest-r1.json", "r1", "missing.parquet")];
729        assert_eq!(
730            select_load_keys(&new, &all),
731            all,
732            "unresolvable part → blanket fallback"
733        );
734    }
735
736    #[test]
737    fn select_load_keys_empty_new_set_selects_nothing_never_the_full_listing() {
738        // When every run is already in the ledger the "new" set is empty. That
739        // must resolve to ZERO uris — NOT the blanket fallback, which would
740        // re-load the whole prefix on every up-to-date run (double-load). The
741        // fallback fires only for an unresolvable NON-empty manifest.
742        let all = vec![
743            "base/r1-000.parquet".to_string(),
744            "base/r2-000.parquet".to_string(),
745        ];
746        assert!(
747            select_load_keys(&[], &all).is_empty(),
748            "no new runs ⇒ load nothing, not everything"
749        );
750    }
751
752    #[test]
753    fn gc_orphans_removes_unmanifested_parquet_only() {
754        let (store, _g) = fs_store(&[
755            ("base/r1-000.parquet", b"aa".to_vec()),   // manifested
756            ("base/orphan.parquet", b"junk".to_vec()), // crash leftover — no manifest
757            ("base/manifest.json", b"{}".to_vec()),    // kept — not a .parquet
758            ("base/_SUCCESS", b"".to_vec()),           // kept — not a .parquet
759        ]);
760        let keyed = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
761        // No run is active on this prefix → the unmanifested part is dead debris.
762        let (removed, bytes) = gc_orphans(&store, "gs://b/base", &keyed, false).unwrap();
763        assert_eq!(removed, 1, "only the unmanifested part is removed");
764        assert_eq!(bytes, 4, "'junk' is 4 bytes");
765        let mut left = store.list_files("base").unwrap();
766        left.sort();
767        assert_eq!(
768            left,
769            vec![
770                "base/_SUCCESS".to_string(),
771                "base/manifest.json".to_string(),
772                "base/r1-000.parquet".to_string(),
773            ],
774            "the manifested part, the manifest, and _SUCCESS all survive"
775        );
776    }
777
778    #[test]
779    fn gc_orphans_of_an_all_manifested_prefix_removes_nothing() {
780        let (store, _g) = fs_store(&[("base/r1-000.parquet", b"a".to_vec())]);
781        let keyed = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
782        assert_eq!(
783            gc_orphans(&store, "gs://b/base", &keyed, false).unwrap().0,
784            0
785        );
786    }
787
788    #[test]
789    fn gc_orphans_keeps_a_snapshot_subprefix_manifested_part() {
790        let (store, _g) = fs_store(&[
791            ("base/snapshot/s-000.parquet", b"a".to_vec()), // manifested under snapshot/
792            ("base/orphan.parquet", b"x".to_vec()),         // top-level orphan
793        ]);
794        let keyed = vec![keyed("base/snapshot/manifest-s.json", "s", "s-000.parquet")];
795        let (removed, _) = gc_orphans(&store, "gs://b/base", &keyed, false).unwrap();
796        assert_eq!(removed, 1, "the top-level orphan goes");
797        assert_eq!(
798            store.list_files("base/snapshot").unwrap(),
799            vec!["base/snapshot/s-000.parquet".to_string()],
800            "the snapshot-subprefix manifested part is kept"
801        );
802    }
803
804    #[test]
805    fn gc_orphans_deletes_a_terminal_runs_parts_even_while_a_run_is_active() {
806        // A Failed/Interrupted run's parts are terminal crash debris — deleted
807        // regardless of `active`, since no LIVE run is streaming THEM. Passing
808        // active=true proves the `active` gate applies only to UNmanifested parts.
809        let (store, _g) = fs_store(&[("base/f-000.parquet", b"x".to_vec())]);
810        let mut kv = keyed("base/manifest-f.json", "f", "f-000.parquet");
811        kv.1.status = ManifestStatus::Failed;
812        assert_eq!(gc_orphans(&store, "gs://b/base", &[kv], true).unwrap().0, 1);
813    }
814
815    #[test]
816    fn gc_orphans_spares_an_unmanifested_part_while_a_run_is_active() {
817        // The concurrent-extract guard. A part with NO manifest, while a run is
818        // ACTIVE on this prefix (the run-status ledger says so), is probably that
819        // run's committed-but-not-yet-manifested write — deleting it would be
820        // silent data loss on the live extract. RED against a mutant that ignores
821        // `active` (deletes every non-Success-manifested `.parquet`).
822        let (store, _g) = fs_store(&[
823            ("base/r1-000.parquet", b"aa".to_vec()),  // manifested (kept)
824            ("base/inflight.parquet", b"x".to_vec()), // unmanifested, a live run's part
825        ]);
826        let keyed = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
827        let (removed, _) = gc_orphans(&store, "gs://b/base", &keyed, true).unwrap();
828        assert_eq!(
829            removed, 0,
830            "an unmanifested part is spared while a run is active"
831        );
832        assert!(
833            store
834                .list_files("base")
835                .unwrap()
836                .iter()
837                .any(|k| k.ends_with("inflight.parquet")),
838            "the live run's in-flight part must survive gc_orphans"
839        );
840    }
841
842    #[test]
843    fn gc_orphans_collects_an_unmanifested_part_when_no_run_is_active() {
844        // The other half: with NO run active, an unmanifested part is a dead
845        // crash orphan and IS collected — the gate DEFERS cleanup, never abandons.
846        let (store, _g) = fs_store(&[
847            ("base/r1-000.parquet", b"aa".to_vec()),
848            ("base/dead-orphan.parquet", b"x".to_vec()),
849        ]);
850        let keyed = vec![keyed("base/manifest-r1.json", "r1", "r1-000.parquet")];
851        assert_eq!(
852            gc_orphans(&store, "gs://b/base", &keyed, false).unwrap().0,
853            1,
854            "with no active run, an unmanifested orphan is collected"
855        );
856    }
857
858    /// A `running` MARKER manifest (schema-less, no parts) started at `started_at`.
859    fn running(run: &str, started_at: &str) -> (String, RunManifest) {
860        let mut m = manifest(run, 0, None);
861        m.status = ManifestStatus::Running;
862        m.started_at = started_at.into();
863        m.finished_at = String::new();
864        m.parts.clear();
865        (format!("base/manifest-{run}.json"), m)
866    }
867
868    #[test]
869    fn has_active_running_manifest_true_for_a_lone_running_marker() {
870        assert!(has_active_running_manifest(&[running(
871            "r1",
872            "2026-01-01T00:00:00Z"
873        )]));
874    }
875
876    #[test]
877    fn has_active_running_manifest_false_when_none_is_running() {
878        // A Success manifest is not a live-run marker.
879        assert!(!has_active_running_manifest(&[keyed(
880            "k",
881            "r1",
882            "p.parquet"
883        )]));
884    }
885
886    #[test]
887    fn has_active_running_manifest_false_for_a_superseded_running_marker() {
888        // r1 crashed leaving a `running` marker; r2 (newer started_at, SAME
889        // export) already ran → r1 is stale and must NOT count as active. Same
890        // clock-free supersession the ledger uses.
891        let r1 = running("r1", "2026-01-01T00:00:00Z");
892        let mut r2 = manifest("r2", 10, Some(10)); // Success, same export ("orders")
893        r2.started_at = "2026-01-02T00:00:00Z".into();
894        assert!(!has_active_running_manifest(&[
895            r1,
896            ("base/manifest-r2.json".into(), r2)
897        ]));
898    }
899
900    #[test]
901    fn select_runs_drops_a_running_manifest() {
902        // A `running` (in-flight) manifest must NEVER be selected for load — else
903        // reconcile would refuse the whole load on a non-Success run. Incremental
904        // mode would otherwise include it (not yet loaded).
905        let run_marker = running("r_running", "2026-01-02T00:00:00Z");
906        let ok = keyed("base/manifest-r_ok.json", "r_ok", "r_ok-000.parquet"); // Success
907        let sel = select_runs(
908            vec![run_marker, ok],
909            &std::collections::HashSet::new(),
910            crate::load::plan::LoadMode::Incremental,
911        );
912        assert_eq!(sel.len(), 1, "only the Success run is selected");
913        assert_eq!(sel[0].1.run_id, "r_ok");
914        assert!(sel.iter().all(|(_, m)| m.status != ManifestStatus::Running));
915    }
916
917    #[test]
918    fn gc_orphans_removes_a_superseded_running_marker_but_spares_a_live_one() {
919        // r1 crashed leaving a `running` marker; r2 (newer, same export) is the
920        // live run. The DEAD superseded marker must be GC'd (else it accumulates
921        // forever — gc otherwise only lists `.parquet`); the LIVE one must survive
922        // (it is the active signal). Gated on supersession, so `active=true`.
923        let (store, _g) = fs_store(&[
924            ("base/manifest-r1.json", b"{}".to_vec()), // superseded running marker
925            ("base/manifest-r2.json", b"{}".to_vec()), // live running marker (newest)
926        ]);
927        let r1 = running("r1", "2026-01-01T00:00:01Z");
928        let r2 = running("r2", "2026-01-01T00:00:02Z");
929        let (removed, _) = gc_orphans(&store, "gs://b/base", &[r1, r2], true).unwrap();
930        assert_eq!(removed, 1, "only the superseded running marker is removed");
931        let left = store.list_files("base").unwrap();
932        assert!(
933            !left.iter().any(|k| k.ends_with("manifest-r1.json")),
934            "the superseded (dead) running marker is deleted"
935        );
936        assert!(
937            left.iter().any(|k| k.ends_with("manifest-r2.json")),
938            "the live (non-superseded) running marker survives — it is the active signal"
939        );
940    }
941
942    fn keyed_at(run: &str, finished_at: &str) -> (String, RunManifest) {
943        let mut m = manifest(run, 100, None);
944        m.finished_at = finished_at.into();
945        (format!("base/manifest-{run}.json"), m)
946    }
947
948    #[test]
949    fn latest_full_picks_the_newest_snapshot_not_all() {
950        // Full loads OVERWRITE, so only the LATEST snapshot may be loaded —
951        // selecting all accumulated runs would load duplicate snapshots.
952        let keyed = vec![
953            keyed_at("r1", "2026-01-01T00:00:00Z"),
954            keyed_at("r3", "2026-01-03T00:00:00Z"),
955            keyed_at("r2", "2026-01-02T00:00:00Z"),
956        ];
957        let sel = latest_full(keyed);
958        assert_eq!(sel.len(), 1, "exactly one snapshot, never all");
959        assert_eq!(sel[0].1.run_id, "r3", "the newest by finished_at");
960    }
961
962    #[test]
963    fn latest_full_re_materializes_even_when_the_latest_is_already_loaded() {
964        // Full is NOT ledger-skipped: a re-load re-OVERWRITEs from the latest
965        // snapshot, self-healing a drifted target and staying resilient to hidden
966        // in-place updates. The ledger must NOT suppress the re-materialization —
967        // full has to stay full (the old `latest_unloaded_full` skip was the bug).
968        let keyed = vec![
969            keyed_at("r1", "2026-01-01T00:00:00Z"),
970            keyed_at("r2", "2026-01-02T00:00:00Z"),
971        ];
972        // r2 is "already loaded" in the caller's ledger, yet full still selects it.
973        let sel = latest_full(keyed);
974        assert_eq!(sel.len(), 1);
975        assert_eq!(sel[0].1.run_id, "r2", "always the latest, loaded or not");
976    }
977
978    #[test]
979    fn latest_full_of_no_staged_runs_is_empty_so_the_caller_no_ops_without_truncating() {
980        // Empty staging (e.g. cleaned, no fresh extract) → empty selection → the
981        // caller returns None and never truncates the target to empty.
982        assert!(latest_full(Vec::new()).is_empty());
983    }
984
985    #[test]
986    fn latest_full_orders_by_parsed_instant_not_lexical_bytes() {
987        // `…00.500Z` is LATER than `…00Z` but sorts EARLIER lexically ('.' < 'Z').
988        // Parsing to an instant picks the truly-newest run, not the lexical max.
989        let keyed = vec![
990            keyed_at("older", "2026-01-01T00:00:00Z"),
991            keyed_at("newer", "2026-01-01T00:00:00.500Z"),
992        ];
993        let sel = latest_full(keyed);
994        assert_eq!(sel.len(), 1);
995        assert_eq!(
996            sel[0].1.run_id, "newer",
997            "the fractional-second run is the newer instant"
998        );
999    }
1000
1001    #[test]
1002    fn select_runs_full_picks_the_latest_even_when_loaded_and_even_when_stateless() {
1003        use crate::load::plan::LoadMode;
1004        use std::collections::HashSet;
1005        let keyed = vec![
1006            keyed_at("r1", "2026-01-01T00:00:00Z"),
1007            keyed_at("r2", "2026-01-02T00:00:00Z"),
1008        ];
1009        // Stateful, r2 already loaded → still selects r2 (re-materialize/self-heal).
1010        let loaded = HashSet::from(["r2".to_string()]);
1011        let sel = select_runs(keyed.clone(), &loaded, LoadMode::Full);
1012        assert_eq!(sel.len(), 1);
1013        assert_eq!(sel[0].1.run_id, "r2", "Full picks latest, loaded or not");
1014        // STATELESS (empty loaded) → the latest, NEVER a blanket load of both
1015        // snapshots (the duplicate-rows bug this fix closes).
1016        let sel = select_runs(keyed, &HashSet::new(), LoadMode::Full);
1017        assert_eq!(sel.len(), 1, "stateless Full is not a blanket load");
1018        assert_eq!(sel[0].1.run_id, "r2");
1019    }
1020
1021    #[test]
1022    fn select_runs_append_modes_filter_loaded_and_load_all_when_stateless() {
1023        use crate::load::plan::LoadMode;
1024        use std::collections::HashSet;
1025        let keyed = vec![
1026            keyed_at("r1", "2026-01-01T00:00:00Z"),
1027            keyed_at("r2", "2026-01-02T00:00:00Z"),
1028        ];
1029        let loaded = HashSet::from(["r1".to_string()]);
1030        for mode in [LoadMode::Incremental, LoadMode::Cdc] {
1031            let sel = select_runs(keyed.clone(), &loaded, mode);
1032            assert_eq!(sel.len(), 1, "{mode:?}: only the unloaded run");
1033            assert_eq!(sel[0].1.run_id, "r2");
1034            // Stateless → empty loaded → load all (at-least-once, dedup absorbs).
1035            assert_eq!(
1036                select_runs(keyed.clone(), &HashSet::new(), mode).len(),
1037                2,
1038                "{mode:?}: stateless loads every run"
1039            );
1040        }
1041    }
1042
1043    #[test]
1044    fn is_run_unique_manifest_needs_both_prefix_and_json() {
1045        assert!(is_run_unique_manifest("manifest-20260101T000000.json"));
1046        assert!(!is_run_unique_manifest("manifest.json")); // no `-` after manifest
1047        assert!(!is_run_unique_manifest("manifest-abc.txt")); // not .json
1048        assert!(!is_run_unique_manifest("data.json")); // wrong prefix
1049    }
1050
1051    #[test]
1052    fn chain_prefix_renders_source_and_files() {
1053        let known = LoadIntegrity {
1054            source_rows: Some(100),
1055            file_rows: 100,
1056            manifests: 1,
1057        };
1058        assert_eq!(known.chain_prefix(), "source 100 → files 100");
1059        let unknown = LoadIntegrity {
1060            source_rows: None,
1061            file_rows: 40,
1062            manifests: 1,
1063        };
1064        assert_eq!(unknown.chain_prefix(), "source ? → files 40");
1065    }
1066
1067    #[test]
1068    fn is_manifest_key_matches_only_the_final_segment() {
1069        assert!(is_manifest_key("gs://b/p/manifest.json"));
1070        assert!(is_manifest_key("manifest.json"));
1071        assert!(!is_manifest_key("gs://b/p/part-0.parquet"));
1072        assert!(!is_manifest_key("gs://b/p/x_manifest.json"));
1073    }
1074
1075    // ---- offline (filesystem-backed store) transport tests ----
1076
1077    /// Build an fs-backed [`GcsStore`] over a fresh tempdir seeded with
1078    /// `(bucket-relative-key, bytes)` objects. Returns the store and the guard —
1079    /// hold the `TempDir` for the store's lifetime.
1080    fn fs_store(files: &[(&str, Vec<u8>)]) -> (GcsStore, tempfile::TempDir) {
1081        let dir = tempfile::tempdir().unwrap();
1082        for (rel, bytes) in files {
1083            let p = dir.path().join(rel);
1084            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1085            std::fs::write(p, bytes).unwrap();
1086        }
1087        let store = GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap();
1088        (store, dir)
1089    }
1090
1091    fn manifest_bytes(run: &str, rows: i64, source: Option<i64>) -> Vec<u8> {
1092        serde_json::to_vec(&manifest(run, rows, source)).unwrap()
1093    }
1094
1095    #[test]
1096    fn list_manifest_keys_prefers_run_unique_copies_over_the_canonical_pointer() {
1097        // A shared prefix that accumulated two runs holds the last-writer-wins
1098        // `manifest.json` pointer AND one immutable per-run copy each. Summing
1099        // must count the two run copies, never the pointer (double-count guard).
1100        let (store, _g) = fs_store(&[
1101            ("base/manifest.json", b"{}".to_vec()),
1102            ("base/manifest-r1.json", b"{}".to_vec()),
1103            ("base/manifest-r2.json", b"{}".to_vec()),
1104            ("base/part-0.parquet", b"x".to_vec()), // data file: not a manifest
1105        ]);
1106        let mut keys = list_manifest_keys(&store, "base").unwrap();
1107        keys.sort();
1108        assert_eq!(
1109            keys,
1110            vec![
1111                "base/manifest-r1.json".to_string(),
1112                "base/manifest-r2.json".to_string(),
1113            ]
1114        );
1115    }
1116
1117    #[test]
1118    fn list_manifest_keys_falls_back_to_the_canonical_name_for_a_single_run() {
1119        // A single batch run (or a legacy prefix) has only the canonical name —
1120        // with no per-run copy, it must still be found.
1121        let (store, _g) = fs_store(&[("base/manifest.json", b"{}".to_vec())]);
1122        assert_eq!(
1123            list_manifest_keys(&store, "base").unwrap(),
1124            vec!["base/manifest.json".to_string()]
1125        );
1126    }
1127
1128    #[test]
1129    fn fetch_manifests_keyed_reads_and_parses_every_run_copy_under_the_prefix() {
1130        // Two runs' copies plus a canonical pointer to the latest: fetch returns
1131        // exactly the two run copies, parsed — so reconcile sums 100 + 40 = 140
1132        // without double-counting the pointer.
1133        let (store, _g) = fs_store(&[
1134            ("base/manifest.json", manifest_bytes("r2", 40, Some(40))),
1135            (
1136                "base/manifest-r1.json",
1137                manifest_bytes("r1", 100, Some(100)),
1138            ),
1139            ("base/manifest-r2.json", manifest_bytes("r2", 40, Some(40))),
1140        ]);
1141        let manifests: Vec<_> = fetch_manifests_keyed(&store, "gs://my-bucket/base")
1142            .unwrap()
1143            .into_iter()
1144            .map(|(_, m)| m)
1145            .collect();
1146        assert_eq!(manifests.len(), 2);
1147        let integrity = reconcile(&manifests, false).unwrap();
1148        assert_eq!(integrity.file_rows, 140);
1149        assert_eq!(integrity.manifests, 2);
1150    }
1151
1152    #[test]
1153    fn fetch_manifests_keyed_names_the_key_when_a_manifest_is_unparseable() {
1154        let (store, _g) = fs_store(&[("base/manifest.json", b"{ not json".to_vec())]);
1155        let err = fetch_manifests_keyed(&store, "gs://my-bucket/base")
1156            .unwrap_err()
1157            .to_string();
1158        assert!(
1159            err.contains("parsing manifest") && err.contains("base/manifest.json"),
1160            "error should name the offending key: {err}"
1161        );
1162    }
1163
1164    // ── fake-gcs-server: the storage contract over the REAL opendal-GCS transport
1165    //
1166    // Every test above runs the storage-contract fns over an Fs-backed GcsStore —
1167    // proving the LOGIC, but not that opendal's GCS client speaks the same
1168    // list/read/remove semantics a real bucket answers with. This cell closes that
1169    // gap against fsouza/fake-gcs-server (the GCS JSON API emulator the extract
1170    // side already uses), so a GCS-only regression — listing pagination, prefix
1171    // handling, `remove_all` recursion — fails HERE, not in production. The
1172    // warehouse half (bq/snow COPY) can't be emulated; it stays the live BigQuery
1173    // matrix (`smoke_batch_mysql` / `StagingWiped`). This owns the BUCKET half.
1174    const FAKE_GCS_ENDPOINT: &str = "http://127.0.0.1:4443";
1175    const FAKE_GCS_BUCKET: &str = "rivet-load-emulator";
1176
1177    /// A real GCS-transport [`GcsStore`] against the local emulator, scoped to
1178    /// `FAKE_GCS_BUCKET`. Ensures the bucket first via the emulator's JSON API
1179    /// (fake-gcs does not auto-create on write) — an idempotent POST, so a re-run
1180    /// needs no teardown. curl, not a new HTTP dep: this is a dev-only cell.
1181    fn fake_gcs_store() -> GcsStore {
1182        let created = std::process::Command::new("curl")
1183            .args([
1184                "-s",
1185                "-X",
1186                "POST",
1187                &format!("{FAKE_GCS_ENDPOINT}/storage/v1/b?project=rivet-test"),
1188                "-H",
1189                "Content-Type: application/json",
1190                "-d",
1191                &format!("{{\"name\":\"{FAKE_GCS_BUCKET}\"}}"),
1192            ])
1193            .output();
1194        assert!(
1195            created.is_ok_and(|o| o.status.success()),
1196            "could not reach fake-gcs to create the bucket — is `docker compose up -d fake-gcs` running on :4443?"
1197        );
1198        let cfg = crate::config::DestinationConfig {
1199            destination_type: crate::config::DestinationType::Gcs,
1200            bucket: Some(FAKE_GCS_BUCKET.into()),
1201            endpoint: Some(FAKE_GCS_ENDPOINT.into()),
1202            allow_anonymous: true,
1203            ..Default::default()
1204        };
1205        GcsStore::new(&cfg).expect("build GcsStore against fake-gcs")
1206    }
1207
1208    /// Per-object drain of `prefix` — list + single `remove` each. fake-gcs
1209    /// implements single-object DELETE but NOT the GCS batch-delete endpoint that
1210    /// opendal's `remove_all` issues for 2+ objects (that 400s with `deleted: 0`),
1211    /// so the emulator can't run the `cleanup_source` batch wipe. That wipe
1212    /// (`delete_under` → `remove_all`) is verified against a REAL bucket by the
1213    /// live BigQuery `StagingWiped` matrix, and over the Fs seam above; here we
1214    /// drain per-object for idempotent setup/teardown.
1215    fn drain(store: &GcsStore, prefix: &str) {
1216        for key in store.list_files(prefix).unwrap() {
1217            store.remove(&key).unwrap();
1218        }
1219    }
1220
1221    #[test]
1222    #[ignore = "emulator: needs `docker compose up -d fake-gcs` (fsouza/fake-gcs-server :4443)"]
1223    fn storage_contract_over_fake_gcs() {
1224        let store = fake_gcs_store();
1225        // Test-owned prefix, drained first so a re-run starts clean even though the
1226        // emulator bucket persists (no cross-run bleed).
1227        let prefix = "load-contract/orders";
1228        drain(&store, prefix);
1229        let gs = format!("gs://{FAKE_GCS_BUCKET}/{prefix}");
1230
1231        // Seed one Success run (its manifest + committed part) plus a crash orphan
1232        // — an unmanifested `.parquet` an interrupted extract would leave behind.
1233        store
1234            .put(
1235                &format!("{prefix}/manifest-r1.json"),
1236                &manifest_bytes("r1", 100, Some(100)),
1237            )
1238            .unwrap();
1239        store
1240            .put(&format!("{prefix}/part-000000.parquet"), b"rows-of-r1")
1241            .unwrap();
1242        store
1243            .put(&format!("{prefix}/orphan.parquet"), b"crash-leftover")
1244            .unwrap();
1245
1246        // 1. fetch + parse the manifest back over real GCS list+read.
1247        let keyed = fetch_manifests_keyed(&store, &gs).unwrap();
1248        assert_eq!(keyed.len(), 1, "the run's manifest, read back over GCS");
1249
1250        // 2. reconcile → file_rows, the value the warehouse COUNT(*) gate enforces.
1251        let manifests: Vec<_> = keyed.iter().map(|(_, m)| m.clone()).collect();
1252        assert_eq!(
1253            reconcile(&manifests, false).unwrap().file_rows,
1254            100,
1255            "file_rows drives the count-gate; a bad GCS read would corrupt it"
1256        );
1257
1258        // 3. select_load_uris resolves the MANIFESTED part only — never the orphan.
1259        assert_eq!(
1260            select_load_uris(&store, &gs, &keyed).unwrap(),
1261            vec![format!(
1262                "gs://{FAKE_GCS_BUCKET}/{prefix}/part-000000.parquet"
1263            )],
1264            "load pulls the manifested part, not the unmanifested crash orphan"
1265        );
1266
1267        // 4. gc_orphans deletes the orphan over real GCS — a REAL single-object
1268        // DELETE against the emulator — keeping the manifested part + manifest.
1269        let (removed, _bytes) = gc_orphans(&store, &gs, &keyed, false).unwrap();
1270        assert_eq!(
1271            removed, 1,
1272            "exactly the orphan parquet is GC'd over real GCS"
1273        );
1274        let mut left = store.list_files(prefix).unwrap();
1275        left.sort();
1276        assert_eq!(
1277            left,
1278            vec![
1279                format!("{prefix}/manifest-r1.json"),
1280                format!("{prefix}/part-000000.parquet"),
1281            ],
1282            "the manifested part + its manifest survive the orphan GC"
1283        );
1284
1285        // cleanup_source's batch wipe (`remove_all`) is NOT emulatable here — see
1286        // `drain`. Teardown per-object; the empty listing confirms the deletes
1287        // landed over real GCS transport.
1288        drain(&store, prefix);
1289        assert!(
1290            store.list_files(prefix).unwrap().is_empty(),
1291            "teardown left the prefix clean over real GCS"
1292        );
1293    }
1294}