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