Skip to main content

hashtree_cli/
storage.rs

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