1use crate::managed_env::ManagedEnv;
2use anyhow::{Context, Result};
3use async_trait::async_trait;
4use futures::executor::block_on as sync_block_on;
5use futures::StreamExt;
6use hashtree_config::StorageBackend;
7use hashtree_core::store::{slice_blob_range, PutManyReport, Store, StoreError};
8use hashtree_core::{sha256, to_hex, types::Hash, Cid, HashTree, HashTreeConfig, TreeNode};
9use hashtree_fs::FsBlobStore;
10#[cfg(feature = "lmdb")]
11use hashtree_lmdb::{
12 open_configured_lmdb_blob_store, open_shared_lmdb_blob_store, ConfiguredLmdbBlobStore,
13 ExternalBlobOptions, LmdbBlobStore, PoolStore,
14};
15use heed::types::*;
16use heed::{Database, EnvFlags, EnvOpenOptions, Error as HeedError, MdbError, PutFlags};
17use lru::LruCache;
18use serde::{Deserialize, Serialize};
19use std::collections::{HashMap, HashSet};
20#[cfg(feature = "s3")]
21use std::future::Future;
22use std::io::Write;
23use std::num::NonZeroUsize;
24use std::path::{Path, PathBuf};
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{Arc, Mutex};
27use std::time::{Instant, SystemTime, UNIX_EPOCH};
28
29mod upload;
30pub use upload::{AddProgress, AddProgressSnapshot};
31
32mod maintenance;
33mod retention;
34
35#[cfg(feature = "s3")]
36const DEFAULT_S3_SYNC_TIMEOUT_MS: u64 = 5_000;
37#[cfg(feature = "s3")]
38const S3_SYNC_TIMEOUT_MS_ENV: &str = "HTREE_S3_SYNC_TIMEOUT_MS";
39
40pub use maintenance::{
41 compact_lmdb_environments_under, CompactResult, R2ImportOptions, R2ImportResult, VerifyResult,
42};
43pub use retention::{
44 OwnedBlobStats, PinTreeError, PinTreeResult, PinnedItem, StorageByPriority, StorageStats,
45 TreeIndexLimits, TreeMeta,
46};
47
48pub const PRIORITY_OTHER: u8 = 64;
50pub const PRIORITY_FOLLOWED: u8 = 128;
51pub const PRIORITY_OWN: u8 = 255;
52const LMDB_MAX_READERS: u32 = 1024;
53const LMDB_METADATA_MIN_MAP_SIZE_BYTES: u64 = 64 * 1024 * 1024;
54const LMDB_METADATA_MAX_MAP_SIZE_BYTES: u64 = 64 * 1024 * 1024 * 1024;
55const LMDB_METADATA_STORAGE_RATIO_DIVISOR: u64 = 1024;
56const LMDB_METADATA_REOPEN_HEADROOM_BYTES: u64 = 64 * 1024 * 1024;
57#[cfg(all(test, feature = "lmdb"))]
58const LMDB_BLOB_MIN_MAP_SIZE_BYTES: u64 = 16 * 1024 * 1024;
59const ACCESS_UPDATE_INTERVAL_SECS: u64 = 300;
60const ACCESS_UPDATE_GATE_MAX_ENTRIES: usize = 4096;
61const DEFAULT_ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT: usize = 64;
62const ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT_ENV: &str = "HTREE_ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT";
63const DEFAULT_FILE_METADATA_CACHE_ENTRIES: usize = 128;
64const FILE_METADATA_CACHE_ENTRIES_ENV: &str = "HTREE_FILE_METADATA_CACHE_ENTRIES";
65const SLOW_OWNED_BLOB_BATCH_LOG_MS_ENV: &str = "HTREE_SLOW_OWNED_BLOB_BATCH_LOG_MS";
66const SLOW_CACHED_BLOB_BATCH_LOG_MS_ENV: &str = "HTREE_SLOW_CACHED_BLOB_BATCH_LOG_MS";
67pub const LOCAL_ADD_EXTERNAL_BLOB_DIR_NAME: &str = "blob-files-v1";
68const LMDB_NO_READ_AHEAD_ENV: &str = "HTREE_LMDB_NO_READ_AHEAD";
69const LMDB_NO_SYNC_ENV: &str = "HTREE_LMDB_NO_SYNC";
70const LMDB_NO_META_SYNC_ENV: &str = "HTREE_LMDB_NO_META_SYNC";
71
72fn slow_owned_blob_batch_log_ms() -> Option<u128> {
73 std::env::var(SLOW_OWNED_BLOB_BATCH_LOG_MS_ENV)
74 .ok()
75 .and_then(|value| value.parse::<u128>().ok())
76 .filter(|value| *value > 0)
77}
78
79fn slow_cached_blob_batch_log_ms() -> Option<u128> {
80 std::env::var(SLOW_CACHED_BLOB_BATCH_LOG_MS_ENV)
81 .ok()
82 .and_then(|value| value.parse::<u128>().ok())
83 .filter(|value| *value > 0)
84}
85
86fn access_update_background_batch_limit() -> usize {
87 std::env::var(ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT_ENV)
88 .ok()
89 .and_then(|value| value.parse::<usize>().ok())
90 .unwrap_or(DEFAULT_ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT)
91}
92
93fn file_metadata_cache_entries() -> NonZeroUsize {
94 let entries = std::env::var(FILE_METADATA_CACHE_ENTRIES_ENV)
95 .ok()
96 .and_then(|value| value.parse::<usize>().ok())
97 .filter(|value| *value > 0)
98 .unwrap_or(DEFAULT_FILE_METADATA_CACHE_ENTRIES);
99 NonZeroUsize::new(entries).unwrap_or(NonZeroUsize::new(1).expect("nonzero cache size"))
100}
101
102fn env_bool(name: &str) -> Option<bool> {
103 std::env::var(name).ok().and_then(|value| {
104 let value = value.trim();
105 if value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") {
106 Some(true)
107 } else if value == "0"
108 || value.eq_ignore_ascii_case("false")
109 || value.eq_ignore_ascii_case("no")
110 {
111 Some(false)
112 } else {
113 None
114 }
115 })
116}
117
118fn lmdb_env_flags_from_env() -> EnvFlags {
119 let mut flags = EnvFlags::empty();
120 if env_bool(LMDB_NO_READ_AHEAD_ENV).unwrap_or(false) {
121 flags |= EnvFlags::NO_READ_AHEAD;
122 }
123 if env_bool(LMDB_NO_SYNC_ENV).unwrap_or(false) {
124 flags |= EnvFlags::NO_SYNC;
125 }
126 if env_bool(LMDB_NO_META_SYNC_ENV).unwrap_or(false) {
127 flags |= EnvFlags::NO_META_SYNC;
128 }
129 flags
130}
131
132fn unix_timestamp_now() -> u64 {
133 SystemTime::now()
134 .duration_since(UNIX_EPOCH)
135 .unwrap_or_default()
136 .as_secs()
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct CachedRoot {
142 pub hash: String,
144 pub key: Option<String>,
146 pub updated_at: u64,
148 pub visibility: String,
150}
151
152#[derive(Debug, Clone)]
154pub struct LocalStoreStats {
155 pub count: usize,
156 pub total_bytes: u64,
157}
158
159#[derive(Default)]
160struct BlobAccessUpdateGate {
161 next_update_by_hash: Mutex<HashMap<Hash, u64>>,
162}
163
164impl BlobAccessUpdateGate {
165 fn due_hashes<I>(&self, hashes: I, now: u64) -> Vec<Hash>
166 where
167 I: IntoIterator<Item = Hash>,
168 {
169 let Ok(mut next_update_by_hash) = self.next_update_by_hash.try_lock() else {
170 return Vec::new();
171 };
172
173 if next_update_by_hash.len() >= ACCESS_UPDATE_GATE_MAX_ENTRIES {
174 next_update_by_hash.retain(|_, next_update| *next_update > now);
175 if next_update_by_hash.len() >= ACCESS_UPDATE_GATE_MAX_ENTRIES {
176 next_update_by_hash.clear();
177 }
178 }
179
180 let mut due = Vec::new();
181 let mut seen = HashSet::new();
182 for hash in hashes {
183 if !seen.insert(hash) {
184 continue;
185 }
186 if next_update_by_hash
187 .get(&hash)
188 .is_some_and(|next_update| now < *next_update)
189 {
190 continue;
191 }
192 next_update_by_hash.insert(hash, now.saturating_add(ACCESS_UPDATE_INTERVAL_SECS));
193 due.push(hash);
194 }
195 due
196 }
197}
198
199pub enum LocalStore {
201 Fs(FsBlobStore),
202 #[cfg(feature = "lmdb")]
203 Lmdb(LmdbBlobStore),
204 #[cfg(feature = "lmdb")]
205 Pool(Box<PoolStore>),
206}
207
208#[cfg(feature = "lmdb")]
209fn is_fs_blob_shard_dir(path: &Path) -> bool {
210 path.file_name()
211 .and_then(|name| name.to_str())
212 .map(|name| name.len() == 2 && name.as_bytes().iter().all(u8::is_ascii_hexdigit))
213 .unwrap_or(false)
214}
215
216fn lmdb_metadata_map_size_for_storage_budget(max_size_bytes: u64) -> u64 {
217 if max_size_bytes == 0 {
218 return LMDB_METADATA_MAX_MAP_SIZE_BYTES;
219 }
220
221 max_size_bytes
222 .saturating_div(LMDB_METADATA_STORAGE_RATIO_DIVISOR)
223 .clamp(
224 LMDB_METADATA_MIN_MAP_SIZE_BYTES,
225 LMDB_METADATA_MAX_MAP_SIZE_BYTES,
226 )
227}
228
229fn lmdb_map_size_for_existing_env(path: &Path, requested_bytes: u64) -> Result<usize> {
230 let existing_bytes = std::fs::metadata(path.join("data.mdb"))
231 .map(|metadata| metadata.len())
232 .unwrap_or(0);
233 let requested = if existing_bytes > requested_bytes {
234 let existing_headroom = existing_bytes
235 .saturating_div(10)
236 .max(LMDB_METADATA_REOPEN_HEADROOM_BYTES);
237 existing_bytes.saturating_add(existing_headroom)
238 } else {
239 requested_bytes
240 };
241 let requested = align_lmdb_map_size(requested);
242 usize::try_from(requested).context("LMDB map size exceeds usize")
243}
244
245fn align_lmdb_map_size(bytes: u64) -> u64 {
246 let page_size = (page_size::get() as u64).max(4096);
247 let remainder = bytes % page_size;
248 if remainder == 0 {
249 bytes
250 } else {
251 bytes.saturating_add(page_size - remainder)
252 }
253}
254
255#[cfg(feature = "lmdb")]
256fn remove_stale_fs_blob_shards(path: &Path) -> Result<(), StoreError> {
257 let entries = std::fs::read_dir(path).map_err(StoreError::Io)?;
258 for entry in entries {
259 let entry = entry.map_err(StoreError::Io)?;
260 let entry_path = entry.path();
261 if entry_path.is_dir() && is_fs_blob_shard_dir(&entry_path) {
262 std::fs::remove_dir_all(&entry_path).map_err(StoreError::Io)?;
263 tracing::info!(
264 "Removed stale filesystem blob shard directory after LMDB cutover: {}",
265 entry_path.display()
266 );
267 }
268 }
269 Ok(())
270}
271
272#[cfg(feature = "lmdb")]
273fn local_add_external_blob_reopen_options(store_path: &Path) -> ExternalBlobOptions {
274 ExternalBlobOptions {
275 base_path: store_path.with_file_name(LOCAL_ADD_EXTERNAL_BLOB_DIR_NAME),
276 min_bytes: usize::MAX,
277 sync: true,
278 pack_target_bytes: None,
279 }
280}
281
282#[cfg(feature = "lmdb")]
283fn external_blob_options_for(store_path: &Path) -> ExternalBlobOptions {
284 ExternalBlobOptions::from_env(store_path).unwrap_or_else(|| {
285 local_add_external_blob_reopen_options(store_path)
290 })
291}
292
293#[cfg(feature = "lmdb")]
294fn open_lmdb_blob_store<P: AsRef<Path>>(
295 path: P,
296 map_size_bytes: Option<u64>,
297) -> Result<LmdbBlobStore, StoreError> {
298 std::fs::create_dir_all(path.as_ref()).map_err(StoreError::Io)?;
299 remove_stale_fs_blob_shards(path.as_ref())?;
300 let external_blobs = Some(external_blob_options_for(path.as_ref()));
301 match map_size_bytes {
302 Some(map_size_bytes) => {
303 LmdbBlobStore::with_max_bytes_and_external_blob_options(path, map_size_bytes, |_| {
304 external_blobs
305 })
306 }
307 None => LmdbBlobStore::with_external_blob_options(path, external_blobs),
308 }
309}
310
311impl LocalStore {
312 pub fn new<P: AsRef<Path>>(path: P, backend: &StorageBackend) -> Result<Self, StoreError> {
318 Self::new_unbounded(path, backend)
319 }
320
321 pub fn new_with_lmdb_map_size<P: AsRef<Path>>(
326 path: P,
327 backend: &StorageBackend,
328 _map_size_bytes: Option<u64>,
329 ) -> Result<Self, StoreError> {
330 match backend {
331 StorageBackend::Fs => Ok(LocalStore::Fs(FsBlobStore::new(path)?)),
332 #[cfg(feature = "lmdb")]
333 StorageBackend::Lmdb => Ok(LocalStore::Lmdb(open_lmdb_blob_store(
334 path,
335 _map_size_bytes,
336 )?)),
337 #[cfg(not(feature = "lmdb"))]
338 StorageBackend::Lmdb => {
339 tracing::warn!(
340 "LMDB backend requested but lmdb feature not enabled, using filesystem storage"
341 );
342 Ok(LocalStore::Fs(FsBlobStore::new(path)?))
343 }
344 }
345 }
346
347 pub fn new_unbounded<P: AsRef<Path>>(
349 path: P,
350 backend: &StorageBackend,
351 ) -> Result<Self, StoreError> {
352 Self::new_with_lmdb_map_size(path, backend, None)
353 }
354
355 pub fn new_unbounded_with_lmdb_map_size<P: AsRef<Path>>(
360 path: P,
361 backend: &StorageBackend,
362 _map_size_bytes: Option<u64>,
363 ) -> Result<Self, StoreError> {
364 match backend {
365 StorageBackend::Fs => Ok(LocalStore::Fs(FsBlobStore::new(path)?)),
366 #[cfg(feature = "lmdb")]
367 StorageBackend::Lmdb => Ok(
368 match open_configured_lmdb_blob_store(path, _map_size_bytes)? {
369 ConfiguredLmdbBlobStore::Single(store) => LocalStore::Lmdb(store),
370 ConfiguredLmdbBlobStore::Pool(store) => LocalStore::Pool(store),
371 },
372 ),
373 #[cfg(not(feature = "lmdb"))]
374 StorageBackend::Lmdb => {
375 tracing::warn!(
376 "LMDB backend requested but lmdb feature not enabled, using filesystem storage"
377 );
378 Ok(LocalStore::Fs(FsBlobStore::new(path)?))
379 }
380 }
381 }
382
383 pub fn backend(&self) -> StorageBackend {
384 match self {
385 LocalStore::Fs(_) => StorageBackend::Fs,
386 #[cfg(feature = "lmdb")]
387 LocalStore::Lmdb(_) | LocalStore::Pool(_) => StorageBackend::Lmdb,
388 }
389 }
390
391 pub fn force_sync(&self) -> Result<(), StoreError> {
392 match self {
393 LocalStore::Fs(_) => Ok(()),
394 #[cfg(feature = "lmdb")]
395 LocalStore::Lmdb(store) => store.force_sync(),
396 #[cfg(feature = "lmdb")]
397 LocalStore::Pool(store) => store.force_sync(),
398 }
399 }
400
401 pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
403 match self {
404 LocalStore::Fs(store) => store.put_sync(hash, data),
405 #[cfg(feature = "lmdb")]
406 LocalStore::Lmdb(store) => store.put_sync(hash, data),
407 #[cfg(feature = "lmdb")]
408 LocalStore::Pool(store) => store.put_sync(hash, data),
409 }
410 }
411
412 pub fn put_many_report_sync(
414 &self,
415 items: &[(Hash, Vec<u8>)],
416 ) -> Result<PutManyReport, StoreError> {
417 match self {
418 LocalStore::Fs(store) => {
419 let mut report = PutManyReport {
420 total: items.len(),
421 ..PutManyReport::default()
422 };
423 for (hash, data) in items {
424 if store.put_sync(*hash, data.as_slice())? {
425 report.inserted = report.inserted.saturating_add(1);
426 report.inserted_bytes =
427 report.inserted_bytes.saturating_add(data.len() as u64);
428 report.inserted_hashes.push(*hash);
429 }
430 }
431 Ok(report)
432 }
433 #[cfg(feature = "lmdb")]
434 LocalStore::Lmdb(store) => store.put_many_report_sync(items),
435 #[cfg(feature = "lmdb")]
436 LocalStore::Pool(store) => store.put_many_report_sync(items),
437 }
438 }
439
440 pub fn put_many_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
442 self.put_many_report_sync(items)
443 .map(|report| report.inserted)
444 }
445
446 pub fn put_many_optimistic_report_sync(
448 &self,
449 items: &[(Hash, Vec<u8>)],
450 ) -> Result<PutManyReport, StoreError> {
451 match self {
452 LocalStore::Fs(_) => self.put_many_report_sync(items),
453 #[cfg(feature = "lmdb")]
454 LocalStore::Lmdb(_) => self.put_many_report_sync(items),
455 #[cfg(feature = "lmdb")]
456 LocalStore::Pool(store) => store.put_many_optimistic_report_sync(items),
457 }
458 }
459
460 pub fn put_many_optimistic_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
461 self.put_many_optimistic_report_sync(items)
462 .map(|report| report.inserted)
463 }
464
465 pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
467 match self {
468 LocalStore::Fs(store) => store.get_sync(hash),
469 #[cfg(feature = "lmdb")]
470 LocalStore::Lmdb(store) => store.get_sync(hash),
471 #[cfg(feature = "lmdb")]
472 LocalStore::Pool(store) => store.get_sync(hash),
473 }
474 }
475
476 pub fn get_range_sync(
477 &self,
478 hash: &Hash,
479 start: u64,
480 end_inclusive: u64,
481 ) -> Result<Option<Vec<u8>>, StoreError> {
482 match self {
483 LocalStore::Fs(store) => store.get_range_sync(hash, start, end_inclusive),
484 #[cfg(feature = "lmdb")]
485 LocalStore::Lmdb(store) => store.get_range_sync(hash, start, end_inclusive),
486 #[cfg(feature = "lmdb")]
487 LocalStore::Pool(store) => store.get_range_sync(hash, start, end_inclusive),
488 }
489 }
490
491 pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
492 match self {
493 LocalStore::Fs(store) => store.blob_size_sync(hash),
494 #[cfg(feature = "lmdb")]
495 LocalStore::Lmdb(store) => store.blob_size_sync(hash),
496 #[cfg(feature = "lmdb")]
497 LocalStore::Pool(store) => store.blob_size_sync(hash),
498 }
499 }
500
501 pub fn touch_accessed_sync(&self, hash: &Hash, now: u64) -> Result<bool, StoreError> {
502 match self {
503 LocalStore::Fs(store) => store.touch_accessed_sync(hash, now),
504 #[cfg(feature = "lmdb")]
505 LocalStore::Lmdb(store) => store.touch_accessed_sync(hash, now),
506 #[cfg(feature = "lmdb")]
507 LocalStore::Pool(store) => store.touch_accessed_sync(hash, now),
508 }
509 }
510
511 pub fn touch_many_accessed_sync(&self, hashes: &[Hash], now: u64) -> Result<usize, StoreError> {
512 match self {
513 LocalStore::Fs(store) => store.touch_many_accessed_sync(hashes, now),
514 #[cfg(feature = "lmdb")]
515 LocalStore::Lmdb(store) => store.touch_many_accessed_sync(hashes, now),
516 #[cfg(feature = "lmdb")]
517 LocalStore::Pool(store) => store.touch_many_accessed_sync(hashes, now),
518 }
519 }
520
521 pub fn last_accessed_at_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
522 match self {
523 LocalStore::Fs(store) => store.last_accessed_at_sync(hash),
524 #[cfg(feature = "lmdb")]
525 LocalStore::Lmdb(store) => store.last_accessed_at_sync(hash),
526 #[cfg(feature = "lmdb")]
527 LocalStore::Pool(store) => store.last_accessed_at_sync(hash),
528 }
529 }
530
531 pub fn many_last_accessed_at_sync(
532 &self,
533 hashes: &[Hash],
534 ) -> Result<Vec<(Hash, u64)>, StoreError> {
535 match self {
536 LocalStore::Fs(store) => store.many_last_accessed_at_sync(hashes),
537 #[cfg(feature = "lmdb")]
538 LocalStore::Lmdb(store) => store.many_last_accessed_at_sync(hashes),
539 #[cfg(feature = "lmdb")]
540 LocalStore::Pool(store) => store.many_last_accessed_at_sync(hashes),
541 }
542 }
543
544 pub fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
546 match self {
547 LocalStore::Fs(store) => Ok(store.exists(hash)),
548 #[cfg(feature = "lmdb")]
549 LocalStore::Lmdb(store) => store.exists(hash),
550 #[cfg(feature = "lmdb")]
551 LocalStore::Pool(store) => store.exists(hash),
552 }
553 }
554
555 pub fn existing_hashes_in_sorted_candidates(
557 &self,
558 sorted_hashes: &[Hash],
559 ) -> Result<Vec<bool>, StoreError> {
560 match self {
561 LocalStore::Fs(store) => Ok(sorted_hashes
562 .iter()
563 .map(|hash| store.exists(hash))
564 .collect()),
565 #[cfg(feature = "lmdb")]
566 LocalStore::Lmdb(store) => store.existing_hashes_in_sorted_candidates(sorted_hashes),
567 #[cfg(feature = "lmdb")]
568 LocalStore::Pool(store) => store.existing_hashes_in_sorted_candidates(sorted_hashes),
569 }
570 }
571
572 pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
574 match self {
575 LocalStore::Fs(store) => store.delete_sync(hash),
576 #[cfg(feature = "lmdb")]
577 LocalStore::Lmdb(store) => store.delete_sync(hash),
578 #[cfg(feature = "lmdb")]
579 LocalStore::Pool(store) => store.delete_sync(hash),
580 }
581 }
582
583 pub fn delete_writable_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
584 match self {
585 LocalStore::Fs(store) => store.delete_sync(hash),
586 #[cfg(feature = "lmdb")]
587 LocalStore::Lmdb(store) => store.delete_sync(hash),
588 #[cfg(feature = "lmdb")]
589 LocalStore::Pool(store) => store.delete_sync(hash),
590 }
591 }
592
593 pub fn stats(&self) -> Result<LocalStoreStats, StoreError> {
595 match self {
596 LocalStore::Fs(store) => {
597 let stats = store.stats()?;
598 Ok(LocalStoreStats {
599 count: stats.count,
600 total_bytes: stats.total_bytes,
601 })
602 }
603 #[cfg(feature = "lmdb")]
604 LocalStore::Lmdb(store) => {
605 let stats = store.stats()?;
606 Ok(LocalStoreStats {
607 count: stats.count,
608 total_bytes: stats.total_bytes,
609 })
610 }
611 #[cfg(feature = "lmdb")]
612 LocalStore::Pool(store) => {
613 let stats = store.stats()?;
614 Ok(LocalStoreStats {
615 count: stats.count as usize,
616 total_bytes: stats.bytes,
617 })
618 }
619 }
620 }
621
622 pub fn writable_stats(&self) -> Result<LocalStoreStats, StoreError> {
624 match self {
625 LocalStore::Fs(store) => {
626 let stats = store.stats()?;
627 Ok(LocalStoreStats {
628 count: stats.count,
629 total_bytes: stats.total_bytes,
630 })
631 }
632 #[cfg(feature = "lmdb")]
633 LocalStore::Lmdb(store) => {
634 let stats = store.stats()?;
635 Ok(LocalStoreStats {
636 count: stats.count,
637 total_bytes: stats.total_bytes,
638 })
639 }
640 #[cfg(feature = "lmdb")]
641 LocalStore::Pool(store) => {
642 let stats = store.stats()?;
643 Ok(LocalStoreStats {
644 count: stats.count as usize,
645 total_bytes: stats.bytes,
646 })
647 }
648 }
649 }
650
651 pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
653 match self {
654 LocalStore::Fs(store) => store.list(),
655 #[cfg(feature = "lmdb")]
656 LocalStore::Lmdb(store) => store.list(),
657 #[cfg(feature = "lmdb")]
658 LocalStore::Pool(store) => store.list(),
659 }
660 }
661
662 pub fn list_writable(&self) -> Result<Vec<Hash>, StoreError> {
664 match self {
665 LocalStore::Fs(store) => store.list(),
666 #[cfg(feature = "lmdb")]
667 LocalStore::Lmdb(store) => store.list(),
668 #[cfg(feature = "lmdb")]
669 LocalStore::Pool(store) => store.list(),
670 }
671 }
672}
673
674#[async_trait]
675impl Store for LocalStore {
676 async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
677 self.put_sync(hash, &data)
678 }
679
680 async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
681 self.put_many_sync(&items)
682 }
683
684 async fn put_many_optimistic(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
685 self.put_many_optimistic_sync(&items)
686 }
687
688 async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
689 self.get_sync(hash)
690 }
691
692 async fn get_range(
693 &self,
694 hash: &Hash,
695 start: u64,
696 end_inclusive: u64,
697 ) -> Result<Option<Vec<u8>>, StoreError> {
698 self.get_range_sync(hash, start, end_inclusive)
699 }
700
701 async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
702 self.blob_size_sync(hash)
703 }
704
705 async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
706 self.exists(hash)
707 }
708
709 async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
710 self.delete_sync(hash)
711 }
712}
713
714fn open_local_blob_store_with_options<P: AsRef<Path>>(
715 data_dir: P,
716 backend: &StorageBackend,
717 max_size_bytes: u64,
718) -> Result<Arc<LocalStore>, StoreError> {
719 #[cfg(feature = "lmdb")]
720 if *backend == StorageBackend::Lmdb {
721 return open_shared_lmdb_blob_store(data_dir, max_size_bytes).map(|store| {
722 Arc::new(match store {
723 ConfiguredLmdbBlobStore::Single(store) => LocalStore::Lmdb(store),
724 ConfiguredLmdbBlobStore::Pool(store) => LocalStore::Pool(store),
725 })
726 });
727 }
728
729 #[cfg(not(feature = "lmdb"))]
730 let _ = max_size_bytes;
731
732 LocalStore::new_unbounded(data_dir.as_ref().join("blobs"), backend).map(Arc::new)
733}
734
735#[cfg(feature = "s3")]
736use tokio::sync::mpsc;
737
738use crate::config::S3Config;
739
740#[cfg(feature = "s3")]
742enum S3SyncMessage {
743 Upload { hash: Hash, data: Vec<u8> },
744 Delete { hash: Hash },
745}
746
747pub struct StorageRouter {
752 local: Arc<LocalStore>,
754 #[cfg(feature = "s3")]
756 s3_client: Option<aws_sdk_s3::Client>,
757 #[cfg(feature = "s3")]
758 s3_bucket: Option<String>,
759 #[cfg(feature = "s3")]
760 s3_prefix: String,
761 #[cfg(feature = "s3")]
763 sync_tx: Option<mpsc::UnboundedSender<S3SyncMessage>>,
764}
765
766impl StorageRouter {
767 #[cfg(feature = "s3")]
768 fn s3_sync_timeout() -> std::time::Duration {
769 let millis = std::env::var(S3_SYNC_TIMEOUT_MS_ENV)
770 .ok()
771 .and_then(|value| value.parse::<u64>().ok())
772 .filter(|value| *value > 0)
773 .unwrap_or(DEFAULT_S3_SYNC_TIMEOUT_MS);
774 std::time::Duration::from_millis(millis)
775 }
776
777 #[cfg(feature = "s3")]
778 fn s3_sync_timeout_error(timeout: std::time::Duration) -> StoreError {
779 StoreError::Other(format!(
780 "S3 sync operation timed out after {}ms",
781 timeout.as_millis()
782 ))
783 }
784
785 #[cfg(feature = "s3")]
786 fn run_s3_future_sync<F, T>(future: F) -> Result<T, StoreError>
787 where
788 F: Future<Output = T> + Send + 'static,
789 T: Send + 'static,
790 {
791 let timeout = Self::s3_sync_timeout();
792 if tokio::runtime::Handle::try_current().is_ok() {
793 return std::thread::Builder::new()
794 .name("storage-s3-sync".to_string())
795 .spawn(move || {
796 let runtime = tokio::runtime::Builder::new_current_thread()
797 .enable_all()
798 .build()
799 .map_err(|err| {
800 StoreError::Other(format!("build storage s3 sync runtime: {err}"))
801 })?;
802 runtime.block_on(async move {
803 tokio::time::timeout(timeout, future)
804 .await
805 .map_err(|_| Self::s3_sync_timeout_error(timeout))
806 })
807 })
808 .map_err(|err| StoreError::Other(format!("spawn S3 sync helper thread: {err}")))?
809 .join()
810 .map_err(|_| StoreError::Other("S3 sync helper thread panicked".to_string()))?;
811 }
812
813 let runtime = tokio::runtime::Builder::new_current_thread()
814 .enable_all()
815 .build()
816 .map_err(|err| StoreError::Other(format!("build storage s3 sync runtime: {err}")))?;
817 runtime.block_on(async move {
818 tokio::time::timeout(timeout, future)
819 .await
820 .map_err(|_| Self::s3_sync_timeout_error(timeout))
821 })
822 }
823
824 pub fn new(local: Arc<LocalStore>) -> Self {
826 Self {
827 local,
828 #[cfg(feature = "s3")]
829 s3_client: None,
830 #[cfg(feature = "s3")]
831 s3_bucket: None,
832 #[cfg(feature = "s3")]
833 s3_prefix: String::new(),
834 #[cfg(feature = "s3")]
835 sync_tx: None,
836 }
837 }
838
839 pub fn force_sync(&self) -> Result<(), StoreError> {
840 self.local.force_sync()
841 }
842
843 #[cfg(feature = "s3")]
845 pub async fn with_s3(local: Arc<LocalStore>, config: &S3Config) -> Result<Self, anyhow::Error> {
846 use aws_sdk_s3::Client as S3Client;
847
848 let mut aws_config_loader = aws_config::from_env();
850 aws_config_loader =
851 aws_config_loader.region(aws_sdk_s3::config::Region::new(config.region.clone()));
852 let aws_config = aws_config_loader.load().await;
853
854 let mut s3_config_builder = aws_sdk_s3::config::Builder::from(&aws_config);
856 s3_config_builder = s3_config_builder
857 .endpoint_url(&config.endpoint)
858 .force_path_style(true);
859
860 let s3_client = S3Client::from_conf(s3_config_builder.build());
861 let bucket = config.bucket.clone();
862 let prefix = config.prefix.clone().unwrap_or_default();
863
864 let (sync_tx, mut sync_rx) = mpsc::unbounded_channel::<S3SyncMessage>();
866
867 let sync_client = s3_client.clone();
869 let sync_bucket = bucket.clone();
870 let sync_prefix = prefix.clone();
871
872 tokio::spawn(async move {
873 use aws_sdk_s3::primitives::ByteStream;
874
875 tracing::info!("S3 background sync task started");
876
877 let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(8));
879 let client = std::sync::Arc::new(sync_client);
880 let bucket = std::sync::Arc::new(sync_bucket);
881 let prefix = std::sync::Arc::new(sync_prefix);
882
883 while let Some(msg) = sync_rx.recv().await {
884 let client = client.clone();
885 let bucket = bucket.clone();
886 let prefix = prefix.clone();
887 let semaphore = semaphore.clone();
888
889 tokio::spawn(async move {
891 let _permit = semaphore.acquire().await;
893
894 match msg {
895 S3SyncMessage::Upload { hash, data } => {
896 let key = format!("{}{}.bin", prefix, to_hex(&hash));
897 tracing::debug!("S3 uploading {} ({} bytes)", &key, data.len());
898
899 let mut attempt = 1u8;
900 loop {
901 match client
902 .put_object()
903 .bucket(bucket.as_str())
904 .key(&key)
905 .body(ByteStream::from(data.clone()))
906 .send()
907 .await
908 {
909 Ok(_) => {
910 tracing::debug!("S3 upload succeeded: {}", &key);
911 break;
912 }
913 Err(e) if attempt < 3 => {
914 tracing::warn!(
915 "S3 upload retrying {}: attempt={} error={}",
916 &key,
917 attempt,
918 e
919 );
920 tokio::time::sleep(std::time::Duration::from_millis(
921 250 * u64::from(attempt),
922 ))
923 .await;
924 attempt += 1;
925 }
926 Err(e) => {
927 tracing::error!(
928 "S3 upload failed {} after {} attempts: {}",
929 &key,
930 attempt,
931 e
932 );
933 break;
934 }
935 }
936 }
937 }
938 S3SyncMessage::Delete { hash } => {
939 let key = format!("{}{}.bin", prefix, to_hex(&hash));
940 tracing::debug!("S3 deleting {}", &key);
941
942 let mut attempt = 1u8;
943 loop {
944 match client
945 .delete_object()
946 .bucket(bucket.as_str())
947 .key(&key)
948 .send()
949 .await
950 {
951 Ok(_) => break,
952 Err(e) if attempt < 3 => {
953 tracing::warn!(
954 "S3 delete retrying {}: attempt={} error={}",
955 &key,
956 attempt,
957 e
958 );
959 tokio::time::sleep(std::time::Duration::from_millis(
960 250 * u64::from(attempt),
961 ))
962 .await;
963 attempt += 1;
964 }
965 Err(e) => {
966 tracing::error!(
967 "S3 delete failed {} after {} attempts: {}",
968 &key,
969 attempt,
970 e
971 );
972 break;
973 }
974 }
975 }
976 }
977 }
978 });
979 }
980 });
981
982 tracing::info!(
983 "S3 storage initialized: bucket={}, prefix={}",
984 bucket,
985 prefix
986 );
987
988 Ok(Self {
989 local,
990 s3_client: Some(s3_client),
991 s3_bucket: Some(bucket),
992 s3_prefix: prefix,
993 sync_tx: Some(sync_tx),
994 })
995 }
996
997 pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
999 let is_new = self.local.put_sync(hash, data)?;
1001
1002 #[cfg(feature = "s3")]
1005 if is_new {
1006 if let Some(ref tx) = self.sync_tx {
1007 tracing::debug!(
1008 "Queueing S3 upload for {} ({} bytes)",
1009 crate::storage::to_hex(&hash)[..16].to_string(),
1010 data.len(),
1011 );
1012 if let Err(e) = tx.send(S3SyncMessage::Upload {
1013 hash,
1014 data: data.to_vec(),
1015 }) {
1016 tracing::error!("Failed to queue S3 upload: {}", e);
1017 }
1018 }
1019 }
1020
1021 Ok(is_new)
1022 }
1023
1024 pub fn put_many_report_sync(
1026 &self,
1027 items: &[(Hash, Vec<u8>)],
1028 ) -> Result<PutManyReport, StoreError> {
1029 let report = self.local.put_many_report_sync(items)?;
1030
1031 #[cfg(feature = "s3")]
1032 if let Some(ref tx) = self.sync_tx {
1033 if !report.inserted_hashes.is_empty() {
1034 let inserted: HashSet<Hash> = report.inserted_hashes.iter().copied().collect();
1035 let mut queued = HashSet::new();
1036 for (hash, data) in items {
1037 if inserted.contains(hash) && queued.insert(*hash) {
1038 if let Err(e) = tx.send(S3SyncMessage::Upload {
1039 hash: *hash,
1040 data: data.clone(),
1041 }) {
1042 tracing::error!("Failed to queue S3 upload: {}", e);
1043 }
1044 }
1045 }
1046 }
1047 }
1048
1049 Ok(report)
1050 }
1051
1052 pub fn put_many_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
1054 self.put_many_report_sync(items)
1055 .map(|report| report.inserted)
1056 }
1057
1058 pub fn put_many_optimistic_report_sync(
1061 &self,
1062 items: &[(Hash, Vec<u8>)],
1063 ) -> Result<PutManyReport, StoreError> {
1064 let report = self.local.put_many_optimistic_report_sync(items)?;
1065
1066 #[cfg(feature = "s3")]
1067 if let Some(ref tx) = self.sync_tx {
1068 if !report.inserted_hashes.is_empty() {
1069 let inserted: HashSet<Hash> = report.inserted_hashes.iter().copied().collect();
1070 let mut queued = HashSet::new();
1071 for (hash, data) in items {
1072 if inserted.contains(hash) && queued.insert(*hash) {
1073 if let Err(e) = tx.send(S3SyncMessage::Upload {
1074 hash: *hash,
1075 data: data.clone(),
1076 }) {
1077 tracing::error!("Failed to queue S3 upload: {}", e);
1078 }
1079 }
1080 }
1081 }
1082 }
1083
1084 Ok(report)
1085 }
1086
1087 pub fn put_many_optimistic_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
1088 self.put_many_optimistic_report_sync(items)
1089 .map(|report| report.inserted)
1090 }
1091
1092 pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
1094 if let Some(data) = self.local.get_sync(hash)? {
1096 return Ok(Some(data));
1097 }
1098
1099 #[cfg(feature = "s3")]
1101 if let (Some(ref client), Some(ref bucket)) = (&self.s3_client, &self.s3_bucket) {
1102 let key = format!("{}{}.bin", self.s3_prefix, to_hex(hash));
1103 let client = client.clone();
1104 let bucket = bucket.clone();
1105
1106 match Self::run_s3_future_sync(async move {
1107 client.get_object().bucket(bucket).key(key).send().await
1108 }) {
1109 Ok(Ok(output)) => {
1110 match Self::run_s3_future_sync(async move { output.body.collect().await }) {
1111 Ok(Ok(body)) => {
1112 let data = body.into_bytes().to_vec();
1113 let _ = self.local.put_sync(*hash, &data);
1115 return Ok(Some(data));
1116 }
1117 Ok(Err(err)) => {
1118 tracing::warn!("S3 body collect failed: {}", err);
1119 }
1120 Err(err) => {
1121 tracing::warn!("S3 body collect runtime failed: {}", err);
1122 }
1123 }
1124 }
1125 Ok(Err(err)) => {
1126 let service_err = err.into_service_error();
1127 if !service_err.is_no_such_key() {
1128 tracing::warn!("S3 get failed: {}", service_err);
1129 }
1130 }
1131 Err(err) => {
1132 tracing::warn!("S3 get runtime failed: {}", err);
1133 }
1134 }
1135 }
1136
1137 Ok(None)
1138 }
1139
1140 pub fn get_range_sync(
1141 &self,
1142 hash: &Hash,
1143 start: u64,
1144 end_inclusive: u64,
1145 ) -> Result<Option<Vec<u8>>, StoreError> {
1146 self.local.get_range_sync(hash, start, end_inclusive)
1147 }
1148
1149 pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1150 self.local.blob_size_sync(hash)
1151 }
1152
1153 pub fn touch_accessed_sync(&self, hash: &Hash, now: u64) -> Result<bool, StoreError> {
1154 self.local.touch_accessed_sync(hash, now)
1155 }
1156
1157 pub fn touch_many_accessed_sync(&self, hashes: &[Hash], now: u64) -> Result<usize, StoreError> {
1158 self.local.touch_many_accessed_sync(hashes, now)
1159 }
1160
1161 pub fn last_accessed_at_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1162 self.local.last_accessed_at_sync(hash)
1163 }
1164
1165 pub fn many_last_accessed_at_sync(
1166 &self,
1167 hashes: &[Hash],
1168 ) -> Result<Vec<(Hash, u64)>, StoreError> {
1169 self.local.many_last_accessed_at_sync(hashes)
1170 }
1171
1172 pub fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
1174 if self.local.exists(hash)? {
1176 return Ok(true);
1177 }
1178
1179 #[cfg(feature = "s3")]
1181 if let (Some(ref client), Some(ref bucket)) = (&self.s3_client, &self.s3_bucket) {
1182 let key = format!("{}{}.bin", self.s3_prefix, to_hex(hash));
1183 let client = client.clone();
1184 let bucket = bucket.clone();
1185
1186 match Self::run_s3_future_sync(async move {
1187 client.head_object().bucket(bucket).key(&key).send().await
1188 }) {
1189 Ok(Ok(_)) => return Ok(true),
1190 Ok(Err(err)) => {
1191 let service_err = err.into_service_error();
1192 if !service_err.is_not_found() {
1193 tracing::warn!("S3 head failed: {}", service_err);
1194 }
1195 }
1196 Err(err) => {
1197 tracing::warn!("S3 head runtime failed: {}", err);
1198 }
1199 }
1200 }
1201
1202 Ok(false)
1203 }
1204
1205 pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
1207 let deleted = self.local.delete_sync(hash)?;
1208
1209 #[cfg(feature = "s3")]
1211 if let Some(ref tx) = self.sync_tx {
1212 let _ = tx.send(S3SyncMessage::Delete { hash: *hash });
1213 }
1214
1215 Ok(deleted)
1216 }
1217
1218 pub fn delete_local_only(&self, hash: &Hash) -> Result<bool, StoreError> {
1221 self.local.delete_writable_sync(hash)
1222 }
1223
1224 pub fn stats(&self) -> Result<LocalStoreStats, StoreError> {
1226 self.local.stats()
1227 }
1228
1229 pub fn writable_stats(&self) -> Result<LocalStoreStats, StoreError> {
1231 self.local.writable_stats()
1232 }
1233
1234 pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
1236 self.local.list()
1237 }
1238
1239 pub fn list_writable(&self) -> Result<Vec<Hash>, StoreError> {
1241 self.local.list_writable()
1242 }
1243
1244 pub fn existing_local_hashes_in_sorted_candidates(
1246 &self,
1247 sorted_hashes: &[Hash],
1248 ) -> Result<Vec<bool>, StoreError> {
1249 self.local
1250 .existing_hashes_in_sorted_candidates(sorted_hashes)
1251 }
1252
1253 pub fn local_store(&self) -> Arc<LocalStore> {
1255 Arc::clone(&self.local)
1256 }
1257}
1258
1259#[derive(Clone)]
1260struct AccessRecordingStore {
1261 inner: Arc<StorageRouter>,
1262 accessed: Arc<Mutex<HashSet<Hash>>>,
1263}
1264
1265impl AccessRecordingStore {
1266 fn new(inner: Arc<StorageRouter>) -> Self {
1267 Self {
1268 inner,
1269 accessed: Arc::new(Mutex::new(HashSet::new())),
1270 }
1271 }
1272
1273 fn take_accessed_hashes(&self) -> Vec<Hash> {
1274 let Ok(mut accessed) = self.accessed.lock() else {
1275 return Vec::new();
1276 };
1277 accessed.drain().collect()
1278 }
1279
1280 fn record_access(&self, hash: &Hash) {
1281 let Ok(mut accessed) = self.accessed.lock() else {
1282 return;
1283 };
1284 accessed.insert(*hash);
1285 }
1286}
1287
1288#[async_trait]
1289impl Store for AccessRecordingStore {
1290 async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
1291 self.inner.put(hash, data).await
1292 }
1293
1294 async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
1295 self.inner.put_many(items).await
1296 }
1297
1298 async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
1299 let data = self.inner.get(hash).await?;
1300 if data.is_some() {
1301 self.record_access(hash);
1302 }
1303 Ok(data)
1304 }
1305
1306 async fn get_range(
1307 &self,
1308 hash: &Hash,
1309 start: u64,
1310 end_inclusive: u64,
1311 ) -> Result<Option<Vec<u8>>, StoreError> {
1312 let data = self.inner.get_range(hash, start, end_inclusive).await?;
1313 if data.is_some() {
1314 self.record_access(hash);
1315 }
1316 Ok(data)
1317 }
1318
1319 async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1320 self.inner.blob_size(hash).await
1321 }
1322
1323 async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
1324 self.inner.has(hash).await
1325 }
1326
1327 async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
1328 self.inner.delete(hash).await
1329 }
1330}
1331
1332#[async_trait]
1335impl Store for StorageRouter {
1336 async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
1337 self.put_sync(hash, &data)
1338 }
1339
1340 async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
1341 self.put_many_sync(&items)
1342 }
1343
1344 async fn put_many_optimistic(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
1345 self.put_many_optimistic_sync(&items)
1346 }
1347
1348 async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
1349 self.get_sync(hash)
1350 }
1351
1352 async fn get_range(
1353 &self,
1354 hash: &Hash,
1355 start: u64,
1356 end_inclusive: u64,
1357 ) -> Result<Option<Vec<u8>>, StoreError> {
1358 if let Some(data) = self.get_range_sync(hash, start, end_inclusive)? {
1359 return Ok(Some(data));
1360 }
1361 let Some(data) = self.get_sync(hash)? else {
1362 return Ok(None);
1363 };
1364 Ok(Some(slice_blob_range(&data, start, end_inclusive)?))
1365 }
1366
1367 async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1368 if let Some(size) = self.blob_size_sync(hash)? {
1369 return Ok(Some(size));
1370 }
1371 Ok(self.get_sync(hash)?.map(|data| data.len() as u64))
1372 }
1373
1374 async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
1375 self.exists(hash)
1376 }
1377
1378 async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
1379 self.delete_sync(hash)
1380 }
1381}
1382
1383pub struct HashtreeStore {
1384 base_path: PathBuf,
1385 env: ManagedEnv,
1386 pins: Database<Bytes, Unit>,
1388 pinned_refs: Database<Str, Unit>,
1390 tracked_authors: Database<Str, Unit>,
1392 blob_owners: Database<Bytes, Unit>,
1394 pubkey_blobs: Database<Bytes, Bytes>,
1396 pubkey_blob_index: Database<Bytes, Bytes>,
1398 tree_meta: Database<Bytes, Bytes>,
1400 blob_trees: Database<Bytes, Unit>,
1402 tree_refs: Database<Str, Bytes>,
1404 cached_roots: Database<Str, Bytes>,
1406 router: Arc<StorageRouter>,
1408 max_size_bytes: u64,
1410 evict_orphans: bool,
1412 blob_access_update_gate: BlobAccessUpdateGate,
1414 blob_access_update_inflight: Arc<AtomicBool>,
1416 file_metadata_cache: Mutex<LruCache<Hash, Arc<FileChunkMetadata>>>,
1418}
1419
1420impl HashtreeStore {
1421 pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
1423 let config = hashtree_config::Config::load_or_default();
1424 let max_size_bytes = config
1425 .storage
1426 .max_size_gb
1427 .saturating_mul(1024 * 1024 * 1024);
1428 Self::with_options_and_backend(
1429 path,
1430 None,
1431 max_size_bytes,
1432 config.storage.evict_orphans,
1433 &config.storage.backend,
1434 )
1435 }
1436
1437 pub fn new_with_backend<P: AsRef<Path>>(
1439 path: P,
1440 backend: hashtree_config::StorageBackend,
1441 max_size_bytes: u64,
1442 ) -> Result<Self> {
1443 Self::with_options_and_backend(path, None, max_size_bytes, true, &backend)
1444 }
1445
1446 pub fn with_s3<P: AsRef<Path>>(path: P, s3_config: Option<&S3Config>) -> Result<Self> {
1448 let config = hashtree_config::Config::load_or_default();
1449 let max_size_bytes = config
1450 .storage
1451 .max_size_gb
1452 .saturating_mul(1024 * 1024 * 1024);
1453 Self::with_options_and_backend(
1454 path,
1455 s3_config,
1456 max_size_bytes,
1457 config.storage.evict_orphans,
1458 &config.storage.backend,
1459 )
1460 }
1461
1462 pub fn with_options<P: AsRef<Path>>(
1468 path: P,
1469 s3_config: Option<&S3Config>,
1470 max_size_bytes: u64,
1471 ) -> Result<Self> {
1472 let config = hashtree_config::Config::load_or_default();
1473 Self::with_options_and_backend(
1474 path,
1475 s3_config,
1476 max_size_bytes,
1477 config.storage.evict_orphans,
1478 &config.storage.backend,
1479 )
1480 }
1481
1482 pub fn with_options_and_backend<P: AsRef<Path>>(
1483 path: P,
1484 s3_config: Option<&S3Config>,
1485 max_size_bytes: u64,
1486 evict_orphans: bool,
1487 backend: &hashtree_config::StorageBackend,
1488 ) -> Result<Self> {
1489 Self::with_options_and_backend_and_env_flags(
1490 path,
1491 s3_config,
1492 max_size_bytes,
1493 evict_orphans,
1494 backend,
1495 EnvFlags::empty(),
1496 )
1497 }
1498
1499 pub fn with_embedded_options<P: AsRef<Path>>(
1506 path: P,
1507 s3_config: Option<&S3Config>,
1508 max_size_bytes: u64,
1509 ) -> Result<Self> {
1510 Self::with_options_and_backend_and_env_flags(
1511 path,
1512 s3_config,
1513 max_size_bytes,
1514 true,
1515 &hashtree_config::StorageBackend::Fs,
1516 EnvFlags::NO_LOCK,
1517 )
1518 }
1519
1520 fn with_options_and_backend_and_env_flags<P: AsRef<Path>>(
1521 path: P,
1522 s3_config: Option<&S3Config>,
1523 max_size_bytes: u64,
1524 evict_orphans: bool,
1525 backend: &hashtree_config::StorageBackend,
1526 env_flags: EnvFlags,
1527 ) -> Result<Self> {
1528 let env_flags = env_flags | lmdb_env_flags_from_env();
1529 let path = path.as_ref();
1530 std::fs::create_dir_all(path)?;
1531 let metadata_map_size = lmdb_map_size_for_existing_env(
1532 path,
1533 lmdb_metadata_map_size_for_storage_budget(max_size_bytes),
1534 )?;
1535
1536 let mut env_options = EnvOpenOptions::new();
1537 env_options
1538 .map_size(metadata_map_size)
1539 .max_dbs(11) .max_readers(LMDB_MAX_READERS);
1541 unsafe {
1542 env_options.flags(env_flags);
1543 }
1544 let env = unsafe { ManagedEnv::open(&env_options, path)? };
1545 let _ = env.clear_stale_readers();
1546 if env.info().map_size < metadata_map_size {
1547 unsafe { env.resize(metadata_map_size) }?;
1548 }
1549
1550 let mut wtxn = env.write_txn()?;
1551 let pins = env.create_database(&mut wtxn, Some("pins"))?;
1552 let pinned_refs = env.create_database(&mut wtxn, Some("pinned_refs"))?;
1553 let tracked_authors = env.create_database(&mut wtxn, Some("tracked_authors"))?;
1554 let blob_owners = env.create_database(&mut wtxn, Some("blob_owners"))?;
1555 let pubkey_blobs = env.create_database(&mut wtxn, Some("pubkey_blobs"))?;
1556 let pubkey_blob_index = env.create_database(&mut wtxn, Some("pubkey_blob_index"))?;
1557 let tree_meta = env.create_database(&mut wtxn, Some("tree_meta"))?;
1558 let blob_trees = env.create_database(&mut wtxn, Some("blob_trees"))?;
1559 let tree_refs = env.create_database(&mut wtxn, Some("tree_refs"))?;
1560 let cached_roots = env.create_database(&mut wtxn, Some("cached_roots"))?;
1561 wtxn.commit()?;
1562
1563 let local_store = open_local_blob_store_with_options(path, backend, max_size_bytes)
1567 .map_err(|e| anyhow::anyhow!("Failed to create blob store: {}", e))?;
1568
1569 #[cfg(feature = "s3")]
1571 let router = Arc::new(if let Some(s3_cfg) = s3_config {
1572 tracing::info!(
1573 "Initializing S3 storage backend: bucket={}, endpoint={}",
1574 s3_cfg.bucket,
1575 s3_cfg.endpoint
1576 );
1577
1578 sync_block_on(async { StorageRouter::with_s3(local_store, s3_cfg).await })?
1579 } else {
1580 StorageRouter::new(local_store)
1581 });
1582
1583 #[cfg(not(feature = "s3"))]
1584 let router = Arc::new({
1585 if s3_config.is_some() {
1586 tracing::warn!(
1587 "S3 config provided but S3 feature not enabled. Using local storage only."
1588 );
1589 }
1590 StorageRouter::new(local_store)
1591 });
1592
1593 Ok(Self {
1594 base_path: path.to_path_buf(),
1595 env,
1596 pins,
1597 pinned_refs,
1598 tracked_authors,
1599 blob_owners,
1600 pubkey_blobs,
1601 pubkey_blob_index,
1602 tree_meta,
1603 blob_trees,
1604 tree_refs,
1605 cached_roots,
1606 router,
1607 max_size_bytes,
1608 evict_orphans,
1609 blob_access_update_gate: BlobAccessUpdateGate::default(),
1610 blob_access_update_inflight: Arc::new(AtomicBool::new(false)),
1611 file_metadata_cache: Mutex::new(LruCache::new(file_metadata_cache_entries())),
1612 })
1613 }
1614
1615 pub fn base_path(&self) -> &Path {
1616 &self.base_path
1617 }
1618
1619 pub fn router(&self) -> &StorageRouter {
1621 &self.router
1622 }
1623
1624 pub fn store_arc(&self) -> Arc<StorageRouter> {
1627 Arc::clone(&self.router)
1628 }
1629
1630 pub fn force_sync(&self) -> Result<()> {
1631 self.env.force_sync()?;
1632 self.router
1633 .force_sync()
1634 .map_err(|err| anyhow::anyhow!("Failed to sync blob store: {}", err))
1635 }
1636
1637 fn access_tracking_tree(&self) -> (HashTree<AccessRecordingStore>, AccessRecordingStore) {
1638 let access_store = AccessRecordingStore::new(self.store_arc());
1639 let tree = HashTree::new(HashTreeConfig::new(Arc::new(access_store.clone())).public());
1640 (tree, access_store)
1641 }
1642
1643 pub fn record_blob_accesses<I>(&self, hashes: I)
1644 where
1645 I: IntoIterator<Item = Hash>,
1646 {
1647 let access_update_batch_limit = access_update_background_batch_limit();
1648 if access_update_batch_limit == 0 {
1649 return;
1650 }
1651
1652 let now = unix_timestamp_now();
1653 let mut due_hashes = self.blob_access_update_gate.due_hashes(hashes, now);
1654 if due_hashes.is_empty() {
1655 return;
1656 }
1657
1658 if self
1659 .blob_access_update_inflight
1660 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1661 .is_err()
1662 {
1663 return;
1664 }
1665
1666 if due_hashes.len() > access_update_batch_limit {
1667 due_hashes.truncate(access_update_batch_limit);
1668 }
1669
1670 let router = Arc::clone(&self.router);
1671 let inflight = Arc::clone(&self.blob_access_update_inflight);
1672 let spawn_result = std::thread::Builder::new()
1673 .name("blob-access-update".to_string())
1674 .spawn(move || {
1675 if let Err(err) = router.touch_many_accessed_sync(&due_hashes, now) {
1676 tracing::debug!("Failed to update blob access metadata: {}", err);
1677 }
1678 inflight.store(false, Ordering::Release);
1679 });
1680 if let Err(err) = spawn_result {
1681 self.blob_access_update_inflight
1682 .store(false, Ordering::Release);
1683 tracing::debug!("Failed to spawn blob access metadata updater: {}", err);
1684 }
1685 }
1686
1687 pub fn blob_last_accessed_at(&self, hash: &Hash) -> Result<Option<u64>> {
1688 self.router
1689 .last_accessed_at_sync(hash)
1690 .map_err(|e| anyhow::anyhow!("Failed to read blob access metadata: {}", e))
1691 }
1692
1693 pub fn blob_last_accessed_many(&self, hashes: &[Hash]) -> Result<Vec<(Hash, u64)>> {
1694 self.router
1695 .many_last_accessed_at_sync(hashes)
1696 .map_err(|e| anyhow::anyhow!("Failed to read blob access metadata: {}", e))
1697 }
1698
1699 pub fn get_tree_node(&self, hash: &[u8; 32]) -> Result<Option<TreeNode>> {
1701 let (tree, access_store) = self.access_tracking_tree();
1702
1703 let result = sync_block_on(async {
1704 tree.get_tree_node(hash)
1705 .await
1706 .map_err(|e| anyhow::anyhow!("Failed to get tree node: {}", e))
1707 })?;
1708 if result.is_some() {
1709 self.record_blob_accesses(access_store.take_accessed_hashes());
1710 }
1711 Ok(result)
1712 }
1713
1714 pub fn put_blob(&self, data: &[u8]) -> Result<String> {
1716 let hash = sha256(data);
1717 self.router
1718 .put_sync(hash, data)
1719 .map_err(|e| anyhow::anyhow!("Failed to store blob: {}", e))?;
1720 Ok(to_hex(&hash))
1721 }
1722
1723 pub fn put_owned_blob_with_inserted(
1725 &self,
1726 data: &[u8],
1727 pubkey: &[u8; 32],
1728 ) -> Result<(String, bool)> {
1729 let hash = sha256(data);
1730 let incoming_bytes = data.len() as u64;
1731 let mut retried_after_cleanup = false;
1732 let inserted = loop {
1733 match self.router.put_sync(hash, data) {
1734 Ok(inserted) => break inserted,
1735 Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
1736 let freed = self.make_room_for_durable_blob(incoming_bytes)?;
1737 if freed == 0 {
1738 return Err(anyhow::anyhow!("Failed to store blob: {}", err));
1739 }
1740 retried_after_cleanup = true;
1741 }
1742 Err(err) => return Err(anyhow::anyhow!("Failed to store blob: {}", err)),
1743 }
1744 };
1745
1746 self.set_blob_owner_with_size(&hash, pubkey, incoming_bytes)?;
1747 if inserted {
1748 if let Err(err) = self.enforce_durable_blob_budget_after_insert(incoming_bytes) {
1749 let _ = self.delete_blossom_blob(&hash, pubkey);
1750 return Err(err);
1751 }
1752 }
1753
1754 Ok((to_hex(&hash), inserted))
1755 }
1756
1757 pub fn put_owned_blob(&self, data: &[u8], pubkey: &[u8; 32]) -> Result<String> {
1758 self.put_owned_blob_with_inserted(data, pubkey)
1759 .map(|(hash, _)| hash)
1760 }
1761
1762 fn put_blob_owners_for_batch(
1763 &self,
1764 items: &[(Hash, Vec<u8>)],
1765 pubkey: &[u8; 32],
1766 ) -> Result<()> {
1767 let now = SystemTime::now()
1768 .duration_since(UNIX_EPOCH)
1769 .unwrap()
1770 .as_secs();
1771 let mut wtxn = self.env.write_txn()?;
1772 for (hash, data) in items {
1773 let owner_key = Self::blob_owner_key(hash, pubkey);
1774 match self.blob_owners.put_with_flags(
1775 &mut wtxn,
1776 PutFlags::NO_OVERWRITE,
1777 &owner_key[..],
1778 &(),
1779 ) {
1780 Ok(()) => {}
1781 Err(HeedError::Mdb(MdbError::KeyExist)) => continue,
1782 Err(error) => return Err(error.into()),
1783 }
1784
1785 let index_key = Self::pubkey_blob_key(pubkey, hash);
1786 let metadata = BlobMetadata {
1787 sha256: to_hex(hash),
1788 size: data.len() as u64,
1789 mime_type: "application/octet-stream".to_string(),
1790 uploaded: now,
1791 };
1792 self.pubkey_blob_index.put(
1793 &mut wtxn,
1794 &index_key[..],
1795 &serde_json::to_vec(&metadata)?,
1796 )?;
1797 }
1798 wtxn.commit()?;
1799 Ok(())
1800 }
1801
1802 fn put_many_durable_blob_bodies(
1803 &self,
1804 items: &[(Hash, Vec<u8>)],
1805 incoming_bytes: u64,
1806 ) -> Result<PutManyReport> {
1807 let mut retried_after_cleanup = false;
1808 loop {
1809 match self.router.put_many_report_sync(items) {
1810 Ok(report) => return Ok(report),
1811 Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
1812 let freed = self.make_room_for_durable_blob(incoming_bytes)?;
1813 if freed == 0 {
1814 return Err(anyhow::anyhow!("Failed to store blob batch: {}", err));
1815 }
1816 retried_after_cleanup = true;
1817 }
1818 Err(err) => return Err(anyhow::anyhow!("Failed to store blob batch: {}", err)),
1819 }
1820 }
1821 }
1822
1823 pub fn put_owned_blobs_report(
1825 &self,
1826 items: &[(Hash, Vec<u8>)],
1827 pubkey: &[u8; 32],
1828 ) -> Result<PutManyReport> {
1829 let started_at = Instant::now();
1830 let slow_log_ms = slow_owned_blob_batch_log_ms();
1831 if items.is_empty() {
1832 return Ok(PutManyReport::default());
1833 }
1834 let incoming_bytes = items.iter().fold(0u64, |total, (_, data)| {
1835 total.saturating_add(data.len() as u64)
1836 });
1837 let count = items.len();
1838 let raw_started = Instant::now();
1839 let report = self.put_many_durable_blob_bodies(items, incoming_bytes)?;
1840 let raw_write_ms = raw_started.elapsed().as_millis();
1841
1842 let owner_started = Instant::now();
1843 self.put_blob_owners_for_batch(items, pubkey)?;
1844 let owner_index_ms = owner_started.elapsed().as_millis();
1845 let quota_started = Instant::now();
1846 if report.inserted_bytes > 0 {
1847 if let Err(err) = self.enforce_durable_blob_budget_after_insert(report.inserted_bytes) {
1848 for hash in &report.inserted_hashes {
1849 let _ = self.delete_blossom_blob(hash, pubkey);
1850 }
1851 return Err(err);
1852 }
1853 }
1854 let quota_ms = quota_started.elapsed().as_millis();
1855 let total_ms = started_at.elapsed().as_millis();
1856 if slow_log_ms.is_some_and(|threshold| total_ms >= threshold) {
1857 tracing::warn!(
1858 blobs = count,
1859 inserted = report.inserted,
1860 incoming_bytes,
1861 inserted_bytes = report.inserted_bytes,
1862 total_ms,
1863 raw_write_ms,
1864 owner_index_ms,
1865 quota_ms,
1866 "slow owned Blossom blob batch write"
1867 );
1868 }
1869 Ok(report)
1870 }
1871
1872 pub fn put_owned_blobs(&self, items: &[(Hash, Vec<u8>)], pubkey: &[u8; 32]) -> Result<usize> {
1874 self.put_owned_blobs_report(items, pubkey)
1875 .map(|report| report.inserted)
1876 }
1877
1878 pub fn put_cached_blob_with_inserted(&self, data: &[u8]) -> Result<(String, bool)> {
1884 let hash = sha256(data);
1885 let incoming_bytes = data.len() as u64;
1886
1887 if !self
1892 .router
1893 .exists(&hash)
1894 .map_err(|e| anyhow::anyhow!("Failed to check cached blob: {}", e))?
1895 {
1896 self.make_room_for_cached_blob(incoming_bytes)?;
1897 }
1898
1899 let mut retried_after_cleanup = false;
1900 loop {
1901 match self.router.put_sync(hash, data) {
1902 Ok(inserted) => {
1903 if inserted {
1904 if let Err(err) =
1905 self.enforce_cached_blob_budget_after_insert(incoming_bytes)
1906 {
1907 tracing::debug!("Failed to enforce cached blob budget: {}", err);
1908 }
1909 }
1910 return Ok((to_hex(&hash), inserted));
1911 }
1912 Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
1913 let freed = self.relieve_cached_blob_write_pressure(incoming_bytes)?;
1914 if freed == 0 {
1915 return Err(anyhow::anyhow!("Failed to store cached blob: {}", err));
1916 }
1917 retried_after_cleanup = true;
1918 }
1919 Err(err) => return Err(anyhow::anyhow!("Failed to store cached blob: {}", err)),
1920 }
1921 }
1922 }
1923
1924 pub fn put_cached_blob(&self, data: &[u8]) -> Result<String> {
1925 self.put_cached_blob_with_inserted(data)
1926 .map(|(hash, _)| hash)
1927 }
1928
1929 pub fn put_cached_blobs_report(&self, items: &[(Hash, Vec<u8>)]) -> Result<PutManyReport> {
1931 let started_at = Instant::now();
1932 let slow_log_ms = slow_cached_blob_batch_log_ms();
1933 if items.is_empty() {
1934 return Ok(PutManyReport::default());
1935 }
1936
1937 let candidate_bytes = items.iter().fold(0u64, |total, (_, data)| {
1938 total.saturating_add(data.len() as u64)
1939 });
1940
1941 let mut retried_after_cleanup = false;
1942 loop {
1943 let raw_started = Instant::now();
1944 match self.router.put_many_report_sync(items) {
1945 Ok(report) => {
1946 let raw_write_ms = raw_started.elapsed().as_millis();
1947 let quota_started = Instant::now();
1948 if report.inserted_bytes > 0 {
1949 if let Err(err) =
1950 self.enforce_cached_blob_budget_after_insert(report.inserted_bytes)
1951 {
1952 tracing::debug!("Failed to enforce cached blob budget: {}", err);
1953 }
1954 }
1955 let quota_ms = quota_started.elapsed().as_millis();
1956 let total_ms = started_at.elapsed().as_millis();
1957 if slow_log_ms.is_some_and(|threshold| total_ms >= threshold) {
1958 tracing::warn!(
1959 blobs = items.len(),
1960 inserted = report.inserted,
1961 candidate_bytes,
1962 inserted_bytes = report.inserted_bytes,
1963 total_ms,
1964 raw_write_ms,
1965 quota_ms,
1966 "slow cached Blossom blob batch write"
1967 );
1968 }
1969 return Ok(report);
1970 }
1971 Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
1972 let freed = self.relieve_cached_blob_write_pressure(candidate_bytes)?;
1973 if freed == 0 {
1974 return Err(anyhow::anyhow!(
1975 "Failed to store cached blob batch: {}",
1976 err
1977 ));
1978 }
1979 retried_after_cleanup = true;
1980 }
1981 Err(err) => {
1982 return Err(anyhow::anyhow!(
1983 "Failed to store cached blob batch: {}",
1984 err
1985 ));
1986 }
1987 }
1988 }
1989 }
1990
1991 pub fn put_cached_blobs(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize> {
1993 self.put_cached_blobs_report(items)
1994 .map(|report| report.inserted)
1995 }
1996
1997 pub fn get_blob(&self, hash: &[u8; 32]) -> Result<Option<Vec<u8>>> {
1999 let data = self
2000 .router
2001 .get_sync(hash)
2002 .map_err(|e| anyhow::anyhow!("Failed to get blob: {}", e))?;
2003 if data.is_some() {
2004 self.record_blob_accesses(std::iter::once(*hash));
2005 }
2006 Ok(data)
2007 }
2008
2009 pub fn get_blob_range(
2010 &self,
2011 hash: &[u8; 32],
2012 start: u64,
2013 end_inclusive: u64,
2014 ) -> Result<Option<Vec<u8>>> {
2015 let data = self
2016 .router
2017 .get_range_sync(hash, start, end_inclusive)
2018 .map_err(|e| anyhow::anyhow!("Failed to get blob range: {}", e))?;
2019 if data.is_some() {
2020 self.record_blob_accesses(std::iter::once(*hash));
2021 }
2022 Ok(data)
2023 }
2024
2025 pub fn blob_size(&self, hash: &[u8; 32]) -> Result<Option<u64>> {
2026 self.router
2027 .blob_size_sync(hash)
2028 .map_err(|e| anyhow::anyhow!("Failed to get blob size: {}", e))
2029 }
2030
2031 pub fn blob_exists(&self, hash: &[u8; 32]) -> Result<bool> {
2033 self.router
2034 .exists(hash)
2035 .map_err(|e| anyhow::anyhow!("Failed to check blob: {}", e))
2036 }
2037
2038 fn blob_owner_key(sha256: &[u8; 32], pubkey: &[u8; 32]) -> [u8; 64] {
2044 let mut key = [0u8; 64];
2045 key[..32].copy_from_slice(sha256);
2046 key[32..].copy_from_slice(pubkey);
2047 key
2048 }
2049
2050 fn pubkey_blob_key(pubkey: &[u8; 32], sha256: &[u8; 32]) -> [u8; 64] {
2051 let mut key = [0u8; 64];
2052 key[..32].copy_from_slice(pubkey);
2053 key[32..].copy_from_slice(sha256);
2054 key
2055 }
2056
2057 pub fn set_blob_owner(&self, sha256: &[u8; 32], pubkey: &[u8; 32]) -> Result<()> {
2060 let size = self
2061 .router
2062 .blob_size_sync(sha256)
2063 .map_err(|e| anyhow::anyhow!("Failed to get blob size: {}", e))?
2064 .unwrap_or(0);
2065 self.set_blob_owner_with_size(sha256, pubkey, size)
2066 }
2067
2068 fn set_blob_owner_with_size(
2069 &self,
2070 sha256: &[u8; 32],
2071 pubkey: &[u8; 32],
2072 size: u64,
2073 ) -> Result<()> {
2074 let key = Self::blob_owner_key(sha256, pubkey);
2075 let index_key = Self::pubkey_blob_key(pubkey, sha256);
2076 let mut wtxn = self.env.write_txn()?;
2077
2078 match self
2079 .blob_owners
2080 .put_with_flags(&mut wtxn, PutFlags::NO_OVERWRITE, &key[..], &())
2081 {
2082 Ok(()) => {}
2083 Err(HeedError::Mdb(MdbError::KeyExist)) => {
2084 wtxn.commit()?;
2085 return Ok(());
2086 }
2087 Err(error) => return Err(error.into()),
2088 }
2089
2090 let now = SystemTime::now()
2091 .duration_since(UNIX_EPOCH)
2092 .unwrap()
2093 .as_secs();
2094 let metadata = BlobMetadata {
2095 sha256: to_hex(sha256),
2096 size,
2097 mime_type: "application/octet-stream".to_string(),
2098 uploaded: now,
2099 };
2100 self.pubkey_blob_index
2101 .put(&mut wtxn, &index_key[..], &serde_json::to_vec(&metadata)?)?;
2102
2103 wtxn.commit()?;
2104 Ok(())
2105 }
2106
2107 pub fn is_blob_owner(&self, sha256: &[u8; 32], pubkey: &[u8; 32]) -> Result<bool> {
2109 let key = Self::blob_owner_key(sha256, pubkey);
2110 let rtxn = self.env.read_txn()?;
2111 Ok(self.blob_owners.get(&rtxn, &key[..])?.is_some())
2112 }
2113
2114 pub fn get_blob_owners(&self, sha256: &[u8; 32]) -> Result<Vec<[u8; 32]>> {
2116 let rtxn = self.env.read_txn()?;
2117
2118 let mut owners = Vec::new();
2119 for item in self.blob_owners.prefix_iter(&rtxn, &sha256[..])? {
2120 let (key, _) = item?;
2121 if key.len() == 64 {
2122 let mut pubkey = [0u8; 32];
2124 pubkey.copy_from_slice(&key[32..64]);
2125 owners.push(pubkey);
2126 }
2127 }
2128 Ok(owners)
2129 }
2130
2131 pub fn blob_has_owners(&self, sha256: &[u8; 32]) -> Result<bool> {
2133 let rtxn = self.env.read_txn()?;
2134
2135 for item in self.blob_owners.prefix_iter(&rtxn, &sha256[..])? {
2137 if item.is_ok() {
2138 return Ok(true);
2139 }
2140 }
2141 Ok(false)
2142 }
2143
2144 pub fn get_blob_owner(&self, sha256: &[u8; 32]) -> Result<Option<[u8; 32]>> {
2146 Ok(self.get_blob_owners(sha256)?.into_iter().next())
2147 }
2148
2149 pub fn delete_blossom_blob(&self, sha256: &[u8; 32], pubkey: &[u8; 32]) -> Result<bool> {
2153 let key = Self::blob_owner_key(sha256, pubkey);
2154 let mut wtxn = self.env.write_txn()?;
2155
2156 self.blob_owners.delete(&mut wtxn, &key[..])?;
2158 self.pubkey_blob_index
2159 .delete(&mut wtxn, &Self::pubkey_blob_key(pubkey, sha256)[..])?;
2160
2161 let sha256_hex = to_hex(sha256);
2163
2164 if let Some(blobs_bytes) = self.pubkey_blobs.get(&wtxn, pubkey)? {
2166 if let Ok(mut blobs) = serde_json::from_slice::<Vec<BlobMetadata>>(blobs_bytes) {
2167 blobs.retain(|b| b.sha256 != sha256_hex);
2168 let blobs_json = serde_json::to_vec(&blobs)?;
2169 self.pubkey_blobs.put(&mut wtxn, pubkey, &blobs_json)?;
2170 }
2171 }
2172
2173 let mut has_other_owners = false;
2175 for item in self.blob_owners.prefix_iter(&wtxn, &sha256[..])? {
2176 if item.is_ok() {
2177 has_other_owners = true;
2178 break;
2179 }
2180 }
2181
2182 if has_other_owners {
2183 wtxn.commit()?;
2184 tracing::debug!(
2185 "Removed {} from blob {} owners, other owners remain",
2186 &to_hex(pubkey)[..8],
2187 &sha256_hex[..8]
2188 );
2189 return Ok(false);
2190 }
2191
2192 tracing::info!(
2194 "All owners removed from blob {}, deleting",
2195 &sha256_hex[..8]
2196 );
2197
2198 let _ = self.router.delete_sync(sha256);
2200
2201 wtxn.commit()?;
2202 Ok(true)
2203 }
2204
2205 pub fn list_blobs_by_pubkey(
2207 &self,
2208 pubkey: &[u8; 32],
2209 ) -> Result<Vec<crate::server::blossom::BlobDescriptor>> {
2210 let rtxn = self.env.read_txn()?;
2211
2212 let mut blobs: Vec<BlobMetadata> = self
2213 .pubkey_blobs
2214 .get(&rtxn, pubkey)?
2215 .and_then(|b| serde_json::from_slice(b).ok())
2216 .unwrap_or_default();
2217 let mut seen: HashSet<String> = blobs.iter().map(|blob| blob.sha256.clone()).collect();
2218
2219 for item in self.pubkey_blob_index.prefix_iter(&rtxn, pubkey)? {
2220 let (_, metadata_bytes) = item?;
2221 let metadata: BlobMetadata = match serde_json::from_slice(metadata_bytes) {
2222 Ok(metadata) => metadata,
2223 Err(_) => continue,
2224 };
2225 if seen.insert(metadata.sha256.clone()) {
2226 blobs.push(metadata);
2227 }
2228 }
2229
2230 Ok(blobs
2231 .into_iter()
2232 .map(|b| crate::server::blossom::BlobDescriptor {
2233 url: format!("/{}", b.sha256),
2234 sha256: b.sha256,
2235 size: b.size,
2236 mime_type: b.mime_type,
2237 uploaded: b.uploaded,
2238 })
2239 .collect())
2240 }
2241
2242 pub fn get_chunk(&self, hash: &[u8; 32]) -> Result<Option<Vec<u8>>> {
2244 let data = self
2245 .router
2246 .get_sync(hash)
2247 .map_err(|e| anyhow::anyhow!("Failed to get chunk: {}", e))?;
2248 if data.is_some() {
2249 self.record_blob_accesses(std::iter::once(*hash));
2250 }
2251 Ok(data)
2252 }
2253
2254 pub fn get_file(&self, hash: &[u8; 32]) -> Result<Option<Vec<u8>>> {
2257 let (tree, access_store) = self.access_tracking_tree();
2258
2259 let result = sync_block_on(async {
2260 tree.read_file(hash)
2261 .await
2262 .map_err(|e| anyhow::anyhow!("Failed to read file: {}", e))
2263 })?;
2264 if result.is_some() {
2265 self.record_blob_accesses(access_store.take_accessed_hashes());
2266 }
2267 Ok(result)
2268 }
2269
2270 pub fn get_file_by_cid(&self, cid: &Cid) -> Result<Option<Vec<u8>>> {
2273 let (tree, access_store) = self.access_tracking_tree();
2274
2275 let result = sync_block_on(async {
2276 tree.get(cid, None)
2277 .await
2278 .map_err(|e| anyhow::anyhow!("Failed to read file: {}", e))
2279 })?;
2280 if result.is_some() {
2281 self.record_blob_accesses(access_store.take_accessed_hashes());
2282 }
2283 Ok(result)
2284 }
2285
2286 fn ensure_cid_exists(&self, cid: &Cid) -> Result<()> {
2287 let exists = self
2288 .router
2289 .exists(&cid.hash)
2290 .map_err(|e| anyhow::anyhow!("Failed to check cid existence: {}", e))?;
2291 if !exists {
2292 anyhow::bail!("CID not found: {}", to_hex(&cid.hash));
2293 }
2294 Ok(())
2295 }
2296
2297 pub fn write_file_by_cid_to_writer<W: Write>(&self, cid: &Cid, writer: &mut W) -> Result<u64> {
2299 self.ensure_cid_exists(cid)?;
2300
2301 let (tree, access_store) = self.access_tracking_tree();
2302 let mut total_bytes = 0u64;
2303 let mut streamed_any_chunk = false;
2304
2305 sync_block_on(async {
2306 let mut stream = tree.get_stream(cid);
2307 while let Some(chunk) = stream.next().await {
2308 streamed_any_chunk = true;
2309 let chunk =
2310 chunk.map_err(|e| anyhow::anyhow!("Failed to stream file chunk: {}", e))?;
2311 writer
2312 .write_all(&chunk)
2313 .map_err(|e| anyhow::anyhow!("Failed to write file chunk: {}", e))?;
2314 total_bytes += chunk.len() as u64;
2315 }
2316 Ok::<(), anyhow::Error>(())
2317 })?;
2318
2319 if !streamed_any_chunk {
2320 anyhow::bail!("CID not found: {}", to_hex(&cid.hash));
2321 }
2322 self.record_blob_accesses(access_store.take_accessed_hashes());
2323
2324 writer
2325 .flush()
2326 .map_err(|e| anyhow::anyhow!("Failed to flush output: {}", e))?;
2327 Ok(total_bytes)
2328 }
2329
2330 pub fn write_file_by_cid<P: AsRef<Path>>(&self, cid: &Cid, output_path: P) -> Result<u64> {
2332 self.ensure_cid_exists(cid)?;
2333
2334 let output_path = output_path.as_ref();
2335 if let Some(parent) = output_path.parent() {
2336 if !parent.as_os_str().is_empty() {
2337 std::fs::create_dir_all(parent).with_context(|| {
2338 format!("Failed to create output directory {}", parent.display())
2339 })?;
2340 }
2341 }
2342
2343 let mut file = std::fs::File::create(output_path)
2344 .with_context(|| format!("Failed to create output file {}", output_path.display()))?;
2345 self.write_file_by_cid_to_writer(cid, &mut file)
2346 }
2347
2348 pub fn write_file<P: AsRef<Path>>(&self, hash: &[u8; 32], output_path: P) -> Result<u64> {
2350 self.write_file_by_cid(&Cid::public(*hash), output_path)
2351 }
2352
2353 pub fn resolve_path(&self, cid: &Cid, path: &str) -> Result<Option<Cid>> {
2355 let (tree, access_store) = self.access_tracking_tree();
2356
2357 let result = sync_block_on(async {
2358 tree.resolve_path(cid, path)
2359 .await
2360 .map_err(|e| anyhow::anyhow!("Failed to resolve path: {}", e))
2361 })?;
2362 if result.is_some() {
2363 self.record_blob_accesses(access_store.take_accessed_hashes());
2364 }
2365 Ok(result)
2366 }
2367
2368 pub fn get_file_chunk_metadata(
2370 &self,
2371 hash: &[u8; 32],
2372 ) -> Result<Option<Arc<FileChunkMetadata>>> {
2373 if let Ok(mut cache) = self.file_metadata_cache.lock() {
2374 if let Some(metadata) = cache.get(hash).cloned() {
2375 self.record_blob_accesses(std::iter::once(*hash));
2376 return Ok(Some(metadata));
2377 }
2378 }
2379
2380 let access_store = AccessRecordingStore::new(self.store_arc());
2381 let tree = HashTree::new(HashTreeConfig::new(Arc::new(access_store.clone())).public());
2382
2383 let metadata: Result<Option<FileChunkMetadata>> = sync_block_on(async {
2384 let exists = access_store
2387 .has(hash)
2388 .await
2389 .map_err(|e| anyhow::anyhow!("Failed to check existence: {}", e))?;
2390
2391 if !exists {
2392 return Ok(None);
2393 }
2394
2395 let total_size = tree
2397 .get_size(hash)
2398 .await
2399 .map_err(|e| anyhow::anyhow!("Failed to get size: {}", e))?;
2400
2401 let is_tree_node = tree
2403 .is_tree(hash)
2404 .await
2405 .map_err(|e| anyhow::anyhow!("Failed to check tree: {}", e))?;
2406
2407 if !is_tree_node {
2408 return Ok(Some(FileChunkMetadata::single_blob(total_size)));
2410 }
2411
2412 let node = match tree
2414 .get_tree_node(hash)
2415 .await
2416 .map_err(|e| anyhow::anyhow!("Failed to get tree node: {}", e))?
2417 {
2418 Some(n) => n,
2419 None => return Ok(None),
2420 };
2421
2422 let is_directory = tree
2424 .is_directory(hash)
2425 .await
2426 .map_err(|e| anyhow::anyhow!("Failed to check directory: {}", e))?;
2427
2428 if is_directory {
2429 return Ok(None); }
2431
2432 let chunk_hashes: Vec<Hash> = node.links.iter().map(|l| l.hash).collect();
2434 let chunk_sizes: Vec<u64> = node.links.iter().map(|l| l.size).collect();
2435
2436 Ok(Some(FileChunkMetadata::new(
2437 total_size,
2438 chunk_hashes,
2439 chunk_sizes,
2440 )))
2441 });
2442 let metadata = metadata?;
2443 if metadata.is_some() {
2444 self.record_blob_accesses(access_store.take_accessed_hashes());
2445 }
2446 let Some(metadata) = metadata else {
2447 return Ok(None);
2448 };
2449 let metadata = Arc::new(metadata);
2450 if let Ok(mut cache) = self.file_metadata_cache.lock() {
2451 cache.put(*hash, Arc::clone(&metadata));
2452 }
2453 Ok(Some(metadata))
2454 }
2455
2456 pub fn get_file_range(
2458 &self,
2459 hash: &[u8; 32],
2460 start: u64,
2461 end: Option<u64>,
2462 ) -> Result<Option<(Vec<u8>, u64)>> {
2463 let metadata = match self.get_file_chunk_metadata(hash)? {
2464 Some(m) => m,
2465 None => return Ok(None),
2466 };
2467
2468 if metadata.total_size == 0 {
2469 return Ok(Some((Vec::new(), 0)));
2470 }
2471
2472 if start >= metadata.total_size {
2473 return Ok(None);
2474 }
2475
2476 let end = end
2477 .unwrap_or(metadata.total_size - 1)
2478 .min(metadata.total_size - 1);
2479
2480 if !metadata.is_chunked {
2482 let range_content = match self.get_blob_range(hash, start, end)? {
2483 Some(content) => content,
2484 None => return Ok(None),
2485 };
2486 return Ok(Some((range_content, metadata.total_size)));
2487 }
2488
2489 let mut result = Vec::new();
2491 let (start_idx, mut current_offset) = metadata.chunk_start_for_range(start);
2492
2493 for (i, chunk_hash) in metadata.chunk_hashes.iter().enumerate().skip(start_idx) {
2494 let chunk_size = metadata.chunk_sizes[i];
2495 let chunk_end = current_offset + chunk_size - 1;
2496
2497 if chunk_end >= start && current_offset <= end {
2499 let chunk_read_start = start.saturating_sub(current_offset);
2500
2501 let chunk_read_end = if chunk_end <= end {
2502 chunk_size - 1
2503 } else {
2504 end - current_offset
2505 };
2506
2507 let chunk_content =
2508 match self.get_blob_range(chunk_hash, chunk_read_start, chunk_read_end)? {
2509 Some(content) => content,
2510 None => {
2511 return Err(anyhow::anyhow!("Chunk {} not found", to_hex(chunk_hash)));
2512 }
2513 };
2514
2515 let expected_len = chunk_read_end.saturating_sub(chunk_read_start) + 1;
2516 if chunk_content.len() as u64 != expected_len {
2517 return Err(anyhow::anyhow!(
2518 "Chunk {} range returned {} bytes, expected {}",
2519 to_hex(chunk_hash),
2520 chunk_content.len(),
2521 expected_len
2522 ));
2523 }
2524
2525 result.extend_from_slice(&chunk_content);
2526 }
2527
2528 current_offset += chunk_size;
2529
2530 if current_offset > end {
2531 break;
2532 }
2533 }
2534
2535 Ok(Some((result, metadata.total_size)))
2536 }
2537
2538 pub fn stream_file_range_chunks_owned(
2540 self: Arc<Self>,
2541 hash: &[u8; 32],
2542 start: u64,
2543 end: u64,
2544 ) -> Result<Option<FileRangeChunksOwned>> {
2545 let metadata = match self.get_file_chunk_metadata(hash)? {
2546 Some(m) => m,
2547 None => return Ok(None),
2548 };
2549
2550 if metadata.total_size == 0 || start >= metadata.total_size {
2551 return Ok(None);
2552 }
2553
2554 let end = end.min(metadata.total_size - 1);
2555
2556 let (current_chunk_idx, current_offset) = metadata.chunk_start_for_range(start);
2557
2558 Ok(Some(FileRangeChunksOwned {
2559 store: self,
2560 metadata,
2561 start,
2562 end,
2563 current_chunk_idx,
2564 current_offset,
2565 }))
2566 }
2567
2568 pub fn get_directory_listing(&self, hash: &[u8; 32]) -> Result<Option<DirectoryListing>> {
2570 let (tree, access_store) = self.access_tracking_tree();
2571
2572 let listing: Result<Option<DirectoryListing>> = sync_block_on(async {
2573 let is_dir = tree
2575 .is_directory(hash)
2576 .await
2577 .map_err(|e| anyhow::anyhow!("Failed to check directory: {}", e))?;
2578
2579 if !is_dir {
2580 return Ok(None);
2581 }
2582
2583 let cid = hashtree_core::Cid::public(*hash);
2585 let tree_entries = tree
2586 .list_directory(&cid)
2587 .await
2588 .map_err(|e| anyhow::anyhow!("Failed to list directory: {}", e))?;
2589
2590 let entries: Vec<DirEntry> = tree_entries
2591 .into_iter()
2592 .map(|e| DirEntry {
2593 name: e.name,
2594 cid: to_hex(&e.hash),
2595 is_directory: e.link_type.is_tree(),
2596 size: e.size,
2597 })
2598 .collect();
2599
2600 Ok(Some(DirectoryListing {
2601 dir_name: String::new(),
2602 entries,
2603 }))
2604 });
2605 let listing = listing?;
2606 if listing.is_some() {
2607 self.record_blob_accesses(access_store.take_accessed_hashes());
2608 }
2609 Ok(listing)
2610 }
2611
2612 pub fn get_directory_listing_by_cid(&self, cid: &Cid) -> Result<Option<DirectoryListing>> {
2614 let (tree, access_store) = self.access_tracking_tree();
2615 let cid = cid.clone();
2616
2617 let listing: Result<Option<DirectoryListing>> = sync_block_on(async {
2618 let is_dir = tree
2619 .is_dir(&cid)
2620 .await
2621 .map_err(|e| anyhow::anyhow!("Failed to check directory: {}", e))?;
2622
2623 if !is_dir {
2624 return Ok(None);
2625 }
2626
2627 let tree_entries = tree
2628 .list_directory(&cid)
2629 .await
2630 .map_err(|e| anyhow::anyhow!("Failed to list directory: {}", e))?;
2631
2632 let entries: Vec<DirEntry> = tree_entries
2633 .into_iter()
2634 .map(|e| DirEntry {
2635 name: e.name,
2636 cid: Cid {
2637 hash: e.hash,
2638 key: e.key,
2639 }
2640 .to_string(),
2641 is_directory: e.link_type.is_tree(),
2642 size: e.size,
2643 })
2644 .collect();
2645
2646 Ok(Some(DirectoryListing {
2647 dir_name: String::new(),
2648 entries,
2649 }))
2650 });
2651 let listing = listing?;
2652 if listing.is_some() {
2653 self.record_blob_accesses(access_store.take_accessed_hashes());
2654 }
2655 Ok(listing)
2656 }
2657
2658 pub fn add_pinned_ref(&self, key: &str) -> Result<()> {
2662 let mut wtxn = self.env.write_txn()?;
2663 self.pinned_refs.put(&mut wtxn, key, &())?;
2664 wtxn.commit()?;
2665 Ok(())
2666 }
2667
2668 pub fn remove_pinned_ref(&self, key: &str) -> Result<bool> {
2670 let mut wtxn = self.env.write_txn()?;
2671 let removed = self.pinned_refs.delete(&mut wtxn, key)?;
2672 wtxn.commit()?;
2673 Ok(removed)
2674 }
2675
2676 pub fn list_pinned_refs(&self) -> Result<Vec<String>> {
2678 let rtxn = self.env.read_txn()?;
2679 let mut refs = Vec::new();
2680
2681 for item in self.pinned_refs.iter(&rtxn)? {
2682 let (key, _) = item?;
2683 refs.push(key.to_string());
2684 }
2685
2686 refs.sort();
2687 Ok(refs)
2688 }
2689
2690 pub fn add_tracked_author(&self, npub: &str) -> Result<bool> {
2692 let mut wtxn = self.env.write_txn()?;
2693 let inserted = self.tracked_authors.get(&wtxn, npub)?.is_none();
2694 self.tracked_authors.put(&mut wtxn, npub, &())?;
2695 wtxn.commit()?;
2696 Ok(inserted)
2697 }
2698
2699 pub fn remove_tracked_author(&self, npub: &str) -> Result<bool> {
2701 let mut wtxn = self.env.write_txn()?;
2702 let removed = self.tracked_authors.delete(&mut wtxn, npub)?;
2703 wtxn.commit()?;
2704 Ok(removed)
2705 }
2706
2707 pub fn list_tracked_authors(&self) -> Result<Vec<String>> {
2709 let rtxn = self.env.read_txn()?;
2710 let mut authors = Vec::new();
2711
2712 for item in self.tracked_authors.iter(&rtxn)? {
2713 let (npub, _) = item?;
2714 authors.push(npub.to_string());
2715 }
2716
2717 authors.sort();
2718 Ok(authors)
2719 }
2720
2721 pub fn get_cached_root(&self, pubkey_hex: &str, tree_name: &str) -> Result<Option<CachedRoot>> {
2723 let key = format!("{}/{}", pubkey_hex, tree_name);
2724 let rtxn = self.env.read_txn()?;
2725 if let Some(bytes) = self.cached_roots.get(&rtxn, &key)? {
2726 let root: CachedRoot = rmp_serde::from_slice(bytes)
2727 .map_err(|e| anyhow::anyhow!("Failed to deserialize CachedRoot: {}", e))?;
2728 Ok(Some(root))
2729 } else {
2730 Ok(None)
2731 }
2732 }
2733
2734 pub fn set_cached_root(
2736 &self,
2737 pubkey_hex: &str,
2738 tree_name: &str,
2739 hash: &str,
2740 key: Option<&str>,
2741 visibility: &str,
2742 updated_at: u64,
2743 ) -> Result<()> {
2744 let db_key = format!("{}/{}", pubkey_hex, tree_name);
2745 let root = CachedRoot {
2746 hash: hash.to_string(),
2747 key: key.map(|k| k.to_string()),
2748 updated_at,
2749 visibility: visibility.to_string(),
2750 };
2751 let bytes = rmp_serde::to_vec(&root)
2752 .map_err(|e| anyhow::anyhow!("Failed to serialize CachedRoot: {}", e))?;
2753 let mut wtxn = self.env.write_txn()?;
2754 self.cached_roots.put(&mut wtxn, &db_key, &bytes)?;
2755 wtxn.commit()?;
2756 Ok(())
2757 }
2758
2759 pub fn list_cached_roots(&self, pubkey_hex: &str) -> Result<Vec<(String, CachedRoot)>> {
2761 let prefix = format!("{}/", pubkey_hex);
2762 let rtxn = self.env.read_txn()?;
2763 let mut results = Vec::new();
2764
2765 for item in self.cached_roots.iter(&rtxn)? {
2766 let (key, bytes) = item?;
2767 if key.starts_with(&prefix) {
2768 let tree_name = key.strip_prefix(&prefix).unwrap_or(key);
2769 let root: CachedRoot = rmp_serde::from_slice(bytes)
2770 .map_err(|e| anyhow::anyhow!("Failed to deserialize CachedRoot: {}", e))?;
2771 results.push((tree_name.to_string(), root));
2772 }
2773 }
2774
2775 Ok(results)
2776 }
2777
2778 pub fn delete_cached_root(&self, pubkey_hex: &str, tree_name: &str) -> Result<bool> {
2780 let key = format!("{}/{}", pubkey_hex, tree_name);
2781 let mut wtxn = self.env.write_txn()?;
2782 let deleted = self.cached_roots.delete(&mut wtxn, &key)?;
2783 wtxn.commit()?;
2784 Ok(deleted)
2785 }
2786}
2787
2788fn is_map_full_store_error(err: &StoreError) -> bool {
2789 let message = err.to_string();
2790 message.contains("MDB_MAP_FULL") || message.contains("MapFull")
2791}
2792
2793#[derive(Debug, Clone)]
2794pub struct FileChunkMetadata {
2795 pub total_size: u64,
2796 pub chunk_hashes: Vec<Hash>,
2797 pub chunk_sizes: Vec<u64>,
2798 pub is_chunked: bool,
2799 uniform_chunk_size: Option<u64>,
2800}
2801
2802impl FileChunkMetadata {
2803 fn new(total_size: u64, chunk_hashes: Vec<Hash>, chunk_sizes: Vec<u64>) -> Self {
2804 let is_chunked = !chunk_hashes.is_empty();
2805 let uniform_chunk_size = uniform_chunk_size(&chunk_sizes);
2806 Self {
2807 total_size,
2808 chunk_hashes,
2809 chunk_sizes,
2810 is_chunked,
2811 uniform_chunk_size,
2812 }
2813 }
2814
2815 fn single_blob(total_size: u64) -> Self {
2816 Self {
2817 total_size,
2818 chunk_hashes: Vec::new(),
2819 chunk_sizes: Vec::new(),
2820 is_chunked: false,
2821 uniform_chunk_size: None,
2822 }
2823 }
2824
2825 fn chunk_start_for_range(&self, start: u64) -> (usize, u64) {
2826 if !self.is_chunked || self.chunk_sizes.is_empty() {
2827 return (0, 0);
2828 }
2829
2830 if let Some(chunk_size) = self.uniform_chunk_size {
2831 let index = start
2832 .checked_div(chunk_size)
2833 .unwrap_or(0)
2834 .min(self.chunk_sizes.len().saturating_sub(1) as u64)
2835 as usize;
2836 return (index, chunk_size.saturating_mul(index as u64));
2837 }
2838
2839 let mut offset = 0u64;
2840 for (index, chunk_size) in self.chunk_sizes.iter().copied().enumerate() {
2841 let next_offset = offset.saturating_add(chunk_size);
2842 if start < next_offset {
2843 return (index, offset);
2844 }
2845 offset = next_offset;
2846 }
2847
2848 (self.chunk_sizes.len(), offset)
2849 }
2850}
2851
2852fn uniform_chunk_size(chunk_sizes: &[u64]) -> Option<u64> {
2853 let (&first, rest) = chunk_sizes.split_first()?;
2854 if first == 0 {
2855 return None;
2856 }
2857 if rest.is_empty() {
2858 return Some(first);
2859 }
2860 let (last, prefix) = rest.split_last()?;
2861 if prefix.iter().any(|size| *size != first) || *last > first {
2862 return None;
2863 }
2864 Some(first)
2865}
2866
2867pub struct FileRangeChunksOwned {
2869 store: Arc<HashtreeStore>,
2870 metadata: Arc<FileChunkMetadata>,
2871 start: u64,
2872 end: u64,
2873 current_chunk_idx: usize,
2874 current_offset: u64,
2875}
2876
2877impl Iterator for FileRangeChunksOwned {
2878 type Item = Result<Vec<u8>>;
2879
2880 fn next(&mut self) -> Option<Self::Item> {
2881 if !self.metadata.is_chunked || self.current_chunk_idx >= self.metadata.chunk_hashes.len() {
2882 return None;
2883 }
2884
2885 if self.current_offset > self.end {
2886 return None;
2887 }
2888
2889 let chunk_hash = &self.metadata.chunk_hashes[self.current_chunk_idx];
2890 let chunk_size = self.metadata.chunk_sizes[self.current_chunk_idx];
2891 let chunk_end = self.current_offset + chunk_size - 1;
2892
2893 self.current_chunk_idx += 1;
2894
2895 if chunk_end < self.start || self.current_offset > self.end {
2896 self.current_offset += chunk_size;
2897 return self.next();
2898 }
2899
2900 let chunk_read_start = self.start.saturating_sub(self.current_offset);
2901
2902 let chunk_read_end = if chunk_end <= self.end {
2903 chunk_size - 1
2904 } else {
2905 self.end - self.current_offset
2906 };
2907
2908 let chunk_content =
2909 match self
2910 .store
2911 .get_blob_range(chunk_hash, chunk_read_start, chunk_read_end)
2912 {
2913 Ok(Some(content)) => content,
2914 Ok(None) => {
2915 return Some(Err(anyhow::anyhow!(
2916 "Chunk {} not found",
2917 to_hex(chunk_hash)
2918 )));
2919 }
2920 Err(e) => {
2921 return Some(Err(e));
2922 }
2923 };
2924
2925 let expected_len = chunk_read_end.saturating_sub(chunk_read_start) + 1;
2926 if chunk_content.len() as u64 != expected_len {
2927 return Some(Err(anyhow::anyhow!(
2928 "Chunk {} range returned {} bytes, expected {}",
2929 to_hex(chunk_hash),
2930 chunk_content.len(),
2931 expected_len
2932 )));
2933 }
2934
2935 let result = chunk_content;
2936 self.current_offset += chunk_size;
2937
2938 Some(Ok(result))
2939 }
2940}
2941
2942#[derive(Debug)]
2943pub struct GcStats {
2944 pub deleted_dags: usize,
2945 pub freed_bytes: u64,
2946}
2947
2948#[derive(Debug, Clone)]
2949pub struct DirEntry {
2950 pub name: String,
2951 pub cid: String,
2952 pub is_directory: bool,
2953 pub size: u64,
2954}
2955
2956#[derive(Debug, Clone)]
2957pub struct DirectoryListing {
2958 pub dir_name: String,
2959 pub entries: Vec<DirEntry>,
2960}
2961
2962#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2964pub struct BlobMetadata {
2965 pub sha256: String,
2966 pub size: u64,
2967 pub mime_type: String,
2968 pub uploaded: u64,
2969}
2970
2971#[cfg(test)]
2972mod tests {
2973 use super::*;
2974 #[cfg(feature = "lmdb")]
2975 use tempfile::TempDir;
2976
2977 #[cfg(feature = "lmdb")]
2978 #[test]
2979 fn drop_closes_mutable_metadata_and_blob_environments() -> Result<()> {
2980 let temp = TempDir::new()?;
2981 let root = temp.path().join("store");
2982 let store = HashtreeStore::with_options(&root, None, LMDB_BLOB_MIN_MAP_SIZE_BYTES)?;
2983 let metadata_path = std::fs::canonicalize(&root)?;
2984 let pool_path = std::fs::canonicalize(root.join("blob-pool-v1"))?;
2985 let member_path = std::fs::canonicalize(root.join("blobs"))?;
2986
2987 drop(store);
2988
2989 assert!(
2990 heed::env_closing_event(metadata_path).is_none(),
2991 "mutable application metadata environment must close"
2992 );
2993 assert!(
2994 heed::env_closing_event(pool_path).is_none(),
2995 "blob pool catalog environment must close"
2996 );
2997 assert!(
2998 heed::env_closing_event(member_path).is_none(),
2999 "blob pool member environment must close"
3000 );
3001 Ok(())
3002 }
3003
3004 #[test]
3005 fn blob_access_update_gate_deduplicates_and_throttles() {
3006 let gate = BlobAccessUpdateGate::default();
3007 let first = sha256(b"first");
3008 let second = sha256(b"second");
3009
3010 assert_eq!(
3011 gate.due_hashes([first, first, second], 10),
3012 vec![first, second]
3013 );
3014 assert!(gate.due_hashes([first, second], 11).is_empty());
3015 assert_eq!(
3016 gate.due_hashes([second, first], 10 + ACCESS_UPDATE_INTERVAL_SECS),
3017 vec![second, first]
3018 );
3019 }
3020
3021 #[cfg(feature = "lmdb")]
3022 #[test]
3023 fn file_range_reads_reuse_metadata_and_seek_to_uniform_chunk() -> Result<()> {
3024 let temp = TempDir::new()?;
3025 let store = Arc::new(HashtreeStore::with_options_and_backend(
3026 temp.path(),
3027 None,
3028 LMDB_BLOB_MIN_MAP_SIZE_BYTES,
3029 true,
3030 &StorageBackend::Fs,
3031 )?);
3032 let tree = HashTree::new(
3033 HashTreeConfig::new(store.store_arc())
3034 .with_chunk_size(4)
3035 .public(),
3036 );
3037 let data = (0u8..20).collect::<Vec<_>>();
3038 let (cid, _) = sync_block_on(tree.put_file(&data))?;
3039
3040 let first = store.get_file_chunk_metadata(&cid.hash)?.unwrap();
3041 let second = store.get_file_chunk_metadata(&cid.hash)?.unwrap();
3042 assert!(
3043 Arc::ptr_eq(&first, &second),
3044 "hot file metadata should be returned from the in-process cache"
3045 );
3046 assert_eq!(first.uniform_chunk_size, Some(4));
3047 assert_eq!(first.chunk_start_for_range(14), (3, 12));
3048
3049 let mut chunks = Arc::clone(&store)
3050 .stream_file_range_chunks_owned(&cid.hash, 14, 17)?
3051 .unwrap();
3052 assert_eq!(chunks.current_chunk_idx, 3);
3053 assert_eq!(chunks.current_offset, 12);
3054 assert_eq!(chunks.next().unwrap()?, vec![14, 15]);
3055 assert_eq!(chunks.next().unwrap()?, vec![16, 17]);
3056 assert!(chunks.next().is_none());
3057
3058 let (range, total_size) = store.get_file_range(&cid.hash, 14, Some(17))?.unwrap();
3059 assert_eq!(total_size, data.len() as u64);
3060 assert_eq!(range, vec![14, 15, 16, 17]);
3061
3062 Ok(())
3063 }
3064
3065 #[cfg(feature = "lmdb")]
3066 #[test]
3067 fn hashtree_store_expands_blob_lmdb_map_size_to_storage_budget() -> Result<()> {
3068 let temp = TempDir::new()?;
3069 let requested = LMDB_BLOB_MIN_MAP_SIZE_BYTES + 64 * 1024 * 1024;
3070 let store = HashtreeStore::with_options_and_backend(
3071 temp.path(),
3072 None,
3073 requested,
3074 true,
3075 &StorageBackend::Lmdb,
3076 )?;
3077
3078 let map_size = match store.router.local.as_ref() {
3079 LocalStore::Lmdb(local) => local.map_size_bytes() as u64,
3080 LocalStore::Pool(pool) => {
3081 pool.largest_member_map_size_bytes()?
3082 .expect("fresh pool should have a blob member") as u64
3083 }
3084 LocalStore::Fs(_) => panic!("expected LMDB local store"),
3085 };
3086
3087 assert!(
3088 map_size >= requested,
3089 "expected blob LMDB map to grow to at least {requested} bytes, got {map_size}"
3090 );
3091
3092 drop(store);
3093 Ok(())
3094 }
3095
3096 #[cfg(feature = "lmdb")]
3097 #[test]
3098 fn hashtree_store_expands_metadata_lmdb_map_size_to_storage_budget() -> Result<()> {
3099 let temp = TempDir::new()?;
3100 let storage_budget = 256 * 1024 * 1024 * 1024u64;
3101 let expected = lmdb_metadata_map_size_for_storage_budget(storage_budget);
3102 let store = HashtreeStore::with_options_and_backend(
3103 temp.path(),
3104 None,
3105 storage_budget,
3106 true,
3107 &StorageBackend::Lmdb,
3108 )?;
3109
3110 let map_size = store.env.info().map_size as u64;
3111 assert!(
3112 map_size >= expected,
3113 "expected metadata LMDB map to grow to at least {expected} bytes, got {map_size}"
3114 );
3115
3116 drop(store);
3117 Ok(())
3118 }
3119
3120 #[cfg(feature = "lmdb")]
3121 #[test]
3122 fn embedded_store_uses_filesystem_blobs_and_no_lmdb_lock() -> Result<()> {
3123 let temp = TempDir::new()?;
3124 let store =
3125 HashtreeStore::with_embedded_options(temp.path(), None, LMDB_BLOB_MIN_MAP_SIZE_BYTES)?;
3126
3127 assert_eq!(store.router.local_store().backend(), StorageBackend::Fs);
3128 let flags = store.env.flags()?.unwrap_or(EnvFlags::empty());
3129 assert!(flags.contains(EnvFlags::NO_LOCK));
3130
3131 drop(store);
3132 Ok(())
3133 }
3134
3135 #[cfg(feature = "lmdb")]
3136 #[test]
3137 fn lmdb_map_size_for_existing_env_keeps_matching_requested_size() -> Result<()> {
3138 let temp = TempDir::new()?;
3139 let requested = LMDB_METADATA_MIN_MAP_SIZE_BYTES;
3140 std::fs::File::create(temp.path().join("data.mdb"))?.set_len(requested)?;
3141
3142 let map_size = lmdb_map_size_for_existing_env(temp.path(), requested)? as u64;
3143
3144 assert_eq!(map_size, align_lmdb_map_size(requested));
3145 Ok(())
3146 }
3147
3148 #[cfg(feature = "lmdb")]
3149 #[test]
3150 fn lmdb_map_size_for_existing_env_adds_headroom_when_existing_is_larger() -> Result<()> {
3151 let temp = TempDir::new()?;
3152 let requested = LMDB_METADATA_MIN_MAP_SIZE_BYTES;
3153 let existing = requested + 4096;
3154 std::fs::File::create(temp.path().join("data.mdb"))?.set_len(existing)?;
3155
3156 let map_size = lmdb_map_size_for_existing_env(temp.path(), requested)? as u64;
3157 let expected = align_lmdb_map_size(existing + LMDB_METADATA_REOPEN_HEADROOM_BYTES);
3158
3159 assert_eq!(map_size, expected);
3160 Ok(())
3161 }
3162
3163 #[cfg(feature = "lmdb")]
3164 #[test]
3165 fn local_store_can_override_lmdb_map_size() -> Result<()> {
3166 let temp = TempDir::new()?;
3167 let requested = 512 * 1024 * 1024u64;
3168 let store = LocalStore::new_with_lmdb_map_size(
3169 temp.path().join("lmdb-blobs"),
3170 &StorageBackend::Lmdb,
3171 Some(requested),
3172 )?;
3173
3174 let map_size = match store {
3175 LocalStore::Lmdb(local) => local.map_size_bytes() as u64,
3176 LocalStore::Pool(pool) => {
3177 pool.largest_member_map_size_bytes()?
3178 .expect("fresh pool should have a blob member") as u64
3179 }
3180 LocalStore::Fs(_) => panic!("expected LMDB local store"),
3181 };
3182
3183 assert!(
3184 map_size >= requested,
3185 "expected LMDB map to grow to at least {requested} bytes, got {map_size}"
3186 );
3187
3188 Ok(())
3189 }
3190
3191 #[cfg(feature = "lmdb")]
3192 #[test]
3193 fn local_add_reopen_options_leave_ordinary_writes_inline() -> Result<()> {
3194 let temp = TempDir::new()?;
3195 let store_path = temp.path().join("blobs");
3196 let external_path = temp.path().join(LOCAL_ADD_EXTERNAL_BLOB_DIR_NAME);
3197 let store = LmdbBlobStore::with_external_blob_options(
3198 &store_path,
3199 Some(local_add_external_blob_reopen_options(&store_path)),
3200 )?;
3201 let data = vec![7; 192 * 1024];
3202 let hash = sha256(&data);
3203
3204 assert!(store.put_sync(hash, &data)?);
3205 assert_eq!(store.get_sync(&hash)?, Some(data));
3206 assert!(!external_path.exists());
3207 Ok(())
3208 }
3209
3210 #[cfg(feature = "lmdb")]
3211 #[test]
3212 fn lmdb_local_store_removes_stale_fs_blob_shard_dirs() -> Result<()> {
3213 let temp = TempDir::new()?;
3214 let path = temp.path().join("lmdb-blobs");
3215 std::fs::create_dir_all(path.join("aa"))?;
3216 std::fs::create_dir_all(path.join("b2"))?;
3217 std::fs::create_dir_all(path.join("keep-me"))?;
3218 std::fs::write(path.join("aa").join("blob.bin"), b"old fs shard")?;
3219 std::fs::write(path.join("b2").join("blob.bin"), b"old fs shard")?;
3220 std::fs::write(path.join("keep-me").join("note.txt"), b"keep")?;
3221
3222 let _store = LocalStore::new_with_lmdb_map_size(
3223 &path,
3224 &StorageBackend::Lmdb,
3225 Some(128 * 1024 * 1024),
3226 )?;
3227
3228 assert!(!path.join("aa").exists());
3229 assert!(!path.join("b2").exists());
3230 assert!(path.join("keep-me").exists());
3231 assert!(path.join("data.mdb").exists());
3232 assert!(path.join("lock.mdb").exists());
3233
3234 Ok(())
3235 }
3236
3237 #[cfg(feature = "lmdb")]
3238 #[test]
3239 fn duplicate_blossom_writes_do_not_refresh_blob_last_accessed() -> Result<()> {
3240 let temp = TempDir::new()?;
3241 let store = HashtreeStore::with_options_and_backend(
3242 temp.path(),
3243 None,
3244 LMDB_BLOB_MIN_MAP_SIZE_BYTES,
3245 true,
3246 &StorageBackend::Lmdb,
3247 )?;
3248
3249 let raw = b"raw duplicate";
3250 let raw_hash = sha256(raw);
3251 store.put_blob(raw)?;
3252 let raw_accessed = store.blob_last_accessed_at(&raw_hash)?;
3253 store.put_blob(raw)?;
3254 assert_eq!(store.blob_last_accessed_at(&raw_hash)?, raw_accessed);
3255
3256 let data = b"cached blossom duplicate";
3257 let hash = sha256(data);
3258 store.put_cached_blob(data)?;
3259 let cached_accessed = store.blob_last_accessed_at(&hash)?;
3260 store.put_cached_blob(data)?;
3261 assert_eq!(store.blob_last_accessed_at(&hash)?, cached_accessed);
3262
3263 let cached_batch = [
3264 (
3265 sha256(b"cached blossom batch 1"),
3266 b"cached blossom batch 1".to_vec(),
3267 ),
3268 (
3269 sha256(b"cached blossom batch 2"),
3270 b"cached blossom batch 2".to_vec(),
3271 ),
3272 ];
3273 assert_eq!(store.put_cached_blobs(&cached_batch)?, 2);
3274 assert_eq!(store.put_cached_blobs(&cached_batch)?, 0);
3275 assert_eq!(
3276 store.get_blob(&cached_batch[0].0)?.as_deref(),
3277 Some(cached_batch[0].1.as_slice())
3278 );
3279
3280 let owned = b"owned blossom duplicate";
3281 let owned_hash = sha256(owned);
3282 let owner = [7u8; 32];
3283 store.put_owned_blob(owned, &owner)?;
3284 let owned_accessed = store.blob_last_accessed_at(&owned_hash)?;
3285 store.put_owned_blob(owned, &owner)?;
3286 assert_eq!(store.blob_last_accessed_at(&owned_hash)?, owned_accessed);
3287 let owned_blobs = store.list_blobs_by_pubkey(&owner)?;
3288 assert_eq!(owned_blobs.len(), 1);
3289 assert_eq!(owned_blobs[0].sha256, to_hex(&owned_hash));
3290
3291 let other_owner = [8u8; 32];
3292 store.put_owned_blob(owned, &other_owner)?;
3293 assert_eq!(store.blob_last_accessed_at(&owned_hash)?, owned_accessed);
3294 let other_owned_blobs = store.list_blobs_by_pubkey(&other_owner)?;
3295 assert_eq!(other_owned_blobs.len(), 1);
3296 assert_eq!(other_owned_blobs[0].sha256, to_hex(&owned_hash));
3297
3298 let batch = [
3299 (
3300 sha256(b"owned blossom batch 1"),
3301 b"owned blossom batch 1".to_vec(),
3302 ),
3303 (
3304 sha256(b"owned blossom batch 2"),
3305 b"owned blossom batch 2".to_vec(),
3306 ),
3307 ];
3308 store.put_owned_blobs(&batch, &owner)?;
3309 assert_eq!(store.put_owned_blobs(&batch, &owner)?, 0);
3310 let owned_blobs = store.list_blobs_by_pubkey(&owner)?;
3311 assert_eq!(owned_blobs.len(), 3);
3312
3313 Ok(())
3314 }
3315
3316 #[cfg(feature = "lmdb")]
3317 #[test]
3318 fn duplicate_heavy_cached_batch_uses_actual_inserted_bytes_for_quota() -> Result<()> {
3319 let temp = TempDir::new()?;
3320 let store = HashtreeStore::with_options_and_backend(
3321 temp.path(),
3322 None,
3323 35,
3324 true,
3325 &StorageBackend::Lmdb,
3326 )?;
3327
3328 let first = [1u8; 10];
3329 let second = [2u8; 10];
3330 let third = [3u8; 10];
3331 let new = [4u8; 5];
3332 let first_hash = sha256(&first);
3333 let second_hash = sha256(&second);
3334 let third_hash = sha256(&third);
3335 let new_hash = sha256(&new);
3336
3337 store.put_cached_blob(&first)?;
3338 store.put_cached_blob(&second)?;
3339 store.put_cached_blob(&third)?;
3340 assert_eq!(store.router.writable_stats()?.total_bytes, 30);
3341
3342 let inserted = store.put_cached_blobs(&[
3343 (first_hash, first.to_vec()),
3344 (second_hash, second.to_vec()),
3345 (new_hash, new.to_vec()),
3346 ])?;
3347
3348 assert_eq!(inserted, 1);
3349 assert_eq!(store.router.writable_stats()?.total_bytes, 35);
3350 assert!(store.blob_exists(&first_hash)?);
3351 assert!(store.blob_exists(&second_hash)?);
3352 assert!(store.blob_exists(&third_hash)?);
3353 assert!(store.blob_exists(&new_hash)?);
3354
3355 Ok(())
3356 }
3357
3358 #[cfg(feature = "lmdb")]
3359 #[test]
3360 fn replacing_tree_ref_unpins_and_unindexes_superseded_root() -> Result<()> {
3361 let temp = TempDir::new()?;
3362 let store = HashtreeStore::with_options_and_backend(
3363 temp.path(),
3364 None,
3365 LMDB_BLOB_MIN_MAP_SIZE_BYTES,
3366 true,
3367 &StorageBackend::Lmdb,
3368 )?;
3369
3370 let old_bytes = b"old published root";
3371 let new_bytes = b"new published root";
3372 let old_root = sha256(old_bytes);
3373 let new_root = sha256(new_bytes);
3374
3375 store.put_blob(old_bytes)?;
3376 store.pin(&old_root)?;
3377 store.index_tree(
3378 &old_root,
3379 "owner",
3380 Some("playlist"),
3381 PRIORITY_OWN,
3382 Some("npub1owner/playlist"),
3383 )?;
3384
3385 assert!(store.is_pinned(&old_root)?);
3386 assert!(store.get_tree_meta(&old_root)?.is_some());
3387
3388 store.put_blob(new_bytes)?;
3389 store.pin(&new_root)?;
3390 store.index_tree(
3391 &new_root,
3392 "owner",
3393 Some("playlist"),
3394 PRIORITY_OWN,
3395 Some("npub1owner/playlist"),
3396 )?;
3397
3398 assert!(
3399 !store.is_pinned(&old_root)?,
3400 "superseded root should be unpinned when ref is replaced"
3401 );
3402 assert!(
3403 store.get_tree_meta(&old_root)?.is_none(),
3404 "superseded root metadata should be removed when ref is replaced"
3405 );
3406 assert!(store.is_pinned(&new_root)?);
3407 assert!(store.get_tree_meta(&new_root)?.is_some());
3408
3409 Ok(())
3410 }
3411
3412 #[test]
3413 fn tracked_authors_round_trip_sorted_and_deduplicated() -> Result<()> {
3414 let temp = TempDir::new()?;
3415 let store = HashtreeStore::with_options(temp.path(), None, 1024 * 1024)?;
3416
3417 store
3418 .add_tracked_author("npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk")?;
3419 store
3420 .add_tracked_author("npub1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaqf5slm")?;
3421 store
3422 .add_tracked_author("npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk")?;
3423
3424 assert_eq!(
3425 store.list_tracked_authors()?,
3426 vec![
3427 "npub1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaqf5slm".to_string(),
3428 "npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk".to_string(),
3429 ]
3430 );
3431 assert!(store.remove_tracked_author(
3432 "npub1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaqf5slm"
3433 )?);
3434 assert!(!store.remove_tracked_author(
3435 "npub1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbpqqqqq"
3436 )?);
3437 assert_eq!(
3438 store.list_tracked_authors()?,
3439 vec!["npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk".to_string()]
3440 );
3441
3442 Ok(())
3443 }
3444
3445 #[cfg(feature = "s3")]
3446 #[test]
3447 fn async_store_s3_fallback_does_not_reenter_futures_executor() -> Result<()> {
3448 let temp = tempfile::TempDir::new()?;
3449 let local = Arc::new(LocalStore::new(
3450 temp.path().join("blobs"),
3451 &StorageBackend::Fs,
3452 )?);
3453
3454 let outcome = std::panic::catch_unwind(|| {
3455 sync_block_on(async {
3456 let aws_config = aws_config::from_env()
3457 .region(aws_sdk_s3::config::Region::new("auto"))
3458 .load()
3459 .await;
3460 let s3_client = aws_sdk_s3::Client::from_conf(
3461 aws_sdk_s3::config::Builder::from(&aws_config)
3462 .endpoint_url("http://127.0.0.1:9")
3463 .force_path_style(true)
3464 .build(),
3465 );
3466
3467 let router = StorageRouter {
3468 local,
3469 s3_client: Some(s3_client),
3470 s3_bucket: Some("test-bucket".to_string()),
3471 s3_prefix: String::new(),
3472 sync_tx: None,
3473 };
3474 let hash = [0u8; 32];
3475
3476 let _ = Store::has(&router, &hash).await;
3477 let _ = Store::get(&router, &hash).await;
3478 });
3479 });
3480
3481 assert!(
3482 outcome.is_ok(),
3483 "S3-backed async store methods should not panic inside futures::block_on"
3484 );
3485
3486 Ok(())
3487 }
3488}