Skip to main content

hashtree_cli/
storage.rs

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