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