Skip to main content

hashtree_cli/storage/
maintenance.rs

1use anyhow::Result;
2use heed::{CompactionOption, EnvOpenOptions};
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6use crate::managed_env::ManagedEnv;
7#[cfg(feature = "s3")]
8use std::sync::Arc;
9#[cfg(feature = "s3")]
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12use super::{GcStats, HashtreeStore};
13
14#[cfg(feature = "s3")]
15use futures::{stream::FuturesUnordered, StreamExt};
16#[cfg(feature = "s3")]
17use hashtree_core::from_hex;
18use hashtree_core::{sha256, to_hex};
19use serde::{Deserialize, Serialize};
20
21/// Result of blob integrity verification
22#[derive(Debug, Clone)]
23pub struct VerifyResult {
24    pub total: usize,
25    pub valid: usize,
26    pub corrupted: usize,
27    pub deleted: usize,
28}
29
30#[derive(Debug, Clone)]
31pub struct CompactResult {
32    pub env_dir: PathBuf,
33    pub before_bytes: u64,
34    pub after_bytes: u64,
35}
36
37#[derive(Debug, Clone, Default)]
38pub struct R2ImportOptions {
39    pub concurrency: usize,
40    pub check_only: bool,
41    pub resume: bool,
42    pub fast_list: bool,
43    pub stream_merge: bool,
44    pub keys: Vec<String>,
45    pub keys_file: Option<PathBuf>,
46    pub start_after: Option<String>,
47    pub scan_prefix: Option<String>,
48    pub state_file: Option<PathBuf>,
49    pub max_objects: Option<usize>,
50    pub progress_every: usize,
51    pub scan_delay_ms: u64,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct R2ImportResult {
56    pub listed: usize,
57    pub skipped: usize,
58    pub missing: usize,
59    pub imported: usize,
60    pub corrupted: usize,
61    pub failed: usize,
62    pub bytes_imported: u64,
63    pub last_key: Option<String>,
64    pub completed: bool,
65}
66
67#[cfg(feature = "s3")]
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69struct R2ImportState {
70    #[serde(flatten)]
71    result: R2ImportResult,
72    updated_at_unix: u64,
73}
74
75#[cfg(feature = "s3")]
76#[derive(Debug, Clone)]
77struct R2ObjectCandidate {
78    key: String,
79    hash: hashtree_core::types::Hash,
80}
81
82#[cfg(feature = "s3")]
83#[derive(Debug, Clone, Default)]
84struct R2ObjectImportOutcome {
85    skipped: bool,
86    missing: bool,
87    imported: bool,
88    corrupted: bool,
89    failed: bool,
90    bytes_imported: u64,
91    message: Option<String>,
92}
93
94#[cfg(feature = "s3")]
95const R2_IMPORT_OBJECT_READ_ATTEMPTS: usize = 4;
96#[cfg(feature = "s3")]
97const R2_IMPORT_OBJECT_RETRY_BASE_DELAY_MS: u64 = 250;
98
99const COMPACT_MAX_DBS: u32 = 64;
100const COMPACT_MAX_READERS: u32 = 2048;
101const COMPACT_OPEN_MAP_SIZE_BYTES: usize = 10 * 1024 * 1024;
102const COMPACT_PAGE_SIZE_BYTES: u64 = 4096;
103
104#[cfg(feature = "s3")]
105fn unix_timestamp_now() -> u64 {
106    SystemTime::now()
107        .duration_since(UNIX_EPOCH)
108        .unwrap_or_default()
109        .as_secs()
110}
111
112#[cfg(feature = "s3")]
113fn r2_import_key_hash(prefix: &str, key: &str) -> Option<hashtree_core::types::Hash> {
114    let filename = key.strip_prefix(prefix).unwrap_or(key);
115    let hash_hex = filename.strip_suffix(".bin")?;
116    if hash_hex.contains('/') {
117        return None;
118    }
119    if hash_hex.len() != 64 {
120        return None;
121    }
122    from_hex(hash_hex).ok()
123}
124
125#[cfg(feature = "s3")]
126fn r2_import_key_candidate(prefix: &str, input: &str) -> Option<R2ObjectCandidate> {
127    let input = input.trim();
128    if input.is_empty() {
129        return None;
130    }
131
132    let key = if input.len() == 64 && input.chars().all(|ch| ch.is_ascii_hexdigit()) {
133        format!("{prefix}{input}.bin")
134    } else if !prefix.is_empty() && !input.starts_with(prefix) && !input.contains('/') {
135        format!("{prefix}{input}")
136    } else {
137        input.to_string()
138    };
139
140    let hash = r2_import_key_hash(prefix, &key)?;
141    Some(R2ObjectCandidate { key, hash })
142}
143
144#[cfg(feature = "s3")]
145fn existing_r2_candidates(
146    local: &super::LocalStore,
147    candidates: &[R2ObjectCandidate],
148) -> Result<Vec<bool>> {
149    let mut indexed_hashes: Vec<(usize, hashtree_core::types::Hash)> = candidates
150        .iter()
151        .enumerate()
152        .map(|(index, candidate)| (index, candidate.hash))
153        .collect();
154    indexed_hashes.sort_unstable_by(|left, right| left.1.cmp(&right.1).then(left.0.cmp(&right.0)));
155
156    let sorted_hashes: Vec<hashtree_core::types::Hash> =
157        indexed_hashes.iter().map(|(_, hash)| *hash).collect();
158    let sorted_existing = local
159        .existing_hashes_in_sorted_candidates(&sorted_hashes)
160        .map_err(|err| anyhow::anyhow!("Failed to compare local hashes: {err}"))?;
161
162    let mut existing = vec![false; candidates.len()];
163    for ((candidate_index, _), exists) in indexed_hashes.into_iter().zip(sorted_existing) {
164        existing[candidate_index] = exists;
165    }
166    Ok(existing)
167}
168
169#[cfg(feature = "s3")]
170fn read_r2_import_keys_file(path: &Path) -> Result<Vec<String>> {
171    let raw = std::fs::read_to_string(path)?;
172    Ok(raw
173        .lines()
174        .map(str::trim)
175        .filter(|line| !line.is_empty() && !line.starts_with('#'))
176        .map(ToOwned::to_owned)
177        .collect())
178}
179
180#[cfg(feature = "s3")]
181fn read_r2_import_state(path: &Path) -> Option<R2ImportState> {
182    let raw = std::fs::read_to_string(path).ok()?;
183    serde_json::from_str(&raw).ok()
184}
185
186#[cfg(feature = "s3")]
187async fn fetch_r2_object_body_with_retries(
188    client: &aws_sdk_s3::Client,
189    bucket: &str,
190    key: &str,
191) -> Result<Vec<u8>, String> {
192    let mut last_error = None;
193    for attempt in 1..=R2_IMPORT_OBJECT_READ_ATTEMPTS {
194        let output = match client.get_object().bucket(bucket).key(key).send().await {
195            Ok(output) => output,
196            Err(err) => {
197                last_error = Some(format!("fetch failed for {key}: {err}"));
198                if attempt < R2_IMPORT_OBJECT_READ_ATTEMPTS {
199                    let delay_ms = R2_IMPORT_OBJECT_RETRY_BASE_DELAY_MS << (attempt - 1);
200                    tokio::time::sleep(Duration::from_millis(delay_ms)).await;
201                }
202                continue;
203            }
204        };
205
206        match output.body.collect().await {
207            Ok(body) => return Ok(body.into_bytes().to_vec()),
208            Err(err) => {
209                last_error = Some(format!("read failed for {key}: {err}"));
210                if attempt < R2_IMPORT_OBJECT_READ_ATTEMPTS {
211                    let delay_ms = R2_IMPORT_OBJECT_RETRY_BASE_DELAY_MS << (attempt - 1);
212                    tokio::time::sleep(Duration::from_millis(delay_ms)).await;
213                }
214            }
215        }
216    }
217
218    Err(format!(
219        "{} after {} attempt(s)",
220        last_error.unwrap_or_else(|| format!("fetch failed for {key}: unknown error")),
221        R2_IMPORT_OBJECT_READ_ATTEMPTS
222    ))
223}
224
225#[cfg(feature = "s3")]
226fn write_r2_import_state(path: &Path, result: &R2ImportResult) -> Result<()> {
227    if let Some(parent) = path.parent() {
228        std::fs::create_dir_all(parent)?;
229    }
230    let state = R2ImportState {
231        result: result.clone(),
232        updated_at_unix: unix_timestamp_now(),
233    };
234    std::fs::write(path, serde_json::to_vec_pretty(&state)?)?;
235    Ok(())
236}
237
238#[cfg(feature = "s3")]
239async fn import_r2_object_to_local(
240    client: Arc<aws_sdk_s3::Client>,
241    bucket: Arc<String>,
242    local: Arc<super::LocalStore>,
243    candidate: R2ObjectCandidate,
244    check_only: bool,
245    prechecked_missing: bool,
246) -> R2ObjectImportOutcome {
247    if !prechecked_missing {
248        match local.exists(&candidate.hash) {
249            Ok(true) => {
250                return R2ObjectImportOutcome {
251                    skipped: true,
252                    ..Default::default()
253                };
254            }
255            Ok(false) => {}
256            Err(err) => {
257                return R2ObjectImportOutcome {
258                    failed: true,
259                    message: Some(format!("local exists failed for {}: {err}", candidate.key)),
260                    ..Default::default()
261                };
262            }
263        }
264    }
265
266    if check_only {
267        return R2ObjectImportOutcome {
268            missing: true,
269            ..Default::default()
270        };
271    }
272
273    let body =
274        match fetch_r2_object_body_with_retries(client.as_ref(), bucket.as_str(), &candidate.key)
275            .await
276        {
277            Ok(body) => body,
278            Err(err) => {
279                return R2ObjectImportOutcome {
280                    missing: true,
281                    failed: true,
282                    message: Some(err),
283                    ..Default::default()
284                };
285            }
286        };
287    let data = body.as_slice();
288    let actual_hash = sha256(data);
289    if actual_hash != candidate.hash {
290        return R2ObjectImportOutcome {
291            missing: true,
292            corrupted: true,
293            message: Some(format!(
294                "hash mismatch for {}: actual {}",
295                candidate.key,
296                to_hex(&actual_hash)
297            )),
298            ..Default::default()
299        };
300    }
301
302    match local.put_sync(candidate.hash, data) {
303        Ok(inserted) => R2ObjectImportOutcome {
304            missing: true,
305            imported: inserted,
306            skipped: !inserted,
307            bytes_imported: if inserted { data.len() as u64 } else { 0 },
308            ..Default::default()
309        },
310        Err(err) => R2ObjectImportOutcome {
311            missing: true,
312            failed: true,
313            message: Some(format!("local put failed for {}: {err}", candidate.key)),
314            ..Default::default()
315        },
316    }
317}
318
319#[cfg(feature = "s3")]
320async fn settle_one_r2_import(
321    pending: &mut FuturesUnordered<impl std::future::Future<Output = R2ObjectImportOutcome>>,
322    result: &mut R2ImportResult,
323) {
324    if let Some(outcome) = pending.next().await {
325        if outcome.skipped {
326            result.skipped += 1;
327        }
328        if outcome.missing {
329            result.missing += 1;
330        }
331        if outcome.imported {
332            result.imported += 1;
333            result.bytes_imported = result.bytes_imported.saturating_add(outcome.bytes_imported);
334        }
335        if outcome.corrupted {
336            result.corrupted += 1;
337        }
338        if outcome.failed {
339            result.failed += 1;
340        }
341        if let Some(message) = outcome.message {
342            println!("  {message}");
343        }
344    }
345}
346
347impl HashtreeStore {
348    /// Garbage collect unpinned content
349    pub fn gc(&self) -> Result<GcStats> {
350        let rtxn = self.env.read_txn()?;
351
352        // Get all pinned hashes as raw bytes
353        let pinned: HashSet<[u8; 32]> = self
354            .pins
355            .iter(&rtxn)?
356            .filter_map(|item| item.ok())
357            .filter_map(|(hash_bytes, _)| {
358                if hash_bytes.len() == 32 {
359                    let mut hash = [0u8; 32];
360                    hash.copy_from_slice(hash_bytes);
361                    Some(hash)
362                } else {
363                    None
364                }
365            })
366            .collect();
367
368        drop(rtxn);
369
370        // Get hashes in the canonical writable store.
371        let all_hashes = self
372            .router
373            .list_writable()
374            .map_err(|e| anyhow::anyhow!("Failed to list writable hashes: {}", e))?;
375
376        // Delete unpinned hashes
377        let mut deleted = 0;
378        let mut freed_bytes = 0u64;
379
380        for hash in all_hashes {
381            if !pinned.contains(&hash) {
382                if let Ok(Some(size)) = self.router.blob_size_sync(&hash) {
383                    freed_bytes += size;
384                    // Delete locally only - keep S3 as archive
385                    let _ = self.router.delete_local_only(&hash);
386                    deleted += 1;
387                }
388            }
389        }
390
391        Ok(GcStats {
392            deleted_dags: deleted,
393            freed_bytes,
394        })
395    }
396
397    /// Verify LMDB blob integrity - checks that stored data matches its key hash
398    /// Returns verification statistics and optionally deletes corrupted entries
399    pub fn verify_lmdb_integrity(&self, delete: bool) -> Result<VerifyResult> {
400        let all_hashes = self
401            .router
402            .list()
403            .map_err(|e| anyhow::anyhow!("Failed to list hashes: {}", e))?;
404
405        let total = all_hashes.len();
406        let mut valid = 0;
407        let mut corrupted = 0;
408        let mut deleted = 0;
409        let mut corrupted_hashes = Vec::new();
410
411        for hash in &all_hashes {
412            let hash_hex = to_hex(hash);
413
414            match self.router.get_sync(hash) {
415                Ok(Some(data)) => {
416                    let actual_hash = sha256(&data);
417
418                    if actual_hash == *hash {
419                        valid += 1;
420                    } else {
421                        corrupted += 1;
422                        let actual_hex = to_hex(&actual_hash);
423                        println!(
424                            "  CORRUPTED: key={} actual={} size={}",
425                            &hash_hex[..16],
426                            &actual_hex[..16],
427                            data.len()
428                        );
429                        corrupted_hashes.push(*hash);
430                    }
431                }
432                Ok(None) => {
433                    corrupted += 1;
434                    println!("  MISSING: key={}", &hash_hex[..16]);
435                    corrupted_hashes.push(*hash);
436                }
437                Err(e) => {
438                    corrupted += 1;
439                    println!("  ERROR: key={} err={}", &hash_hex[..16], e);
440                    corrupted_hashes.push(*hash);
441                }
442            }
443        }
444
445        if delete {
446            for hash in &corrupted_hashes {
447                match self.router.delete_sync(hash) {
448                    Ok(true) => deleted += 1,
449                    Ok(false) => {}
450                    Err(e) => {
451                        let hash_hex = to_hex(hash);
452                        println!("  Failed to delete {}: {}", &hash_hex[..16], e);
453                    }
454                }
455            }
456        }
457
458        Ok(VerifyResult {
459            total,
460            valid,
461            corrupted,
462            deleted,
463        })
464    }
465
466    /// Verify R2/S3 blob integrity - lists all objects and verifies hash matches filename
467    /// Returns verification statistics and optionally deletes corrupted entries
468    #[cfg(feature = "s3")]
469    pub async fn verify_r2_integrity(&self, delete: bool) -> Result<VerifyResult> {
470        use aws_sdk_s3::Client as S3Client;
471
472        let config = crate::config::Config::load()?;
473        let s3_config = config
474            .storage
475            .s3
476            .ok_or_else(|| anyhow::anyhow!("S3 not configured"))?;
477
478        let aws_config = aws_config::from_env()
479            .region(aws_sdk_s3::config::Region::new(s3_config.region.clone()))
480            .load()
481            .await;
482
483        let s3_client = S3Client::from_conf(
484            aws_sdk_s3::config::Builder::from(&aws_config)
485                .endpoint_url(&s3_config.endpoint)
486                .force_path_style(true)
487                .build(),
488        );
489
490        let bucket = &s3_config.bucket;
491        let prefix = s3_config.prefix.as_deref().unwrap_or("");
492
493        let mut total = 0;
494        let mut valid = 0;
495        let mut corrupted = 0;
496        let mut deleted = 0;
497        let mut corrupted_keys = Vec::new();
498
499        let mut continuation_token: Option<String> = None;
500
501        loop {
502            let mut list_req = s3_client.list_objects_v2().bucket(bucket).prefix(prefix);
503
504            if let Some(ref token) = continuation_token {
505                list_req = list_req.continuation_token(token);
506            }
507
508            let list_resp = list_req
509                .send()
510                .await
511                .map_err(|e| anyhow::anyhow!("Failed to list S3 objects: {}", e))?;
512
513            for object in list_resp.contents() {
514                let key = object.key().unwrap_or("");
515
516                if !key.ends_with(".bin") {
517                    continue;
518                }
519
520                total += 1;
521
522                let filename = key.strip_prefix(prefix).unwrap_or(key);
523                let expected_hash_hex = filename.strip_suffix(".bin").unwrap_or(filename);
524
525                if expected_hash_hex.len() != 64 {
526                    corrupted += 1;
527                    println!("  INVALID KEY: {}", key);
528                    corrupted_keys.push(key.to_string());
529                    continue;
530                }
531
532                let expected_hash = match from_hex(expected_hash_hex) {
533                    Ok(h) => h,
534                    Err(_) => {
535                        corrupted += 1;
536                        println!("  INVALID HEX: {}", key);
537                        corrupted_keys.push(key.to_string());
538                        continue;
539                    }
540                };
541
542                match s3_client.get_object().bucket(bucket).key(key).send().await {
543                    Ok(resp) => match resp.body.collect().await {
544                        Ok(bytes) => {
545                            let data = bytes.into_bytes();
546                            let actual_hash = sha256(&data);
547
548                            if actual_hash == expected_hash {
549                                valid += 1;
550                            } else {
551                                corrupted += 1;
552                                let actual_hex = to_hex(&actual_hash);
553                                println!(
554                                    "  CORRUPTED: key={} actual={} size={}",
555                                    &expected_hash_hex[..16],
556                                    &actual_hex[..16],
557                                    data.len()
558                                );
559                                corrupted_keys.push(key.to_string());
560                            }
561                        }
562                        Err(e) => {
563                            corrupted += 1;
564                            println!("  READ ERROR: {} - {}", key, e);
565                            corrupted_keys.push(key.to_string());
566                        }
567                    },
568                    Err(e) => {
569                        corrupted += 1;
570                        println!("  FETCH ERROR: {} - {}", key, e);
571                        corrupted_keys.push(key.to_string());
572                    }
573                }
574
575                if total % 100 == 0 {
576                    println!(
577                        "  Progress: {} objects checked, {} corrupted so far",
578                        total, corrupted
579                    );
580                }
581            }
582
583            if list_resp.is_truncated() == Some(true) {
584                continuation_token = list_resp.next_continuation_token().map(|s| s.to_string());
585            } else {
586                break;
587            }
588        }
589
590        if delete {
591            for key in &corrupted_keys {
592                match s3_client
593                    .delete_object()
594                    .bucket(bucket)
595                    .key(key)
596                    .send()
597                    .await
598                {
599                    Ok(_) => deleted += 1,
600                    Err(e) => {
601                        println!("  Failed to delete {}: {}", key, e);
602                    }
603                }
604            }
605        }
606
607        Ok(VerifyResult {
608            total,
609            valid,
610            corrupted,
611            deleted,
612        })
613    }
614
615    /// Import missing R2/S3 blobs into local storage without writing back to S3.
616    ///
617    /// This mirrors rclone's shape: list the source, compare each source object
618    /// against the destination by cheap metadata (here the content-addressed key),
619    /// and only transfer missing objects. `--check-only` runs the same comparison
620    /// without downloading object bodies.
621    #[cfg(feature = "s3")]
622    pub async fn import_r2_to_local(&self, options: R2ImportOptions) -> Result<R2ImportResult> {
623        use aws_sdk_s3::Client as S3Client;
624
625        let config = crate::config::Config::load()?;
626        let s3_config = config
627            .storage
628            .s3
629            .ok_or_else(|| anyhow::anyhow!("S3 not configured"))?;
630
631        let aws_config = aws_config::from_env()
632            .region(aws_sdk_s3::config::Region::new(s3_config.region.clone()))
633            .load()
634            .await;
635
636        let s3_client = S3Client::from_conf(
637            aws_sdk_s3::config::Builder::from(&aws_config)
638                .endpoint_url(&s3_config.endpoint)
639                .force_path_style(true)
640                .build(),
641        );
642
643        let bucket = Arc::new(s3_config.bucket);
644        let prefix = s3_config.prefix.unwrap_or_default();
645        let list_prefix = options
646            .scan_prefix
647            .as_ref()
648            .map(|scan_prefix| format!("{prefix}{scan_prefix}"))
649            .unwrap_or_else(|| prefix.clone());
650        let mut explicit_keys = options.keys.clone();
651        if let Some(keys_file) = options.keys_file.as_ref() {
652            explicit_keys.extend(read_r2_import_keys_file(keys_file)?);
653        }
654
655        let local = self.router.local_store();
656        let client = Arc::new(s3_client);
657        let concurrency = options.concurrency.max(1);
658        let mut pending = FuturesUnordered::new();
659
660        if !explicit_keys.is_empty() {
661            let mut result = R2ImportResult {
662                completed: false,
663                ..Default::default()
664            };
665
666            println!(
667                "R2 import {} targeted: bucket={}, prefix={}, requested_keys={}, state_file={}",
668                if options.check_only { "check" } else { "sync" },
669                bucket.as_str(),
670                prefix,
671                explicit_keys.len(),
672                options
673                    .state_file
674                    .as_ref()
675                    .map(|path| path.display().to_string())
676                    .unwrap_or_else(|| "<none>".to_string()),
677            );
678            for key in explicit_keys {
679                let Some(candidate) = r2_import_key_candidate(&prefix, &key) else {
680                    result.failed += 1;
681                    println!("  invalid R2 blob key/hash: {key}");
682                    continue;
683                };
684
685                result.last_key = Some(candidate.key.clone());
686                result.listed += 1;
687                pending.push(import_r2_object_to_local(
688                    client.clone(),
689                    bucket.clone(),
690                    local.clone(),
691                    candidate,
692                    options.check_only,
693                    false,
694                ));
695
696                while pending.len() >= concurrency {
697                    settle_one_r2_import(&mut pending, &mut result).await;
698                }
699            }
700
701            while !pending.is_empty() {
702                settle_one_r2_import(&mut pending, &mut result).await;
703            }
704
705            result.completed = true;
706            if let Some(state_file) = options.state_file.as_ref() {
707                write_r2_import_state(state_file, &result)?;
708            }
709            return Ok(result);
710        }
711
712        let state_file = options
713            .state_file
714            .unwrap_or_else(|| self.base_path().join("r2-import-state.json"));
715        let saved_state = read_r2_import_state(&state_file);
716        let saved_incomplete = saved_state
717            .as_ref()
718            .is_some_and(|state| !state.result.completed && state.result.last_key.is_some());
719        let start_after = options.start_after.clone().or_else(|| {
720            if options.resume && saved_incomplete {
721                saved_state
722                    .as_ref()
723                    .and_then(|state| state.result.last_key.clone())
724            } else {
725                None
726            }
727        });
728        let mut result = if options.resume && options.start_after.is_none() && saved_incomplete {
729            saved_state.map(|state| state.result).unwrap_or_default()
730        } else {
731            R2ImportResult::default()
732        };
733        result.completed = false;
734
735        println!(
736            "R2 import {}: bucket={}, prefix={}, list_prefix={}, start_after={}, state_file={}",
737            if options.check_only { "check" } else { "sync" },
738            bucket.as_str(),
739            prefix,
740            list_prefix,
741            start_after.as_deref().unwrap_or("<beginning>"),
742            state_file.display(),
743        );
744
745        if options.stream_merge && options.fast_list {
746            println!("  Stream merge enabled; skipping in-memory --fast-list index");
747        }
748
749        let local_hashes = if options.fast_list && !options.stream_merge {
750            println!("  Loading local hash index...");
751            let mut local_hashes = self
752                .router
753                .list()
754                .map_err(|err| anyhow::anyhow!("Failed to list local blobs: {err}"))?;
755            local_hashes.sort_unstable();
756            println!("  Local hash index loaded: {} blobs", local_hashes.len());
757            Some(local_hashes)
758        } else {
759            None
760        };
761
762        let progress_every = options.progress_every.max(1);
763        let mut continuation_token: Option<String> = None;
764        let mut listed_since_progress = 0usize;
765        let mut listed_this_run = 0usize;
766        let mut first_page = true;
767        let mut hit_max_objects = false;
768
769        loop {
770            let mut list_req = client
771                .list_objects_v2()
772                .bucket(bucket.as_str())
773                .prefix(&list_prefix);
774
775            if let Some(ref token) = continuation_token {
776                list_req = list_req.continuation_token(token);
777            } else if first_page {
778                if let Some(ref start_after) = start_after {
779                    list_req = list_req.start_after(start_after);
780                }
781            }
782            first_page = false;
783
784            let list_resp = list_req
785                .send()
786                .await
787                .map_err(|err| anyhow::anyhow!("Failed to list S3 objects: {err}"))?;
788
789            let mut page_candidates = Vec::new();
790            let mut page_last_key = None;
791            for object in list_resp.contents() {
792                if options
793                    .max_objects
794                    .is_some_and(|max_objects| listed_this_run >= max_objects)
795                {
796                    hit_max_objects = true;
797                    break;
798                }
799
800                let key = object.key().unwrap_or("").to_string();
801                page_last_key = Some(key.clone());
802                if !key.ends_with(".bin") {
803                    continue;
804                }
805
806                let Some(hash) = r2_import_key_hash(&prefix, &key) else {
807                    continue;
808                };
809
810                result.listed += 1;
811                listed_this_run += 1;
812                listed_since_progress += 1;
813                page_candidates.push(R2ObjectCandidate { key, hash });
814            }
815
816            let page_existing = if options.stream_merge && !page_candidates.is_empty() {
817                Some(existing_r2_candidates(local.as_ref(), &page_candidates)?)
818            } else {
819                None
820            };
821
822            for (candidate_index, candidate) in page_candidates.into_iter().enumerate() {
823                let already_exists = page_existing
824                    .as_ref()
825                    .is_some_and(|existing| existing[candidate_index]);
826
827                if options.scan_delay_ms > 0 {
828                    tokio::time::sleep(Duration::from_millis(options.scan_delay_ms)).await;
829                }
830
831                if already_exists {
832                    result.skipped += 1;
833                    continue;
834                }
835
836                if let Some(local_hashes) = &local_hashes {
837                    if local_hashes.binary_search(&candidate.hash).is_ok() {
838                        result.skipped += 1;
839                        continue;
840                    }
841                }
842
843                pending.push(import_r2_object_to_local(
844                    client.clone(),
845                    bucket.clone(),
846                    local.clone(),
847                    candidate,
848                    options.check_only,
849                    page_existing.is_some(),
850                ));
851
852                while pending.len() >= concurrency {
853                    settle_one_r2_import(&mut pending, &mut result).await;
854                }
855            }
856
857            while !pending.is_empty() {
858                settle_one_r2_import(&mut pending, &mut result).await;
859            }
860            if let Some(last_key) = page_last_key {
861                result.last_key = Some(last_key);
862            }
863            if listed_since_progress >= progress_every {
864                listed_since_progress = 0;
865                println!(
866                    "  Progress: {} listed, {} imported, {} skipped, {} missing, {} corrupted, {} failed, {:.2} GB imported",
867                    result.listed,
868                    result.imported,
869                    result.skipped,
870                    result.missing,
871                    result.corrupted,
872                    result.failed,
873                    result.bytes_imported as f64 / 1024.0 / 1024.0 / 1024.0,
874                );
875            }
876            write_r2_import_state(&state_file, &result)?;
877
878            if hit_max_objects {
879                break;
880            }
881            if list_resp.is_truncated() == Some(true) {
882                continuation_token = list_resp.next_continuation_token().map(|s| s.to_string());
883            } else {
884                result.completed = true;
885                break;
886            }
887        }
888
889        write_r2_import_state(&state_file, &result)?;
890        Ok(result)
891    }
892
893    /// Fallback for non-S3 builds
894    #[cfg(not(feature = "s3"))]
895    pub async fn verify_r2_integrity(&self, _delete: bool) -> Result<VerifyResult> {
896        Err(anyhow::anyhow!("S3 feature not enabled"))
897    }
898
899    pub fn compact_lmdb_environments(
900        &self,
901        env_dirs: &[PathBuf],
902        keep_backup: bool,
903    ) -> Result<Vec<CompactResult>> {
904        compact_lmdb_environments_under(self.base_path(), env_dirs, keep_backup)
905    }
906}
907
908pub fn compact_lmdb_environments_under(
909    base_path: &Path,
910    env_dirs: &[PathBuf],
911    keep_backup: bool,
912) -> Result<Vec<CompactResult>> {
913    let targets = if env_dirs.is_empty() {
914        discover_lmdb_environment_dirs(base_path)?
915    } else {
916        env_dirs
917            .iter()
918            .map(|path| {
919                if path.is_absolute() {
920                    path.clone()
921                } else {
922                    base_path.join(path)
923                }
924            })
925            .collect()
926    };
927
928    let mut results = Vec::new();
929    for env_dir in targets {
930        results.push(compact_lmdb_environment_dir(&env_dir, keep_backup)?);
931    }
932    Ok(results)
933}
934
935fn discover_lmdb_environment_dirs(root: &Path) -> Result<Vec<PathBuf>> {
936    let mut dirs = Vec::new();
937    collect_lmdb_environment_dirs(root, &mut dirs)?;
938    dirs.sort();
939    Ok(dirs)
940}
941
942fn collect_lmdb_environment_dirs(root: &Path, dirs: &mut Vec<PathBuf>) -> Result<()> {
943    if root.join("data.mdb").exists() {
944        dirs.push(root.to_path_buf());
945    }
946
947    for entry in std::fs::read_dir(root)? {
948        let entry = entry?;
949        let path = entry.path();
950        if path.is_dir() {
951            collect_lmdb_environment_dirs(&path, dirs)?;
952        }
953    }
954
955    Ok(())
956}
957
958fn compact_lmdb_environment_dir(env_dir: &Path, keep_backup: bool) -> Result<CompactResult> {
959    let data_path = env_dir.join("data.mdb");
960    if !data_path.exists() {
961        anyhow::bail!("No data.mdb found in {}", env_dir.display());
962    }
963
964    let before_bytes = std::fs::metadata(&data_path)?.len();
965    let compact_path = env_dir.join("data.mdb.compact");
966    let backup_path = env_dir.join("data.mdb.bak");
967
968    if compact_path.exists() {
969        std::fs::remove_file(&compact_path)?;
970    }
971    if !keep_backup && backup_path.exists() {
972        std::fs::remove_file(&backup_path)?;
973    }
974
975    let open_map_size = existing_lmdb_map_size_bytes(&data_path)?;
976
977    {
978        let mut options = EnvOpenOptions::new();
979        options
980            .map_size(open_map_size)
981            .max_dbs(COMPACT_MAX_DBS)
982            .max_readers(COMPACT_MAX_READERS);
983        let env = unsafe { ManagedEnv::open(&options, env_dir) }?;
984        env.force_sync()?;
985        env.copy_to_file(&compact_path, CompactionOption::Enabled)?;
986    }
987
988    let after_bytes = std::fs::metadata(&compact_path)?.len();
989
990    if backup_path.exists() {
991        std::fs::remove_file(&backup_path)?;
992    }
993
994    std::fs::rename(&data_path, &backup_path)?;
995    if let Err(error) = std::fs::rename(&compact_path, &data_path) {
996        let _ = std::fs::rename(&backup_path, &data_path);
997        return Err(error.into());
998    }
999
1000    if !keep_backup {
1001        std::fs::remove_file(&backup_path)?;
1002    }
1003
1004    Ok(CompactResult {
1005        env_dir: env_dir.to_path_buf(),
1006        before_bytes,
1007        after_bytes,
1008    })
1009}
1010
1011fn existing_lmdb_map_size_bytes(data_path: &Path) -> Result<usize> {
1012    let file_bytes = std::fs::metadata(data_path)?.len();
1013    let aligned_bytes = if file_bytes == 0 {
1014        COMPACT_OPEN_MAP_SIZE_BYTES as u64
1015    } else {
1016        let remainder = file_bytes % COMPACT_PAGE_SIZE_BYTES;
1017        if remainder == 0 {
1018            file_bytes
1019        } else {
1020            file_bytes.saturating_add(COMPACT_PAGE_SIZE_BYTES - remainder)
1021        }
1022    };
1023
1024    Ok(usize::try_from(aligned_bytes)
1025        .unwrap_or(usize::MAX)
1026        .max(COMPACT_OPEN_MAP_SIZE_BYTES))
1027}
1028
1029#[cfg(all(test, feature = "s3"))]
1030mod tests {
1031    use super::{r2_import_key_candidate, r2_import_key_hash};
1032
1033    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1034
1035    #[test]
1036    fn r2_import_key_hash_accepts_only_root_blob_keys() {
1037        assert!(r2_import_key_hash("", &format!("{HASH}.bin")).is_some());
1038        assert!(r2_import_key_hash("legacy/", &format!("legacy/{HASH}.bin")).is_some());
1039
1040        assert!(r2_import_key_hash("", &format!("hot/{HASH}.bin")).is_none());
1041        assert!(
1042            r2_import_key_hash("", &format!("site-bytes/pubkey/tree/root/{HASH}.bin")).is_none()
1043        );
1044        assert!(r2_import_key_hash("", "roots/pubkey/tree.json").is_none());
1045        assert!(r2_import_key_hash("", &format!("{HASH}.png")).is_none());
1046        assert!(r2_import_key_hash("", "not-a-hash.bin").is_none());
1047    }
1048
1049    #[test]
1050    fn r2_import_key_candidate_accepts_hash_or_canonical_key() {
1051        let bare = r2_import_key_candidate("", HASH).expect("bare hash");
1052        assert_eq!(bare.key, format!("{HASH}.bin"));
1053
1054        let explicit = r2_import_key_candidate("", &format!("{HASH}.bin")).expect("hash key");
1055        assert_eq!(explicit.key, format!("{HASH}.bin"));
1056
1057        assert!(r2_import_key_candidate("", &format!("hot/{HASH}.bin")).is_none());
1058    }
1059
1060    #[test]
1061    fn r2_import_key_candidate_applies_configured_prefix() {
1062        let bare = r2_import_key_candidate("legacy/", HASH).expect("prefixed bare hash");
1063        assert_eq!(bare.key, format!("legacy/{HASH}.bin"));
1064
1065        let explicit =
1066            r2_import_key_candidate("legacy/", &format!("{HASH}.bin")).expect("prefixed key");
1067        assert_eq!(explicit.key, format!("legacy/{HASH}.bin"));
1068
1069        let already_prefixed =
1070            r2_import_key_candidate("legacy/", &format!("legacy/{HASH}.bin")).expect("key");
1071        assert_eq!(already_prefixed.key, format!("legacy/{HASH}.bin"));
1072    }
1073}