Skip to main content

hashtree_cli/
storage.rs

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