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#[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 pub fn gc(&self) -> Result<GcStats> {
350 let rtxn = self.env.read_txn()?;
351
352 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 let all_hashes = self
372 .router
373 .list_writable()
374 .map_err(|e| anyhow::anyhow!("Failed to list writable hashes: {}", e))?;
375
376 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 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 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 #[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 #[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 #[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}