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