Skip to main content

hashtree_cli/
storage.rs

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