Skip to main content

boatramp_node/
blob_migrate.rs

1//! Offline, node-local **blob-backend migration** — the copy engine behind
2//! `boatramp blob migrate --from <config> --to <config>` (Part 1 of the
3//! blob-backend-migration feature).
4//!
5//! Switching the node blob backend (`--blobs fs|s3|gcs|azure`, or provider→provider,
6//! or region→region) points serving at an EMPTY store, so every site on the node 404s
7//! until each project is re-applied. This engine copies the existing objects from a
8//! source backend to a destination backend so the switch has no re-upload step.
9//!
10//! It operates over the existing [`Storage`] trait primitives — `list`/`head`/`get`/`put`
11//! — so it works uniformly across every backend (fs, S3, GCS, Azure). The CLI builds the
12//! two backends from two node config files via [`build_blobs`](crate::blobs::build_blobs)
13//! and calls [`migrate`]; the engine itself is backend-agnostic (it takes two
14//! `Arc<dyn Storage>`), which is exactly what lets the mutation gate drive it fs→fs.
15//!
16//! ## Guarantees
17//! - **Read-only on the source.** The engine NEVER deletes (or writes) the source. The
18//!   operator flips `--blobs`/config after verifying and retires the old store separately.
19//! - **Key fidelity.** A destination key is byte-for-byte the source key (no prefix
20//!   mangling that could collapse a tenant/`hblob` boundary). The `content_type` is
21//!   preserved from the source metadata.
22//! - **Idempotent / resumable.** A `head`-present destination object of matching size is
23//!   skipped, so a re-run after an interruption is a near-no-op.
24//! - **Completeness (verified).** With `--verify` (default on) every source key is
25//!   confirmed `head`-present in the destination after the copy; any missing key is a
26//!   hard error (non-zero exit).
27
28use std::sync::Arc;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::time::{Duration, Instant};
31
32use boatramp_core::{ByteStream, PutMeta, Storage, StorageError};
33use futures::StreamExt;
34
35/// The gate-mutation seams for the Part-1 `BLOB MIGRATE COMPLETE OK` battery. Compiled ONLY
36/// under the `blob-migrate-gate-mutation` feature (the CI gate lane). Each
37/// `BOATRAMP_BLOBMIG_MUTATE_*` env var makes the engine behave like a specific broken
38/// implementation, so the CI gate proves each invariant is load-bearing (every mutation MUST
39/// fail the gate). Mirrors the #505 `s3_credential::gate_mutation` seam exactly.
40#[cfg(feature = "blob-migrate-gate-mutation")]
41pub(crate) mod gate_mutation {
42    /// Whether a mutation env var is set (non-empty and not `0`).
43    pub(crate) fn env_on(name: &str) -> bool {
44        std::env::var(name)
45            .map(|v| !v.is_empty() && v != "0")
46            .unwrap_or(false)
47    }
48}
49
50/// Options controlling a [`migrate`] run.
51#[derive(Debug, Clone)]
52pub struct MigrateOptions {
53    /// Bounded worker concurrency for the copy loop (≥ 1; clamped up to 1).
54    pub concurrency: usize,
55    /// Verify (default on) that every source object is `head`-present in the destination
56    /// after the copy; any missing key is an error.
57    pub verify: bool,
58    /// Enumerate + classify (would-copy / would-skip) but copy nothing.
59    pub dry_run: bool,
60    /// Restrict the enumeration to keys under this prefix (default `""` = all).
61    pub prefix: String,
62}
63
64impl Default for MigrateOptions {
65    fn default() -> Self {
66        Self {
67            concurrency: 8,
68            verify: true,
69            dry_run: false,
70            prefix: String::new(),
71        }
72    }
73}
74
75/// A summary of a completed [`migrate`] run.
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
77pub struct MigrateReport {
78    /// Total source objects enumerated under the prefix.
79    pub total_objects: u64,
80    /// Objects copied source→dest (or, on `--dry-run`, that WOULD be copied).
81    pub copied_objects: u64,
82    /// Objects skipped because the destination already had the key at matching size
83    /// (or, on `--dry-run`, that WOULD be skipped).
84    pub skipped_objects: u64,
85    /// Total bytes copied (0 on `--dry-run`; sums the source-reported sizes on a real copy).
86    pub copied_bytes: u64,
87    /// Whether verification ran (`opts.verify` and not a dry-run).
88    pub verified: bool,
89}
90
91/// A failure running the migration.
92#[derive(Debug, thiserror::Error)]
93pub enum MigrateError {
94    /// Enumerating / reading / writing an object failed.
95    #[error("blob storage: {0}")]
96    Storage(#[from] StorageError),
97    /// Post-copy verification found source objects absent from the destination. The list is
98    /// capped ([`VERIFY_REPORT_CAP`]) so a large miss set does not flood the terminal.
99    #[error(
100        "verification FAILED: {missing} source object(s) absent from the destination \
101         (first {shown} shown): {keys:?}"
102    )]
103    VerifyMissing {
104        /// How many source keys were found missing in the destination.
105        missing: usize,
106        /// How many of them are listed in `keys` (capped).
107        shown: usize,
108        /// The (capped) list of missing keys.
109        keys: Vec<String>,
110    },
111}
112
113/// How many missing keys a [`MigrateError::VerifyMissing`] lists before truncating.
114pub const VERIFY_REPORT_CAP: usize = 50;
115
116/// One classified object after the destination `head` check.
117enum PlanItem {
118    /// The destination lacks the key (or has it at a different size) — it must be copied.
119    Copy {
120        key: String,
121        size: u64,
122        content_type: Option<String>,
123    },
124    /// The destination already has the key at matching size — skip (idempotent/resumable).
125    Skip,
126}
127
128/// The outcome of processing one source object.
129struct ItemOutcome {
130    copied: bool,
131    bytes: u64,
132}
133
134/// Copy every object under `opts.prefix` from `source` to `dest`, skipping any already
135/// present at matching size, then (when `opts.verify`) confirm every source key is present
136/// in the destination.
137///
138/// The source is treated as read-only — the engine never deletes or writes it. On success
139/// it returns a [`MigrateReport`]; a storage error or a failed verification returns
140/// [`MigrateError`] (the CLI maps this to a non-zero exit).
141pub async fn migrate(
142    source: Arc<dyn Storage>,
143    dest: Arc<dyn Storage>,
144    opts: &MigrateOptions,
145) -> Result<MigrateReport, MigrateError> {
146    let concurrency = opts.concurrency.max(1);
147
148    // 1. Enumerate the source. `list` is not paginated at the trait — each backend flattens
149    //    internally (fs = recursive read_dir; s3/gcs/azure = native paged ListObjects loop).
150    // `mut` is consumed only by the DROP_LAST gate seam below; a non-gate build never mutates it.
151    #[cfg_attr(not(feature = "blob-migrate-gate-mutation"), allow(unused_mut))]
152    let mut objects = source.list(&opts.prefix).await?;
153    // The reported total counts the FULL source enumeration — even under the DROP_LAST mutation,
154    // so `total_objects` still reflects the true source set (verification re-enumerates the source
155    // independently and finds the dropped object absent in the dest).
156    let total_objects = objects.len() as u64;
157
158    // GATE MUTATION SEAM (I1 completeness): drop the LAST source object from the COPY set so it is
159    // never written to the destination. Verification independently re-enumerates the source and
160    // `head`s every key in the dest, so the dropped key is found absent ⇒ I1 FAIL.
161    #[cfg(feature = "blob-migrate-gate-mutation")]
162    if gate_mutation::env_on("BOATRAMP_BLOBMIG_MUTATE_DROP_LAST") {
163        // A deterministic "last" — sort so the dropped key is stable regardless of list order.
164        objects.sort_by(|a, b| a.key.cmp(&b.key));
165        objects.pop();
166    }
167    tracing::info!(
168        total = total_objects,
169        prefix = %opts.prefix,
170        dry_run = opts.dry_run,
171        concurrency,
172        "blob migrate: enumerated source objects"
173    );
174
175    // Shared progress counters (updated from the concurrent workers).
176    let copied_objects = Arc::new(AtomicU64::new(0));
177    let skipped_objects = Arc::new(AtomicU64::new(0));
178    let copied_bytes = Arc::new(AtomicU64::new(0));
179    let done_objects = Arc::new(AtomicU64::new(0));
180    let progress = Arc::new(std::sync::Mutex::new(Progress::new(total_objects)));
181
182    // 2. Bounded-concurrency copy. `buffer_unordered(N)` runs at most `N` per-object futures
183    //    at once, and yields each `Result` so a storage error short-circuits the whole run.
184    let results: Vec<Result<ItemOutcome, MigrateError>> = futures::stream::iter(objects)
185        .map(|meta| {
186            let source = source.clone();
187            let dest = dest.clone();
188            let dry_run = opts.dry_run;
189            async move { copy_one(&source, &dest, meta, dry_run).await }
190        })
191        .buffer_unordered(concurrency)
192        .map(|outcome| {
193            // Fold each completed object into the shared counters + periodic progress line.
194            if let Ok(ref o) = outcome {
195                if o.copied {
196                    copied_objects.fetch_add(1, Ordering::Relaxed);
197                    copied_bytes.fetch_add(o.bytes, Ordering::Relaxed);
198                } else {
199                    skipped_objects.fetch_add(1, Ordering::Relaxed);
200                }
201            }
202            let done = done_objects.fetch_add(1, Ordering::Relaxed) + 1;
203            let (c, s, b) = (
204                copied_objects.load(Ordering::Relaxed),
205                skipped_objects.load(Ordering::Relaxed),
206                copied_bytes.load(Ordering::Relaxed),
207            );
208            if let Ok(mut p) = progress.lock() {
209                p.maybe_log(done, c, s, b, opts.dry_run);
210            }
211            outcome
212        })
213        .collect()
214        .await;
215
216    // Surface the first storage error (if any) — a copy failure aborts before verification.
217    for r in results {
218        r?;
219    }
220
221    let copied_objects = copied_objects.load(Ordering::Relaxed);
222    let skipped_objects = skipped_objects.load(Ordering::Relaxed);
223    let copied_bytes = copied_bytes.load(Ordering::Relaxed);
224
225    // A final summary line (always), independent of the periodic cadence.
226    tracing::info!(
227        total = total_objects,
228        copied = copied_objects,
229        skipped = skipped_objects,
230        copied_bytes,
231        dry_run = opts.dry_run,
232        "blob migrate: copy phase complete"
233    );
234
235    // 3. Verify (default on; skipped on a dry-run — nothing was written to confirm). Re-enumerate
236    //    the SOURCE and `head` each key in the DESTINATION; collect any that are absent. This is
237    //    the completeness invariant (I1) + the key-fidelity invariant (I3, since a mangled dest
238    //    key would leave the true source key absent).
239    let mut verified = false;
240    if opts.verify && !opts.dry_run {
241        let missing = verify(&source, &dest, &opts.prefix, concurrency).await?;
242        if !missing.is_empty() {
243            let shown = missing.len().min(VERIFY_REPORT_CAP);
244            return Err(MigrateError::VerifyMissing {
245                missing: missing.len(),
246                shown,
247                keys: missing.into_iter().take(VERIFY_REPORT_CAP).collect(),
248            });
249        }
250        verified = true;
251        tracing::info!(
252            objects = total_objects,
253            "VERIFY OK: all source objects present in destination"
254        );
255    }
256
257    Ok(MigrateReport {
258        total_objects,
259        copied_objects,
260        skipped_objects,
261        copied_bytes,
262        verified,
263    })
264}
265
266/// `head` the destination for `key`; classify whether it must be copied. Under the
267/// `SKIP_ALWAYS` mutation, unconditionally report "skip" (a broken head-check that would leave
268/// a not-present key missing — breaks I1/I2).
269async fn plan_one(
270    dest: &Arc<dyn Storage>,
271    key: &str,
272    source_size: u64,
273    content_type: Option<String>,
274) -> Result<PlanItem, MigrateError> {
275    // GATE MUTATION SEAM (I2 head-skip soundness): skip unconditionally, even when the dest does
276    // NOT have the key. The gate then finds that key absent in verification ⇒ I1 FAIL.
277    #[cfg(feature = "blob-migrate-gate-mutation")]
278    if gate_mutation::env_on("BOATRAMP_BLOBMIG_MUTATE_SKIP_ALWAYS") {
279        return Ok(PlanItem::Skip);
280    }
281
282    match dest.head(key).await {
283        // Present at matching size ⇒ idempotent skip.
284        Ok(meta) if meta.size == Some(source_size) => Ok(PlanItem::Skip),
285        // Present at a different size, or absent ⇒ (re)copy.
286        Ok(_) => Ok(PlanItem::Copy {
287            key: key.to_string(),
288            size: source_size,
289            content_type,
290        }),
291        Err(StorageError::NotFound(_)) => Ok(PlanItem::Copy {
292            key: key.to_string(),
293            size: source_size,
294            content_type,
295        }),
296        // A transient/backend error is NOT a miss — propagate it (never a silent copy-or-skip).
297        Err(e) => Err(MigrateError::Storage(e)),
298    }
299}
300
301/// Process one source object: head-check the destination, then (unless dry-run or skipped)
302/// stream the source body into the destination preserving the key + content_type.
303async fn copy_one(
304    source: &Arc<dyn Storage>,
305    dest: &Arc<dyn Storage>,
306    meta: boatramp_core::ObjectMeta,
307    dry_run: bool,
308) -> Result<ItemOutcome, MigrateError> {
309    let source_size = meta.size.unwrap_or(0);
310    let plan = plan_one(dest, &meta.key, source_size, meta.content_type.clone()).await?;
311    match plan {
312        PlanItem::Skip => Ok(ItemOutcome {
313            copied: false,
314            bytes: 0,
315        }),
316        PlanItem::Copy {
317            key,
318            size,
319            content_type,
320        } => {
321            if dry_run {
322                // Classify only — never get/put on a dry-run.
323                return Ok(ItemOutcome {
324                    copied: true,
325                    bytes: 0,
326                });
327            }
328            // Stream the source body straight into the destination — no full-object buffering.
329            // The source `get` yields the authoritative content_type; fall back to the list meta.
330            let got = source.get(&key).await?;
331            let ct = got.meta.content_type.or(content_type);
332            let dest_key = dest_key_for(&key);
333            let body: ByteStream = got.body;
334            let written = dest
335                .put(&dest_key, body, PutMeta { content_type: ct })
336                .await?;
337
338            // GATE MUTATION SEAM (I4 source read-only): delete from the SOURCE after copying — the
339            // exact "never delete from source" regression. The gate asserts the source object set is
340            // unchanged, so this must fail it.
341            #[cfg(feature = "blob-migrate-gate-mutation")]
342            if gate_mutation::env_on("BOATRAMP_BLOBMIG_MUTATE_DELETE_SOURCE") {
343                source.delete(&key).await?;
344            }
345
346            Ok(ItemOutcome {
347                copied: true,
348                bytes: written.size.unwrap_or(size),
349            })
350        }
351    }
352}
353
354/// The destination key for a source key. Identity — a destination key is byte-for-byte the
355/// source key (I3 key fidelity). Under the `REWRITE_KEY` mutation, prepend a mangled segment so
356/// the true source key is never written to the destination (verification then finds it absent).
357fn dest_key_for(source_key: &str) -> String {
358    // GATE MUTATION SEAM (I3 key fidelity): mangle the destination key. Verification `head`s the
359    // ORIGINAL source key in the destination and finds it absent ⇒ FAIL. In a real build this is a
360    // straight identity copy.
361    #[cfg(feature = "blob-migrate-gate-mutation")]
362    if gate_mutation::env_on("BOATRAMP_BLOBMIG_MUTATE_REWRITE_KEY") {
363        return format!("MANGLED/{source_key}");
364    }
365    source_key.to_string()
366}
367
368/// Confirm every source key under `prefix` is `head`-present in the destination; return the keys
369/// that are absent (empty ⇒ complete). Bounded-concurrency `head` probes.
370async fn verify(
371    source: &Arc<dyn Storage>,
372    dest: &Arc<dyn Storage>,
373    prefix: &str,
374    concurrency: usize,
375) -> Result<Vec<String>, MigrateError> {
376    // Re-enumerate the SOURCE independently of the copy set. This is what makes the DROP_LAST
377    // mutation observable: the copy loop dropped a key, but verification still lists it here and
378    // finds it absent in the destination.
379    let source_keys = source.list(prefix).await?;
380
381    let checks: Vec<Result<Option<String>, MigrateError>> = futures::stream::iter(source_keys)
382        .map(|meta| {
383            let dest = dest.clone();
384            async move {
385                match dest.head(&meta.key).await {
386                    Ok(_) => Ok(None),
387                    Err(StorageError::NotFound(_)) => Ok(Some(meta.key)),
388                    Err(e) => Err(MigrateError::Storage(e)),
389                }
390            }
391        })
392        .buffer_unordered(concurrency)
393        .collect()
394        .await;
395
396    let mut missing = Vec::new();
397    for c in checks {
398        if let Some(key) = c? {
399            missing.push(key);
400        }
401    }
402    missing.sort();
403    Ok(missing)
404}
405
406/// Periodic progress logger — a line roughly every [`PROGRESS_INTERVAL`] or every
407/// [`PROGRESS_EVERY_N`] objects, whichever comes first.
408struct Progress {
409    total: u64,
410    last: Instant,
411}
412
413const PROGRESS_INTERVAL: Duration = Duration::from_secs(2);
414const PROGRESS_EVERY_N: u64 = 500;
415
416impl Progress {
417    fn new(total: u64) -> Self {
418        Self {
419            total,
420            last: Instant::now(),
421        }
422    }
423
424    fn maybe_log(&mut self, done: u64, copied: u64, skipped: u64, bytes: u64, dry_run: bool) {
425        let elapsed = self.last.elapsed();
426        if elapsed >= PROGRESS_INTERVAL
427            || done.is_multiple_of(PROGRESS_EVERY_N)
428            || done == self.total
429        {
430            self.last = Instant::now();
431            tracing::info!(
432                done,
433                total = self.total,
434                copied,
435                skipped,
436                copied_bytes = bytes,
437                dry_run,
438                "blob migrate: progress"
439            );
440        }
441    }
442}
443
444// ================================================================================================
445// The Part-1 mutation-verified gate battery: `BLOB MIGRATE COMPLETE OK`.
446//
447// One `#[tokio::test]` runs every invariant fs→fs in a tempdir (no cloud), then prints the marker.
448// Compiled ONLY under the `blob-migrate-gate-mutation` feature (the CI gate lane); each
449// `BOATRAMP_BLOBMIG_MUTATE_*` env var neuters exactly ONE invariant (via the seams above), so a
450// clean run reaches the marker while every mutation PANICS before it — proving each check is
451// load-bearing. Mirrors the #505 `s3_credential::gate` structure exactly. See the ci.yml gate step.
452// ================================================================================================
453#[cfg(all(test, feature = "blob-migrate-gate-mutation"))]
454mod gate {
455    use super::*;
456    use boatramp_storage::FsStorage;
457
458    /// A representative slice of the ONE node blob keyspace: an immutable content-addressed deploy
459    /// blob (`{2hex}/{64hex}`), a mutable control-plane manifest record, and a mutable guest object
460    /// (`hblob/{qualified-site}/{container}/{key}`). Migrating must move all three faithfully.
461    fn seed_keys() -> Vec<(&'static str, &'static [u8])> {
462        vec![
463            (
464                "ab/0000000000000000000000000000000000000000000000000000000000000000",
465                b"content-addressed-immutable-blob",
466            ),
467            ("manifests/site-alpha", b"{\"deployment\":\"d1\"}"),
468            ("hblob/proj~site/uploads/report.json", b"{\"ok\":true}"),
469        ]
470    }
471
472    async fn put(store: &Arc<dyn Storage>, key: &str, bytes: &[u8]) {
473        let owned = bytes.to_vec();
474        let body: ByteStream =
475            futures::stream::once(async move { Ok(bytes::Bytes::from(owned)) }).boxed();
476        store
477            .put(key, body, PutMeta::default())
478            .await
479            .expect("seed put");
480    }
481
482    /// Build a fresh fs source backend seeded with the representative keyspace, plus an empty fs
483    /// destination. Two distinct tempdir roots so source + dest never alias.
484    async fn seeded_backends(
485        tmp: &std::path::Path,
486    ) -> (Arc<dyn Storage>, Arc<dyn Storage>, Vec<String>) {
487        let source: Arc<dyn Storage> = Arc::new(FsStorage::new(tmp.join("src")));
488        let dest: Arc<dyn Storage> = Arc::new(FsStorage::new(tmp.join("dst")));
489        let mut keys = Vec::new();
490        for (k, v) in seed_keys() {
491            put(&source, k, v).await;
492            keys.push(k.to_string());
493        }
494        keys.sort();
495        (source, dest, keys)
496    }
497
498    /// The sorted set of keys currently present in a backend (list `""`).
499    async fn keys_of(store: &Arc<dyn Storage>) -> Vec<String> {
500        let mut ks: Vec<String> = store
501            .list("")
502            .await
503            .expect("list")
504            .into_iter()
505            .map(|m| m.key)
506            .collect();
507        ks.sort();
508        ks
509    }
510
511    /// Invariant 1 (completeness): after migrate, EVERY source key is `head`-present in the dest.
512    async fn invariant_1_completeness(dest: &Arc<dyn Storage>, source_keys: &[String]) {
513        for key in source_keys {
514            dest.head(key).await.unwrap_or_else(|_| {
515                panic!("I1 completeness: source key {key:?} is absent from the destination")
516            });
517        }
518    }
519
520    /// Invariant 2 (head-skip soundness): a skip only happens when the dest already holds the key at
521    /// matching size. We assert it by proving a SECOND run (dest now fully populated) copies nothing
522    /// AND still leaves every key present — i.e. the skip decision never dropped a key. The
523    /// SKIP_ALWAYS mutation makes the FIRST run skip a not-present key, which I1 catches.
524    async fn invariant_2_head_skip_sound(
525        source: &Arc<dyn Storage>,
526        dest: &Arc<dyn Storage>,
527        source_keys: &[String],
528    ) {
529        let report = migrate(source.clone(), dest.clone(), &MigrateOptions::default())
530            .await
531            .expect("second migrate (idempotent) succeeds");
532        assert_eq!(
533            report.copied_objects, 0,
534            "I2: a re-run over a fully-populated destination must copy nothing (all head-skipped)"
535        );
536        assert_eq!(
537            report.skipped_objects,
538            source_keys.len() as u64,
539            "I2: every object must be head-skipped on the idempotent re-run"
540        );
541        // And the skip must not have dropped anything.
542        invariant_1_completeness(dest, source_keys).await;
543    }
544
545    /// Invariant 3 (key fidelity): the dest key set equals the source key set, byte-exact. The
546    /// REWRITE_KEY mutation prepends `MANGLED/`, so the dest keys diverge from the source keys.
547    async fn invariant_3_key_fidelity(source: &Arc<dyn Storage>, dest: &Arc<dyn Storage>) {
548        let src = keys_of(source).await;
549        let dst = keys_of(dest).await;
550        assert_eq!(
551            src, dst,
552            "I3 key fidelity: destination keys must equal source keys byte-exact (no mangling)"
553        );
554    }
555
556    /// Invariant 4 (source read-only): the source key set is unchanged after migrate. The
557    /// DELETE_SOURCE mutation deletes each copied object from the source.
558    async fn invariant_4_source_read_only(source: &Arc<dyn Storage>, before: &[String]) {
559        let after = keys_of(source).await;
560        assert_eq!(
561            before,
562            after.as_slice(),
563            "I4 source read-only: the source object set must be unchanged after migrate"
564        );
565    }
566
567    #[tokio::test]
568    async fn blob_migrate_complete_gate() {
569        let tmp = tempfile::tempdir().expect("tempdir");
570        let (source, dest, source_keys) = seeded_backends(tmp.path()).await;
571        let before_source = keys_of(&source).await;
572        assert_eq!(before_source, source_keys, "sanity: seeded source keys");
573
574        // The real migration under test (verify ON) — a mutation makes it break an invariant below.
575        let report = migrate(source.clone(), dest.clone(), &MigrateOptions::default())
576            .await
577            .expect("migrate succeeds on a clean run");
578        assert_eq!(report.total_objects, source_keys.len() as u64);
579        assert!(report.verified, "verify must have run + passed");
580
581        invariant_1_completeness(&dest, &source_keys).await;
582        // I4 before I3: DELETE_SOURCE empties the source, so check "source unchanged" against its own
583        // invariant before the source-vs-dest key-set comparison (both catch it — panic ⇒ marker
584        // absent — but this attributes it to the load-bearing check).
585        invariant_4_source_read_only(&source, &before_source).await;
586        invariant_3_key_fidelity(&source, &dest).await;
587        invariant_2_head_skip_sound(&source, &dest, &source_keys).await;
588
589        // Reached only on a clean, fully-passing run — a mutation env var panics one invariant above
590        // (DROP_LAST/SKIP_ALWAYS/REWRITE_KEY via I1/I3; DELETE_SOURCE via I4).
591        println!(
592            "BLOB MIGRATE COMPLETE OK: the offline blob-backend migration copies every source \
593             object to the destination (completeness), skips only a matching-size head-present key \
594             (idempotent/resumable), preserves keys byte-exact (no tenant/hblob-boundary collapse), \
595             and never mutates the source (read-only). Mutation-verified: \
596             BOATRAMP_BLOBMIG_MUTATE_{{DROP_LAST,SKIP_ALWAYS,REWRITE_KEY,DELETE_SOURCE}}=1 each FAIL \
597             this gate."
598        );
599    }
600}