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,
13    open_shared_lmdb_blob_store_with_space_guard, pool_audit_read_only_enabled,
14    ConfiguredLmdbBlobStore, ExternalBlobOptions, LmdbBlobReader, LmdbBlobStore,
15    PhysicalSpaceGuard, PoolStore, ReadOnlyPoolStore, POOL_AUDIT_READ_ONLY_ERROR,
16    SHARED_BLOB_POOL_DIR_NAME,
17};
18use heed::types::*;
19use heed::{Database, EnvFlags, EnvOpenOptions, Error as HeedError, MdbError, PutFlags};
20use lru::LruCache;
21use serde::{Deserialize, Serialize};
22use std::collections::{HashMap, HashSet};
23#[cfg(feature = "s3")]
24use std::future::Future;
25use std::io::Write;
26use std::num::NonZeroUsize;
27use std::path::{Path, PathBuf};
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::sync::{Arc, Mutex};
30use std::time::{Instant, SystemTime, UNIX_EPOCH};
31
32mod upload;
33pub use upload::{AddProgress, AddProgressSnapshot};
34
35mod maintenance;
36mod quota;
37mod retention;
38
39use quota::CacheQuotaController;
40
41#[cfg(feature = "s3")]
42const DEFAULT_S3_SYNC_TIMEOUT_MS: u64 = 5_000;
43#[cfg(feature = "s3")]
44const S3_SYNC_TIMEOUT_MS_ENV: &str = "HTREE_S3_SYNC_TIMEOUT_MS";
45
46pub use maintenance::{
47    compact_lmdb_environments_under, CompactResult, R2ImportOptions, R2ImportResult, VerifyResult,
48};
49pub use retention::{
50    acquire_existing_profile_repair_retention_guard, OwnedBlobStats, PinTreeError, PinTreeResult,
51    PinnedItem, ProfileRepairRetentionLease, ProfileRepairRetentionPublicationGuard,
52    RootRetentionReport, StorageByPriority, StorageStats, TreeIndexLimits, TreeMeta,
53    PROFILE_REPAIR_RETENTION_LEASE_FORMAT, PROFILE_REPAIR_RETENTION_LEASE_RELATIVE_PATH,
54};
55
56/// Priority levels for tree eviction
57pub const PRIORITY_OTHER: u8 = 64;
58pub const PRIORITY_FOLLOWED: u8 = 128;
59pub const PRIORITY_OWN: u8 = 255;
60const LMDB_MAX_READERS: u32 = 1024;
61const LMDB_METADATA_MIN_MAP_SIZE_BYTES: u64 = 64 * 1024 * 1024;
62const LMDB_METADATA_MAX_MAP_SIZE_BYTES: u64 = 64 * 1024 * 1024 * 1024;
63const LMDB_METADATA_STORAGE_RATIO_DIVISOR: u64 = 1024;
64const LMDB_METADATA_REOPEN_HEADROOM_BYTES: u64 = 64 * 1024 * 1024;
65#[cfg(all(test, feature = "lmdb"))]
66const LMDB_BLOB_MIN_MAP_SIZE_BYTES: u64 = 16 * 1024 * 1024;
67const ACCESS_UPDATE_INTERVAL_SECS: u64 = 300;
68const ACCESS_UPDATE_GATE_MAX_ENTRIES: usize = 4096;
69const DEFAULT_ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT: usize = 64;
70const ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT_ENV: &str = "HTREE_ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT";
71const DEFAULT_FILE_METADATA_CACHE_ENTRIES: usize = 128;
72const FILE_METADATA_CACHE_ENTRIES_ENV: &str = "HTREE_FILE_METADATA_CACHE_ENTRIES";
73const SLOW_OWNED_BLOB_BATCH_LOG_MS_ENV: &str = "HTREE_SLOW_OWNED_BLOB_BATCH_LOG_MS";
74const SLOW_CACHED_BLOB_BATCH_LOG_MS_ENV: &str = "HTREE_SLOW_CACHED_BLOB_BATCH_LOG_MS";
75pub const LOCAL_ADD_EXTERNAL_BLOB_DIR_NAME: &str = "blob-files-v1";
76pub(crate) const POOL_MIGRATION_DELETE_DISABLED: &str =
77    "explicit deletes are temporarily disabled during legacy PoolStore migration";
78#[cfg(feature = "lmdb")]
79const LMDB_HOT_BLOB_DIR_ENV: &str = "HTREE_LMDB_HOT_BLOB_DIR";
80#[cfg(feature = "lmdb")]
81const LMDB_HOT_BLOB_LEGACY_DIR_ENV: &str = "HTREE_LMDB_HOT_BLOB_LEGACY_DIR";
82#[cfg(feature = "lmdb")]
83const LMDB_HOT_EXTERNAL_BLOB_DIR_ENV: &str = "HTREE_LMDB_HOT_EXTERNAL_BLOB_DIR";
84#[cfg(feature = "lmdb")]
85const LMDB_LEGACY_EXTERNAL_BLOB_DIR_ENV: &str = "HTREE_LMDB_LEGACY_EXTERNAL_BLOB_DIR";
86#[cfg(feature = "lmdb")]
87const POOL_READ_FALLBACK_MODE_ENV: &str = "HTREE_POOL_READ_FALLBACK_MODE";
88#[cfg(feature = "lmdb")]
89const POOL_READ_FALLBACK_PATH_ENV: &str = "HTREE_POOL_READ_FALLBACK_PATH";
90#[cfg(feature = "lmdb")]
91const POOL_READ_FALLBACK_MANIFEST_SHA256_ENV: &str = "HTREE_POOL_READ_FALLBACK_MANIFEST_SHA256";
92#[cfg(feature = "lmdb")]
93const POOL_READ_FALLBACK_MODE_V1: &str = "read-only-exact-hash-v1";
94const LMDB_NO_READ_AHEAD_ENV: &str = "HTREE_LMDB_NO_READ_AHEAD";
95const LMDB_NO_SYNC_ENV: &str = "HTREE_LMDB_NO_SYNC";
96const LMDB_NO_META_SYNC_ENV: &str = "HTREE_LMDB_NO_META_SYNC";
97
98fn slow_owned_blob_batch_log_ms() -> Option<u128> {
99    std::env::var(SLOW_OWNED_BLOB_BATCH_LOG_MS_ENV)
100        .ok()
101        .and_then(|value| value.parse::<u128>().ok())
102        .filter(|value| *value > 0)
103}
104
105fn slow_cached_blob_batch_log_ms() -> Option<u128> {
106    std::env::var(SLOW_CACHED_BLOB_BATCH_LOG_MS_ENV)
107        .ok()
108        .and_then(|value| value.parse::<u128>().ok())
109        .filter(|value| *value > 0)
110}
111
112fn access_update_background_batch_limit() -> usize {
113    std::env::var(ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT_ENV)
114        .ok()
115        .and_then(|value| value.parse::<usize>().ok())
116        .unwrap_or(DEFAULT_ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT)
117}
118
119fn file_metadata_cache_entries() -> NonZeroUsize {
120    let entries = std::env::var(FILE_METADATA_CACHE_ENTRIES_ENV)
121        .ok()
122        .and_then(|value| value.parse::<usize>().ok())
123        .filter(|value| *value > 0)
124        .unwrap_or(DEFAULT_FILE_METADATA_CACHE_ENTRIES);
125    NonZeroUsize::new(entries).unwrap_or(NonZeroUsize::new(1).expect("nonzero cache size"))
126}
127
128fn env_bool(name: &str) -> Option<bool> {
129    std::env::var(name).ok().and_then(|value| {
130        let value = value.trim();
131        if value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") {
132            Some(true)
133        } else if value == "0"
134            || value.eq_ignore_ascii_case("false")
135            || value.eq_ignore_ascii_case("no")
136        {
137            Some(false)
138        } else {
139            None
140        }
141    })
142}
143
144fn lmdb_env_flags_from_env() -> EnvFlags {
145    let mut flags = EnvFlags::empty();
146    if env_bool(LMDB_NO_READ_AHEAD_ENV).unwrap_or(false) {
147        flags |= EnvFlags::NO_READ_AHEAD;
148    }
149    if env_bool(LMDB_NO_SYNC_ENV).unwrap_or(false) {
150        flags |= EnvFlags::NO_SYNC;
151    }
152    if env_bool(LMDB_NO_META_SYNC_ENV).unwrap_or(false) {
153        flags |= EnvFlags::NO_META_SYNC;
154    }
155    flags
156}
157
158fn unix_timestamp_now() -> u64 {
159    SystemTime::now()
160        .duration_since(UNIX_EPOCH)
161        .unwrap_or_default()
162        .as_secs()
163}
164
165/// Cached root info from Nostr events.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct CachedRoot {
168    /// Root hash (hex)
169    pub hash: String,
170    /// Optional decryption key (hex)
171    pub key: Option<String>,
172    /// Unix timestamp when this was cached (from event created_at)
173    pub updated_at: u64,
174    /// Visibility: "public", "link-visible", or "private"
175    pub visibility: String,
176}
177
178/// Storage statistics
179#[derive(Debug, Clone)]
180pub struct LocalStoreStats {
181    pub count: usize,
182    pub total_bytes: u64,
183}
184
185#[derive(Default)]
186struct BlobAccessUpdateGate {
187    next_update_by_hash: Mutex<HashMap<Hash, u64>>,
188}
189
190impl BlobAccessUpdateGate {
191    fn due_hashes<I>(&self, hashes: I, now: u64) -> Vec<Hash>
192    where
193        I: IntoIterator<Item = Hash>,
194    {
195        let Ok(mut next_update_by_hash) = self.next_update_by_hash.try_lock() else {
196            return Vec::new();
197        };
198
199        if next_update_by_hash.len() >= ACCESS_UPDATE_GATE_MAX_ENTRIES {
200            next_update_by_hash.retain(|_, next_update| *next_update > now);
201            if next_update_by_hash.len() >= ACCESS_UPDATE_GATE_MAX_ENTRIES {
202                next_update_by_hash.clear();
203            }
204        }
205
206        let mut due = Vec::new();
207        let mut seen = HashSet::new();
208        for hash in hashes {
209            if !seen.insert(hash) {
210                continue;
211            }
212            if next_update_by_hash
213                .get(&hash)
214                .is_some_and(|next_update| now < *next_update)
215            {
216                continue;
217            }
218            next_update_by_hash.insert(hash, now.saturating_add(ACCESS_UPDATE_INTERVAL_SECS));
219            due.push(hash);
220        }
221        due
222    }
223}
224
225/// Local blob store - wraps either FsBlobStore or LmdbBlobStore
226pub enum LocalStore {
227    Fs(FsBlobStore),
228    #[cfg(feature = "lmdb")]
229    Lmdb(LmdbBlobStore),
230    #[cfg(feature = "lmdb")]
231    Pool(Box<PoolStoreWithFallbacks>),
232    #[cfg(feature = "lmdb")]
233    ReadOnlyPool(Box<ReadOnlyPoolStoreWithFallbacks>),
234}
235
236/// Public status for one temporary read-only Pool fallback.
237///
238/// The fallback is never enumerable or writable. Every body read is still
239/// verified by [`ReadOnlyPoolStore`] against its requested SHA-256 and catalog
240/// size. The filesystem path is intentionally kept out of public status.
241#[cfg(feature = "lmdb")]
242#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
243pub struct PoolReadFallbackStatus {
244    pub enabled: bool,
245    pub manifest_sha256: String,
246}
247
248#[cfg(feature = "lmdb")]
249struct PoolReadFallback {
250    store: ReadOnlyPoolStore,
251    status: PoolReadFallbackStatus,
252}
253
254/// A PoolStore with temporary exact-hash lookup fallbacks used during an
255/// online migration from the former hot and legacy LMDB tiers.
256///
257/// PoolStore remains the sole writable and enumerable store. Fallbacks are
258/// consulted only after a pool miss, so new writes stop extending the legacy
259/// migration tail immediately. Explicit deletes fail closed until the
260/// fallbacks are removed after migration.
261#[cfg(feature = "lmdb")]
262pub struct PoolStoreWithFallbacks {
263    primary: PoolStore,
264    fallbacks: Vec<LmdbBlobReader>,
265    pool_read_fallback: Option<PoolReadFallback>,
266}
267
268#[cfg(feature = "lmdb")]
269impl std::ops::Deref for PoolStoreWithFallbacks {
270    type Target = PoolStore;
271
272    fn deref(&self) -> &Self::Target {
273        &self.primary
274    }
275}
276
277#[cfg(feature = "lmdb")]
278impl PoolStoreWithFallbacks {
279    fn new(primary: PoolStore, fallbacks: Vec<LmdbBlobReader>) -> Self {
280        Self {
281            primary,
282            fallbacks,
283            pool_read_fallback: None,
284        }
285    }
286
287    fn with_pool_read_fallback(mut self, fallback: Option<PoolReadFallback>) -> Self {
288        self.pool_read_fallback = fallback;
289        self
290    }
291
292    fn during_migration(primary: PoolStore, fallbacks: Vec<LmdbBlobReader>) -> Self {
293        if !fallbacks.is_empty() {
294            tracing::warn!(
295                "Blocking explicit full-store deletes until legacy PoolStore migration finishes"
296            );
297        }
298        Self::new(primary, fallbacks)
299    }
300
301    fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
302        let mut first_error = None;
303        match self.primary.get_sync(hash) {
304            Ok(Some(data)) => return Ok(Some(data)),
305            Ok(None) => {}
306            Err(error) => first_error = Some(error),
307        }
308
309        for fallback in &self.fallbacks {
310            match fallback.get_sync(hash) {
311                Ok(Some(data)) if sha256(&data) == *hash => {
312                    // Exhaustive migration is the sole authority for moving
313                    // legacy bytes into PoolStore. Returning verified bytes
314                    // directly keeps reads independent of Pool write/fsync
315                    // latency and avoids uncancellable promotion work after an
316                    // HTTP request has timed out.
317                    return Ok(Some(data));
318                }
319                Ok(Some(_)) if first_error.is_none() => {
320                    first_error = Some(StoreError::Other(format!(
321                        "legacy fallback returned corrupt blob {}",
322                        to_hex(hash)
323                    )))
324                }
325                Ok(Some(_)) => {}
326                Ok(None) => {}
327                Err(error) if first_error.is_none() => first_error = Some(error),
328                Err(_) => {}
329            }
330        }
331        if let Some(fallback) = &self.pool_read_fallback {
332            match fallback.store.get_sync(hash) {
333                Ok(Some(data)) => return Ok(Some(data)),
334                Ok(None) => {}
335                Err(error) if first_error.is_none() => first_error = Some(error),
336                Err(_) => {}
337            }
338        }
339        match first_error {
340            Some(error) => Err(error),
341            None => Ok(None),
342        }
343    }
344
345    fn get_range_sync(
346        &self,
347        hash: &Hash,
348        start: u64,
349        end_inclusive: u64,
350    ) -> Result<Option<Vec<u8>>, StoreError> {
351        if let Ok(Some(data)) = self.primary.get_range_sync(hash, start, end_inclusive) {
352            return Ok(Some(data));
353        }
354        self.get_sync(hash)?
355            .map(|data| slice_blob_range(&data, start, end_inclusive))
356            .transpose()
357    }
358
359    fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
360        let mut first_error = None;
361        match self.primary.blob_size_sync(hash) {
362            Ok(Some(size)) => return Ok(Some(size)),
363            Ok(None) => {}
364            Err(error) => first_error = Some(error),
365        }
366        for fallback in &self.fallbacks {
367            match fallback.blob_size_sync(hash) {
368                Ok(Some(size)) => return Ok(Some(size)),
369                Ok(None) => {}
370                Err(error) if first_error.is_none() => first_error = Some(error),
371                Err(_) => {}
372            }
373        }
374        if let Some(fallback) = &self.pool_read_fallback {
375            match fallback.store.blob_size_sync(hash) {
376                Ok(Some(size)) => return Ok(Some(size)),
377                Ok(None) => {}
378                Err(error) if first_error.is_none() => first_error = Some(error),
379                Err(_) => {}
380            }
381        }
382        match first_error {
383            Some(error) => Err(error),
384            None => Ok(None),
385        }
386    }
387
388    fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
389        self.get_sync(hash).map(|data| data.is_some())
390    }
391
392    fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
393        if !self.fallbacks.is_empty() || self.pool_read_fallback.is_some() {
394            return Err(StoreError::Other(POOL_MIGRATION_DELETE_DISABLED.into()));
395        }
396        self.primary.delete_sync(hash)
397    }
398
399    fn delete_many_sync(&self, hashes: &[Hash]) -> Result<usize, StoreError> {
400        if !self.fallbacks.is_empty() || self.pool_read_fallback.is_some() {
401            return Err(StoreError::Other(POOL_MIGRATION_DELETE_DISABLED.into()));
402        }
403        self.primary.delete_many_sync(hashes)
404    }
405
406    fn delete_writable_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
407        self.primary.delete_sync(hash)
408    }
409
410    fn delete_many_writable_sync(&self, hashes: &[Hash]) -> Result<usize, StoreError> {
411        self.primary.delete_many_sync(hashes)
412    }
413
414    fn full_deletes_blocked(&self) -> bool {
415        !self.fallbacks.is_empty() || self.pool_read_fallback.is_some()
416    }
417
418    fn pool_read_fallback_status(&self) -> Option<PoolReadFallbackStatus> {
419        self.pool_read_fallback
420            .as_ref()
421            .map(|fallback| fallback.status.clone())
422    }
423}
424
425/// A strict MDB_RDONLY PoolStore plus strict read-only legacy fallbacks.
426///
427/// This is used only during an exhaustive Pool audit. It keeps hash GETs
428/// available while making every local storage mutation structurally
429/// impossible in the serving process.
430#[cfg(feature = "lmdb")]
431pub struct ReadOnlyPoolStoreWithFallbacks {
432    primary: ReadOnlyPoolStore,
433    fallbacks: Vec<LmdbBlobReader>,
434    pool_read_fallback: Option<PoolReadFallback>,
435}
436
437#[cfg(feature = "lmdb")]
438impl ReadOnlyPoolStoreWithFallbacks {
439    fn new(primary: ReadOnlyPoolStore, fallbacks: Vec<LmdbBlobReader>) -> Self {
440        Self {
441            primary,
442            fallbacks,
443            pool_read_fallback: None,
444        }
445    }
446
447    fn with_pool_read_fallback(mut self, fallback: Option<PoolReadFallback>) -> Self {
448        self.pool_read_fallback = fallback;
449        self
450    }
451
452    fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
453        let mut first_error = None;
454        match self.primary.get_sync(hash) {
455            Ok(Some(data)) => return Ok(Some(data)),
456            Ok(None) => {}
457            Err(error) => return Err(error),
458        }
459        for fallback in &self.fallbacks {
460            match fallback.get_sync(hash) {
461                Ok(Some(data)) if sha256(&data) == *hash => return Ok(Some(data)),
462                Ok(Some(_)) if first_error.is_none() => {
463                    first_error = Some(StoreError::Other(format!(
464                        "legacy fallback returned corrupt blob {}",
465                        to_hex(hash)
466                    )));
467                }
468                Ok(Some(_)) | Ok(None) => {}
469                Err(error) if first_error.is_none() => first_error = Some(error),
470                Err(_) => {}
471            }
472        }
473        if let Some(fallback) = &self.pool_read_fallback {
474            match fallback.store.get_sync(hash) {
475                Ok(Some(data)) => return Ok(Some(data)),
476                Ok(None) => {}
477                Err(error) if first_error.is_none() => first_error = Some(error),
478                Err(_) => {}
479            }
480        }
481        match first_error {
482            Some(error) => Err(error),
483            None => Ok(None),
484        }
485    }
486
487    fn get_range_sync(
488        &self,
489        hash: &Hash,
490        start: u64,
491        end_inclusive: u64,
492    ) -> Result<Option<Vec<u8>>, StoreError> {
493        self.get_sync(hash)?
494            .map(|data| slice_blob_range(&data, start, end_inclusive))
495            .transpose()
496    }
497
498    fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
499        let mut first_error = None;
500        match self.primary.blob_size_sync(hash) {
501            Ok(Some(size)) => return Ok(Some(size)),
502            Ok(None) => {}
503            Err(error) => return Err(error),
504        }
505        for fallback in &self.fallbacks {
506            match fallback.blob_size_sync(hash) {
507                Ok(Some(size)) => return Ok(Some(size)),
508                Ok(None) => {}
509                Err(error) if first_error.is_none() => first_error = Some(error),
510                Err(_) => {}
511            }
512        }
513        if let Some(fallback) = &self.pool_read_fallback {
514            match fallback.store.blob_size_sync(hash) {
515                Ok(Some(size)) => return Ok(Some(size)),
516                Ok(None) => {}
517                Err(error) if first_error.is_none() => first_error = Some(error),
518                Err(_) => {}
519            }
520        }
521        match first_error {
522            Some(error) => Err(error),
523            None => Ok(None),
524        }
525    }
526
527    fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
528        self.get_sync(hash).map(|data| data.is_some())
529    }
530
531    fn pool_read_fallback_status(&self) -> Option<PoolReadFallbackStatus> {
532        self.pool_read_fallback
533            .as_ref()
534            .map(|fallback| fallback.status.clone())
535    }
536}
537
538#[cfg(feature = "lmdb")]
539fn pool_audit_read_only_error() -> StoreError {
540    StoreError::Other(POOL_AUDIT_READ_ONLY_ERROR.into())
541}
542
543#[cfg(feature = "lmdb")]
544fn is_fs_blob_shard_dir(path: &Path) -> bool {
545    path.file_name()
546        .and_then(|name| name.to_str())
547        .map(|name| name.len() == 2 && name.as_bytes().iter().all(u8::is_ascii_hexdigit))
548        .unwrap_or(false)
549}
550
551fn lmdb_metadata_map_size_for_storage_budget(max_size_bytes: u64) -> u64 {
552    if max_size_bytes == 0 {
553        return LMDB_METADATA_MAX_MAP_SIZE_BYTES;
554    }
555
556    max_size_bytes
557        .saturating_div(LMDB_METADATA_STORAGE_RATIO_DIVISOR)
558        .clamp(
559            LMDB_METADATA_MIN_MAP_SIZE_BYTES,
560            LMDB_METADATA_MAX_MAP_SIZE_BYTES,
561        )
562}
563
564fn lmdb_map_size_for_existing_env(path: &Path, requested_bytes: u64) -> Result<usize> {
565    let existing_bytes = std::fs::metadata(path.join("data.mdb"))
566        .map(|metadata| metadata.len())
567        .unwrap_or(0);
568    let requested = if existing_bytes > requested_bytes {
569        let existing_headroom = existing_bytes
570            .saturating_div(10)
571            .max(LMDB_METADATA_REOPEN_HEADROOM_BYTES);
572        existing_bytes.saturating_add(existing_headroom)
573    } else {
574        requested_bytes
575    };
576    let requested = align_lmdb_map_size(requested);
577    usize::try_from(requested).context("LMDB map size exceeds usize")
578}
579
580fn align_lmdb_map_size(bytes: u64) -> u64 {
581    let page_size = (page_size::get() as u64).max(4096);
582    let remainder = bytes % page_size;
583    if remainder == 0 {
584        bytes
585    } else {
586        bytes.saturating_add(page_size - remainder)
587    }
588}
589
590#[cfg(feature = "lmdb")]
591fn remove_stale_fs_blob_shards(path: &Path) -> Result<(), StoreError> {
592    let entries = std::fs::read_dir(path).map_err(StoreError::Io)?;
593    for entry in entries {
594        let entry = entry.map_err(StoreError::Io)?;
595        let entry_path = entry.path();
596        if entry_path.is_dir() && is_fs_blob_shard_dir(&entry_path) {
597            std::fs::remove_dir_all(&entry_path).map_err(StoreError::Io)?;
598            tracing::info!(
599                "Removed stale filesystem blob shard directory after LMDB cutover: {}",
600                entry_path.display()
601            );
602        }
603    }
604    Ok(())
605}
606
607#[cfg(feature = "lmdb")]
608fn local_add_external_blob_reopen_options(store_path: &Path) -> ExternalBlobOptions {
609    ExternalBlobOptions {
610        base_path: store_path.with_file_name(LOCAL_ADD_EXTERNAL_BLOB_DIR_NAME),
611        min_bytes: usize::MAX,
612        sync: true,
613        pack_target_bytes: None,
614    }
615}
616
617#[cfg(feature = "lmdb")]
618fn external_blob_options_for(store_path: &Path) -> ExternalBlobOptions {
619    ExternalBlobOptions::from_env(store_path).unwrap_or_else(|| {
620        // `htree add --local` spills large blobs into this deterministic sibling
621        // directory. Keep ordinary opens able to read those markers without
622        // changing their write placement; local add opts into packed writes via
623        // its process-local environment.
624        local_add_external_blob_reopen_options(store_path)
625    })
626}
627
628#[cfg(feature = "lmdb")]
629fn external_blob_options_for_fallback(
630    store_path: &Path,
631    external_dir_env: &str,
632) -> ExternalBlobOptions {
633    let options = external_blob_options_for(store_path);
634    std::env::var(external_dir_env)
635        .ok()
636        .map(|value| value.trim().to_owned())
637        .filter(|value| !value.is_empty())
638        .map(PathBuf::from)
639        .map(|path| options.clone().with_base_path(path))
640        .unwrap_or(options)
641}
642
643#[cfg(feature = "lmdb")]
644fn paths_refer_to_same_location(left: &Path, right: &Path) -> bool {
645    if left == right {
646        return true;
647    }
648    match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
649        (Ok(left), Ok(right)) => left == right,
650        _ => false,
651    }
652}
653
654#[cfg(feature = "lmdb")]
655#[derive(Debug, Clone, PartialEq, Eq)]
656struct PoolReadFallbackConfig {
657    path: PathBuf,
658    manifest_sha256: String,
659}
660
661#[cfg(feature = "lmdb")]
662fn require_lower_sha256(value: &str, label: &str) -> Result<(), StoreError> {
663    if value.len() != 64
664        || !value
665            .bytes()
666            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
667    {
668        return Err(StoreError::Other(format!(
669            "{label} must be 64 lowercase hexadecimal characters"
670        )));
671    }
672    Ok(())
673}
674
675#[cfg(feature = "lmdb")]
676fn direct_canonical_directory(path: &Path, label: &str) -> Result<PathBuf, StoreError> {
677    if !path.is_absolute() {
678        return Err(StoreError::Other(format!(
679            "{label} must be absolute: {}",
680            path.display()
681        )));
682    }
683    let metadata = std::fs::symlink_metadata(path).map_err(StoreError::Io)?;
684    if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
685        return Err(StoreError::Other(format!(
686            "{label} must be a direct directory: {}",
687            path.display()
688        )));
689    }
690    let canonical = std::fs::canonicalize(path).map_err(StoreError::Io)?;
691    if canonical != path {
692        return Err(StoreError::Other(format!(
693            "{label} must be its exact canonical path: got {}, canonical {}",
694            path.display(),
695            canonical.display()
696        )));
697    }
698    let data_path = path.join("data.mdb");
699    let data_metadata = std::fs::symlink_metadata(&data_path).map_err(StoreError::Io)?;
700    if data_metadata.file_type().is_symlink() || !data_metadata.file_type().is_file() {
701        return Err(StoreError::Other(format!(
702            "{label} data.mdb must be a direct regular file: {}",
703            data_path.display()
704        )));
705    }
706    Ok(canonical)
707}
708
709#[cfg(feature = "lmdb")]
710fn pool_read_fallback_config_from_values(
711    mode: Option<String>,
712    path: Option<String>,
713    manifest_sha256: Option<String>,
714) -> Result<Option<PoolReadFallbackConfig>, StoreError> {
715    let supplied = [mode.is_some(), path.is_some(), manifest_sha256.is_some()];
716    if supplied.iter().all(|supplied| !supplied) {
717        return Ok(None);
718    }
719    if !supplied.iter().all(|supplied| *supplied) {
720        return Err(StoreError::Other(format!(
721            "temporary Pool read fallback is fail-closed: set all of {POOL_READ_FALLBACK_MODE_ENV}, {POOL_READ_FALLBACK_PATH_ENV}, and {POOL_READ_FALLBACK_MANIFEST_SHA256_ENV}, or none"
722        )));
723    }
724    let mode = mode.expect("all fallback values checked");
725    if mode != POOL_READ_FALLBACK_MODE_V1 {
726        return Err(StoreError::Other(format!(
727            "{POOL_READ_FALLBACK_MODE_ENV} must be exactly {POOL_READ_FALLBACK_MODE_V1:?}"
728        )));
729    }
730    let path = path.expect("all fallback values checked");
731    if path.trim() != path || path.is_empty() {
732        return Err(StoreError::Other(format!(
733            "{POOL_READ_FALLBACK_PATH_ENV} must be a nonempty exact path without surrounding whitespace"
734        )));
735    }
736    let manifest_sha256 = manifest_sha256.expect("all fallback values checked");
737    require_lower_sha256(&manifest_sha256, POOL_READ_FALLBACK_MANIFEST_SHA256_ENV)?;
738    Ok(Some(PoolReadFallbackConfig {
739        path: PathBuf::from(path),
740        manifest_sha256,
741    }))
742}
743
744#[cfg(feature = "lmdb")]
745fn configured_pool_read_fallback() -> Result<Option<PoolReadFallbackConfig>, StoreError> {
746    let read = |name: &str| -> Result<Option<String>, StoreError> {
747        match std::env::var(name) {
748            Ok(value) => Ok(Some(value)),
749            Err(std::env::VarError::NotPresent) => Ok(None),
750            Err(std::env::VarError::NotUnicode(_)) => {
751                Err(StoreError::Other(format!("{name} is not valid Unicode")))
752            }
753        }
754    };
755    pool_read_fallback_config_from_values(
756        read(POOL_READ_FALLBACK_MODE_ENV)?,
757        read(POOL_READ_FALLBACK_PATH_ENV)?,
758        read(POOL_READ_FALLBACK_MANIFEST_SHA256_ENV)?,
759    )
760}
761
762#[cfg(feature = "lmdb")]
763fn open_pinned_pool_read_fallback(
764    config: PoolReadFallbackConfig,
765    primary_pool_path: &Path,
766) -> Result<PoolReadFallback, StoreError> {
767    let canonical = direct_canonical_directory(&config.path, "Pool read fallback")?;
768    if paths_refer_to_same_location(&canonical, primary_pool_path) {
769        return Err(StoreError::Other(
770            "Pool read fallback must not resolve to the writable primary Pool".into(),
771        ));
772    }
773    let store = ReadOnlyPoolStore::open(&canonical)?;
774    store.require_durable_external_blob_writes()?;
775    let manifest_sha256 = to_hex(&store.manifest_snapshot()?.sha256);
776    if manifest_sha256 != config.manifest_sha256 {
777        return Err(StoreError::Other(format!(
778            "Pool read fallback manifest does not match its startup pin: expected {}, found {}",
779            config.manifest_sha256, manifest_sha256,
780        )));
781    }
782    tracing::warn!(
783        path = %canonical.display(),
784        manifest_sha256 = %manifest_sha256,
785        "Enabled temporary exact-hash read-only Pool fallback; writes remain on the primary Pool"
786    );
787    Ok(PoolReadFallback {
788        store,
789        status: PoolReadFallbackStatus {
790            enabled: true,
791            manifest_sha256,
792        },
793    })
794}
795
796#[cfg(feature = "lmdb")]
797fn open_pool_fallbacks(data_dir: &Path) -> Result<Vec<LmdbBlobReader>, StoreError> {
798    let canonical_legacy = data_dir.join("blobs");
799    let configured_legacy = std::env::var(LMDB_HOT_BLOB_LEGACY_DIR_ENV)
800        .ok()
801        .map(|value| value.trim().to_owned())
802        .filter(|value| !value.is_empty())
803        .map(PathBuf::from);
804    let Some(configured_legacy) = configured_legacy else {
805        return Ok(Vec::new());
806    };
807    if !paths_refer_to_same_location(&canonical_legacy, &configured_legacy) {
808        tracing::warn!(
809            expected = %canonical_legacy.display(),
810            configured = %configured_legacy.display(),
811            "Ignoring PoolStore fallbacks because the legacy tier guard does not match"
812        );
813        return Ok(Vec::new());
814    }
815
816    let hot = std::env::var(LMDB_HOT_BLOB_DIR_ENV)
817        .ok()
818        .map(|value| value.trim().to_owned())
819        .filter(|value| !value.is_empty())
820        .map(PathBuf::from);
821    let candidates = [
822        hot.map(|path| (path, LMDB_HOT_EXTERNAL_BLOB_DIR_ENV)),
823        Some((configured_legacy, LMDB_LEGACY_EXTERNAL_BLOB_DIR_ENV)),
824    ];
825    let mut fallbacks = Vec::new();
826    let mut opened = HashSet::new();
827    for candidate in candidates.into_iter().flatten() {
828        let (path, external_dir_env) = candidate;
829        if !path.join("data.mdb").exists() {
830            continue;
831        }
832        let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
833        if !opened.insert(identity) {
834            continue;
835        }
836        let external = external_blob_options_for_fallback(&path, external_dir_env);
837        let store = LmdbBlobReader::open(&path, Some(external))?;
838        tracing::info!(
839            path = %path.display(),
840            external_dir_env,
841            "Enabled read-only exact-hash legacy LMDB fallback"
842        );
843        fallbacks.push(store);
844    }
845    Ok(fallbacks)
846}
847
848#[cfg(feature = "lmdb")]
849fn open_lmdb_blob_store<P: AsRef<Path>>(
850    path: P,
851    map_size_bytes: Option<u64>,
852) -> Result<LmdbBlobStore, StoreError> {
853    std::fs::create_dir_all(path.as_ref()).map_err(StoreError::Io)?;
854    remove_stale_fs_blob_shards(path.as_ref())?;
855    let external_blobs = Some(external_blob_options_for(path.as_ref()));
856    match map_size_bytes {
857        Some(map_size_bytes) => {
858            LmdbBlobStore::with_max_bytes_and_external_blob_options(path, map_size_bytes, |_| {
859                external_blobs
860            })
861        }
862        None => LmdbBlobStore::with_external_blob_options(path, external_blobs),
863    }
864}
865
866impl LocalStore {
867    /// Create a new unbounded local store.
868    ///
869    /// Higher-level stores that need quota enforcement should manage eviction
870    /// above this layer so tree metadata, pins, and archival policies stay
871    /// coherent.
872    pub fn new<P: AsRef<Path>>(path: P, backend: &StorageBackend) -> Result<Self, StoreError> {
873        Self::new_unbounded(path, backend)
874    }
875
876    /// Create a new local store with an explicit LMDB logical size cap when using the LMDB backend.
877    ///
878    /// The requested size is used for both the LMDB map and the store's built-in
879    /// byte quota, so standalone blob envs can evict instead of growing forever.
880    pub fn new_with_lmdb_map_size<P: AsRef<Path>>(
881        path: P,
882        backend: &StorageBackend,
883        _map_size_bytes: Option<u64>,
884    ) -> Result<Self, StoreError> {
885        match backend {
886            StorageBackend::Fs => Ok(LocalStore::Fs(FsBlobStore::new(path)?)),
887            #[cfg(feature = "lmdb")]
888            StorageBackend::Lmdb => Ok(LocalStore::Lmdb(open_lmdb_blob_store(
889                path,
890                _map_size_bytes,
891            )?)),
892            #[cfg(not(feature = "lmdb"))]
893            StorageBackend::Lmdb => {
894                tracing::warn!(
895                    "LMDB backend requested but lmdb feature not enabled, using filesystem storage"
896                );
897                Ok(LocalStore::Fs(FsBlobStore::new(path)?))
898            }
899        }
900    }
901
902    /// Create a new unbounded local store for a specific backend.
903    pub fn new_unbounded<P: AsRef<Path>>(
904        path: P,
905        backend: &StorageBackend,
906    ) -> Result<Self, StoreError> {
907        Self::new_with_lmdb_map_size(path, backend, None)
908    }
909
910    /// Create a local store with an explicit LMDB map size but without adapter-level eviction.
911    ///
912    /// Higher layers use this when they need a large mmap while enforcing quota
913    /// with richer retention policy.
914    pub fn new_unbounded_with_lmdb_map_size<P: AsRef<Path>>(
915        path: P,
916        backend: &StorageBackend,
917        _map_size_bytes: Option<u64>,
918    ) -> Result<Self, StoreError> {
919        match backend {
920            StorageBackend::Fs => Ok(LocalStore::Fs(FsBlobStore::new(path)?)),
921            #[cfg(feature = "lmdb")]
922            StorageBackend::Lmdb => Ok(
923                match open_configured_lmdb_blob_store(path, _map_size_bytes)? {
924                    ConfiguredLmdbBlobStore::Single(store) => LocalStore::Lmdb(store),
925                    ConfiguredLmdbBlobStore::Pool(store) => {
926                        LocalStore::Pool(Box::new(PoolStoreWithFallbacks::new(*store, Vec::new())))
927                    }
928                    ConfiguredLmdbBlobStore::ReadOnlyPool(store) => LocalStore::ReadOnlyPool(
929                        Box::new(ReadOnlyPoolStoreWithFallbacks::new(*store, Vec::new())),
930                    ),
931                },
932            ),
933            #[cfg(not(feature = "lmdb"))]
934            StorageBackend::Lmdb => {
935                tracing::warn!(
936                    "LMDB backend requested but lmdb feature not enabled, using filesystem storage"
937                );
938                Ok(LocalStore::Fs(FsBlobStore::new(path)?))
939            }
940        }
941    }
942
943    pub fn backend(&self) -> StorageBackend {
944        match self {
945            LocalStore::Fs(_) => StorageBackend::Fs,
946            #[cfg(feature = "lmdb")]
947            LocalStore::Lmdb(_) | LocalStore::Pool(_) | LocalStore::ReadOnlyPool(_) => {
948                StorageBackend::Lmdb
949            }
950        }
951    }
952
953    pub fn is_pool_audit_read_only(&self) -> bool {
954        #[cfg(feature = "lmdb")]
955        {
956            matches!(self, LocalStore::ReadOnlyPool(_))
957        }
958        #[cfg(not(feature = "lmdb"))]
959        {
960            false
961        }
962    }
963
964    pub fn force_sync(&self) -> Result<(), StoreError> {
965        match self {
966            LocalStore::Fs(_) => Ok(()),
967            #[cfg(feature = "lmdb")]
968            LocalStore::Lmdb(store) => store.force_sync(),
969            #[cfg(feature = "lmdb")]
970            LocalStore::Pool(store) => store.force_sync(),
971            #[cfg(feature = "lmdb")]
972            LocalStore::ReadOnlyPool(_) => Ok(()),
973        }
974    }
975
976    /// Sync put operation
977    pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
978        match self {
979            LocalStore::Fs(store) => store.put_sync(hash, data),
980            #[cfg(feature = "lmdb")]
981            LocalStore::Lmdb(store) => store.put_sync(hash, data),
982            #[cfg(feature = "lmdb")]
983            LocalStore::Pool(store) => store.put_sync(hash, data),
984            #[cfg(feature = "lmdb")]
985            LocalStore::ReadOnlyPool(_) => Err(pool_audit_read_only_error()),
986        }
987    }
988
989    /// Sync batch put operation.
990    pub fn put_many_report_sync(
991        &self,
992        items: &[(Hash, Vec<u8>)],
993    ) -> Result<PutManyReport, StoreError> {
994        match self {
995            LocalStore::Fs(store) => {
996                let mut report = PutManyReport {
997                    total: items.len(),
998                    ..PutManyReport::default()
999                };
1000                for (hash, data) in items {
1001                    if store.put_sync(*hash, data.as_slice())? {
1002                        report.inserted = report.inserted.saturating_add(1);
1003                        report.inserted_bytes =
1004                            report.inserted_bytes.saturating_add(data.len() as u64);
1005                        report.inserted_hashes.push(*hash);
1006                    }
1007                }
1008                Ok(report)
1009            }
1010            #[cfg(feature = "lmdb")]
1011            LocalStore::Lmdb(store) => store.put_many_report_sync(items),
1012            #[cfg(feature = "lmdb")]
1013            LocalStore::Pool(store) => store.put_many_report_sync(items),
1014            #[cfg(feature = "lmdb")]
1015            LocalStore::ReadOnlyPool(_) => Err(pool_audit_read_only_error()),
1016        }
1017    }
1018
1019    /// Sync batch put operation.
1020    pub fn put_many_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
1021        self.put_many_report_sync(items)
1022            .map(|report| report.inserted)
1023    }
1024
1025    /// Sync batch put for locally generated content-addressed candidates.
1026    pub fn put_many_optimistic_report_sync(
1027        &self,
1028        items: &[(Hash, Vec<u8>)],
1029    ) -> Result<PutManyReport, StoreError> {
1030        match self {
1031            LocalStore::Fs(_) => self.put_many_report_sync(items),
1032            #[cfg(feature = "lmdb")]
1033            LocalStore::Lmdb(_) => self.put_many_report_sync(items),
1034            #[cfg(feature = "lmdb")]
1035            LocalStore::Pool(store) => store.put_many_optimistic_report_sync(items),
1036            #[cfg(feature = "lmdb")]
1037            LocalStore::ReadOnlyPool(_) => Err(pool_audit_read_only_error()),
1038        }
1039    }
1040
1041    pub fn put_many_optimistic_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
1042        self.put_many_optimistic_report_sync(items)
1043            .map(|report| report.inserted)
1044    }
1045
1046    /// Sync get operation
1047    pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
1048        match self {
1049            LocalStore::Fs(store) => store.get_sync(hash),
1050            #[cfg(feature = "lmdb")]
1051            LocalStore::Lmdb(store) => store.get_sync(hash),
1052            #[cfg(feature = "lmdb")]
1053            LocalStore::Pool(store) => store.get_sync(hash),
1054            #[cfg(feature = "lmdb")]
1055            LocalStore::ReadOnlyPool(store) => store.get_sync(hash),
1056        }
1057    }
1058
1059    pub fn get_range_sync(
1060        &self,
1061        hash: &Hash,
1062        start: u64,
1063        end_inclusive: u64,
1064    ) -> Result<Option<Vec<u8>>, StoreError> {
1065        match self {
1066            LocalStore::Fs(store) => store.get_range_sync(hash, start, end_inclusive),
1067            #[cfg(feature = "lmdb")]
1068            LocalStore::Lmdb(store) => store.get_range_sync(hash, start, end_inclusive),
1069            #[cfg(feature = "lmdb")]
1070            LocalStore::Pool(store) => store.get_range_sync(hash, start, end_inclusive),
1071            #[cfg(feature = "lmdb")]
1072            LocalStore::ReadOnlyPool(store) => store.get_range_sync(hash, start, end_inclusive),
1073        }
1074    }
1075
1076    pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1077        match self {
1078            LocalStore::Fs(store) => store.blob_size_sync(hash),
1079            #[cfg(feature = "lmdb")]
1080            LocalStore::Lmdb(store) => store.blob_size_sync(hash),
1081            #[cfg(feature = "lmdb")]
1082            LocalStore::Pool(store) => store.blob_size_sync(hash),
1083            #[cfg(feature = "lmdb")]
1084            LocalStore::ReadOnlyPool(store) => store.blob_size_sync(hash),
1085        }
1086    }
1087
1088    pub fn touch_accessed_sync(&self, hash: &Hash, now: u64) -> Result<bool, StoreError> {
1089        match self {
1090            LocalStore::Fs(store) => store.touch_accessed_sync(hash, now),
1091            #[cfg(feature = "lmdb")]
1092            LocalStore::Lmdb(store) => store.touch_accessed_sync(hash, now),
1093            #[cfg(feature = "lmdb")]
1094            LocalStore::Pool(store) => store.touch_accessed_sync(hash, now),
1095            #[cfg(feature = "lmdb")]
1096            LocalStore::ReadOnlyPool(_) => Ok(false),
1097        }
1098    }
1099
1100    pub fn touch_many_accessed_sync(&self, hashes: &[Hash], now: u64) -> Result<usize, StoreError> {
1101        match self {
1102            LocalStore::Fs(store) => store.touch_many_accessed_sync(hashes, now),
1103            #[cfg(feature = "lmdb")]
1104            LocalStore::Lmdb(store) => store.touch_many_accessed_sync(hashes, now),
1105            #[cfg(feature = "lmdb")]
1106            LocalStore::Pool(store) => store.touch_many_accessed_sync(hashes, now),
1107            #[cfg(feature = "lmdb")]
1108            LocalStore::ReadOnlyPool(_) => Ok(0),
1109        }
1110    }
1111
1112    pub fn last_accessed_at_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1113        match self {
1114            LocalStore::Fs(store) => store.last_accessed_at_sync(hash),
1115            #[cfg(feature = "lmdb")]
1116            LocalStore::Lmdb(store) => store.last_accessed_at_sync(hash),
1117            #[cfg(feature = "lmdb")]
1118            LocalStore::Pool(store) => store.last_accessed_at_sync(hash),
1119            #[cfg(feature = "lmdb")]
1120            LocalStore::ReadOnlyPool(_) => Ok(None),
1121        }
1122    }
1123
1124    pub fn many_last_accessed_at_sync(
1125        &self,
1126        hashes: &[Hash],
1127    ) -> Result<Vec<(Hash, u64)>, StoreError> {
1128        match self {
1129            LocalStore::Fs(store) => store.many_last_accessed_at_sync(hashes),
1130            #[cfg(feature = "lmdb")]
1131            LocalStore::Lmdb(store) => store.many_last_accessed_at_sync(hashes),
1132            #[cfg(feature = "lmdb")]
1133            LocalStore::Pool(store) => store.many_last_accessed_at_sync(hashes),
1134            #[cfg(feature = "lmdb")]
1135            LocalStore::ReadOnlyPool(_) => Ok(Vec::new()),
1136        }
1137    }
1138
1139    /// Check if hash exists
1140    pub fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
1141        match self {
1142            LocalStore::Fs(store) => Ok(store.exists(hash)),
1143            #[cfg(feature = "lmdb")]
1144            LocalStore::Lmdb(store) => store.exists(hash),
1145            #[cfg(feature = "lmdb")]
1146            LocalStore::Pool(store) => store.exists(hash),
1147            #[cfg(feature = "lmdb")]
1148            LocalStore::ReadOnlyPool(store) => store.exists(hash),
1149        }
1150    }
1151
1152    #[cfg(feature = "lmdb")]
1153    pub fn pool_read_fallback_status(&self) -> Option<PoolReadFallbackStatus> {
1154        match self {
1155            LocalStore::Pool(store) => store.pool_read_fallback_status(),
1156            LocalStore::ReadOnlyPool(store) => store.pool_read_fallback_status(),
1157            LocalStore::Fs(_) | LocalStore::Lmdb(_) => None,
1158        }
1159    }
1160
1161    /// Mark which sorted hashes already exist in local storage.
1162    pub fn existing_hashes_in_sorted_candidates(
1163        &self,
1164        sorted_hashes: &[Hash],
1165    ) -> Result<Vec<bool>, StoreError> {
1166        match self {
1167            LocalStore::Fs(store) => Ok(sorted_hashes
1168                .iter()
1169                .map(|hash| store.exists(hash))
1170                .collect()),
1171            #[cfg(feature = "lmdb")]
1172            LocalStore::Lmdb(store) => store.existing_hashes_in_sorted_candidates(sorted_hashes),
1173            #[cfg(feature = "lmdb")]
1174            LocalStore::Pool(store) => store.existing_hashes_in_sorted_candidates(sorted_hashes),
1175            #[cfg(feature = "lmdb")]
1176            LocalStore::ReadOnlyPool(store) => sorted_hashes
1177                .iter()
1178                .map(|hash| store.blob_size_sync(hash).map(|size| size.is_some()))
1179                .collect(),
1180        }
1181    }
1182
1183    /// Sync delete operation
1184    pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
1185        match self {
1186            LocalStore::Fs(store) => store.delete_sync(hash),
1187            #[cfg(feature = "lmdb")]
1188            LocalStore::Lmdb(store) => store.delete_sync(hash),
1189            #[cfg(feature = "lmdb")]
1190            LocalStore::Pool(store) => store.delete_sync(hash),
1191            #[cfg(feature = "lmdb")]
1192            LocalStore::ReadOnlyPool(_) => Err(pool_audit_read_only_error()),
1193        }
1194    }
1195
1196    pub fn delete_many_sync(&self, hashes: &[Hash]) -> Result<usize, StoreError> {
1197        match self {
1198            LocalStore::Fs(store) => {
1199                let mut deleted = 0usize;
1200                for hash in hashes {
1201                    if store.delete_sync(hash)? {
1202                        deleted += 1;
1203                    }
1204                }
1205                Ok(deleted)
1206            }
1207            #[cfg(feature = "lmdb")]
1208            LocalStore::Lmdb(store) => store.delete_many_sync(hashes),
1209            #[cfg(feature = "lmdb")]
1210            LocalStore::Pool(store) => store.delete_many_sync(hashes),
1211            #[cfg(feature = "lmdb")]
1212            LocalStore::ReadOnlyPool(_) => Err(pool_audit_read_only_error()),
1213        }
1214    }
1215
1216    pub fn delete_writable_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
1217        match self {
1218            LocalStore::Fs(store) => store.delete_sync(hash),
1219            #[cfg(feature = "lmdb")]
1220            LocalStore::Lmdb(store) => store.delete_sync(hash),
1221            #[cfg(feature = "lmdb")]
1222            LocalStore::Pool(store) => store.delete_writable_sync(hash),
1223            #[cfg(feature = "lmdb")]
1224            LocalStore::ReadOnlyPool(_) => Err(pool_audit_read_only_error()),
1225        }
1226    }
1227
1228    pub fn delete_many_writable_sync(&self, hashes: &[Hash]) -> Result<usize, StoreError> {
1229        match self {
1230            LocalStore::Fs(store) => {
1231                let mut deleted = 0usize;
1232                for hash in hashes {
1233                    if store.delete_sync(hash)? {
1234                        deleted += 1;
1235                    }
1236                }
1237                Ok(deleted)
1238            }
1239            #[cfg(feature = "lmdb")]
1240            LocalStore::Lmdb(store) => store.delete_many_sync(hashes),
1241            #[cfg(feature = "lmdb")]
1242            LocalStore::Pool(store) => store.delete_many_writable_sync(hashes),
1243            #[cfg(feature = "lmdb")]
1244            LocalStore::ReadOnlyPool(_) => Err(pool_audit_read_only_error()),
1245        }
1246    }
1247
1248    pub fn full_deletes_blocked(&self) -> bool {
1249        match self {
1250            LocalStore::Fs(_) => false,
1251            #[cfg(feature = "lmdb")]
1252            LocalStore::Lmdb(_) => false,
1253            #[cfg(feature = "lmdb")]
1254            LocalStore::Pool(store) => store.full_deletes_blocked(),
1255            #[cfg(feature = "lmdb")]
1256            LocalStore::ReadOnlyPool(_) => true,
1257        }
1258    }
1259
1260    /// Get storage statistics
1261    pub fn stats(&self) -> Result<LocalStoreStats, StoreError> {
1262        match self {
1263            LocalStore::Fs(store) => {
1264                let stats = store.stats()?;
1265                Ok(LocalStoreStats {
1266                    count: stats.count,
1267                    total_bytes: stats.total_bytes,
1268                })
1269            }
1270            #[cfg(feature = "lmdb")]
1271            LocalStore::Lmdb(store) => {
1272                let stats = store.stats()?;
1273                Ok(LocalStoreStats {
1274                    count: stats.count,
1275                    total_bytes: stats.total_bytes,
1276                })
1277            }
1278            #[cfg(feature = "lmdb")]
1279            LocalStore::Pool(store) => {
1280                let stats = store.stats()?;
1281                Ok(LocalStoreStats {
1282                    count: stats.count as usize,
1283                    total_bytes: stats.bytes,
1284                })
1285            }
1286            #[cfg(feature = "lmdb")]
1287            LocalStore::ReadOnlyPool(_) => Ok(LocalStoreStats {
1288                count: 0,
1289                total_bytes: 0,
1290            }),
1291        }
1292    }
1293
1294    /// Get storage statistics for the canonical writable store.
1295    pub fn writable_stats(&self) -> Result<LocalStoreStats, StoreError> {
1296        match self {
1297            LocalStore::Fs(store) => {
1298                let stats = store.stats()?;
1299                Ok(LocalStoreStats {
1300                    count: stats.count,
1301                    total_bytes: stats.total_bytes,
1302                })
1303            }
1304            #[cfg(feature = "lmdb")]
1305            LocalStore::Lmdb(store) => {
1306                let stats = store.stats()?;
1307                Ok(LocalStoreStats {
1308                    count: stats.count,
1309                    total_bytes: stats.total_bytes,
1310                })
1311            }
1312            #[cfg(feature = "lmdb")]
1313            LocalStore::Pool(store) => {
1314                let stats = store.writable_physical_stats()?;
1315                Ok(LocalStoreStats {
1316                    count: usize::try_from(stats.count).map_err(|_| {
1317                        StoreError::Other("pool physical blob count exceeds usize".into())
1318                    })?,
1319                    total_bytes: stats.bytes,
1320                })
1321            }
1322            #[cfg(feature = "lmdb")]
1323            LocalStore::ReadOnlyPool(_) => Ok(LocalStoreStats {
1324                count: 0,
1325                total_bytes: 0,
1326            }),
1327        }
1328    }
1329
1330    /// List all hashes in the store
1331    pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
1332        match self {
1333            LocalStore::Fs(store) => store.list(),
1334            #[cfg(feature = "lmdb")]
1335            LocalStore::Lmdb(store) => store.list(),
1336            #[cfg(feature = "lmdb")]
1337            LocalStore::Pool(store) => store.list(),
1338            #[cfg(feature = "lmdb")]
1339            LocalStore::ReadOnlyPool(_) => Err(pool_audit_read_only_error()),
1340        }
1341    }
1342
1343    /// List hashes in the canonical writable store.
1344    pub fn list_writable(&self) -> Result<Vec<Hash>, StoreError> {
1345        match self {
1346            LocalStore::Fs(store) => store.list(),
1347            #[cfg(feature = "lmdb")]
1348            LocalStore::Lmdb(store) => store.list(),
1349            #[cfg(feature = "lmdb")]
1350            LocalStore::Pool(store) => store.list(),
1351            #[cfg(feature = "lmdb")]
1352            LocalStore::ReadOnlyPool(_) => Err(pool_audit_read_only_error()),
1353        }
1354    }
1355
1356    /// Scan the canonical writable store in bounded lexicographic pages.
1357    ///
1358    /// Quota cleanup must fail closed when the backend cannot distinguish a
1359    /// disposable writable cache copy from durable data. In particular,
1360    /// `PoolStore::delete_sync` removes every member copy and its catalog
1361    /// location, so treating the whole pool catalog as a hot-cache scan would
1362    /// be unsafe.
1363    pub fn scan_writable_hashes_after(
1364        &self,
1365        after: Option<Hash>,
1366        limit: usize,
1367    ) -> Result<Vec<Hash>, StoreError> {
1368        match self {
1369            // Preserve embedded/filesystem quota behavior. The filesystem
1370            // backend has no native cursor yet, so this compatibility path
1371            // still enumerates once and only bounds the retention work done by
1372            // the caller. LMDB below is the scalable production path.
1373            LocalStore::Fs(store) => {
1374                let mut hashes = store.list()?;
1375                hashes.sort_unstable();
1376                let start = after
1377                    .map(|after| hashes.partition_point(|hash| *hash <= after))
1378                    .unwrap_or(0);
1379                hashes.truncate(start.saturating_add(limit).min(hashes.len()));
1380                Ok(hashes.drain(start..).collect())
1381            }
1382            #[cfg(feature = "lmdb")]
1383            LocalStore::Lmdb(store) => store.scan_hashes_after(after, limit),
1384            #[cfg(feature = "lmdb")]
1385            LocalStore::Pool(_) => Err(StoreError::Other(
1386                "bounded orphan cleanup is unsupported for PoolStore without tier-aware deletion"
1387                    .into(),
1388            )),
1389            #[cfg(feature = "lmdb")]
1390            LocalStore::ReadOnlyPool(_) => Err(pool_audit_read_only_error()),
1391        }
1392    }
1393}
1394
1395#[async_trait]
1396impl Store for LocalStore {
1397    async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
1398        self.put_sync(hash, &data)
1399    }
1400
1401    async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
1402        self.put_many_sync(&items)
1403    }
1404
1405    async fn put_many_optimistic(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
1406        self.put_many_optimistic_sync(&items)
1407    }
1408
1409    async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
1410        self.get_sync(hash)
1411    }
1412
1413    async fn get_range(
1414        &self,
1415        hash: &Hash,
1416        start: u64,
1417        end_inclusive: u64,
1418    ) -> Result<Option<Vec<u8>>, StoreError> {
1419        self.get_range_sync(hash, start, end_inclusive)
1420    }
1421
1422    async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1423        self.blob_size_sync(hash)
1424    }
1425
1426    async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
1427        self.exists(hash)
1428    }
1429
1430    async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
1431        self.delete_sync(hash)
1432    }
1433
1434    async fn delete_many(&self, hashes: Vec<Hash>) -> Result<usize, StoreError> {
1435        self.delete_many_sync(&hashes)
1436    }
1437}
1438
1439fn open_local_blob_store_with_options<P: AsRef<Path>>(
1440    data_dir: P,
1441    backend: &StorageBackend,
1442    max_size_bytes: u64,
1443    #[cfg(feature = "lmdb")] physical_space: Option<&PhysicalSpaceGuard>,
1444) -> Result<Arc<LocalStore>, StoreError> {
1445    #[cfg(feature = "lmdb")]
1446    {
1447        let pool_audit_read_only = pool_audit_read_only_enabled()?;
1448        if pool_audit_read_only && *backend != StorageBackend::Lmdb {
1449            return Err(StoreError::Other(format!(
1450                "{}=1 requires the LMDB storage backend",
1451                hashtree_lmdb::POOL_AUDIT_READ_ONLY_ENV
1452            )));
1453        }
1454    }
1455
1456    #[cfg(feature = "lmdb")]
1457    if *backend == StorageBackend::Lmdb {
1458        let data_dir = data_dir.as_ref();
1459        let pool_read_fallback_config = configured_pool_read_fallback()?;
1460        let opened = match physical_space {
1461            Some(guard) => open_shared_lmdb_blob_store_with_space_guard(
1462                data_dir,
1463                max_size_bytes,
1464                guard.clone(),
1465            ),
1466            None => open_shared_lmdb_blob_store(data_dir, max_size_bytes),
1467        };
1468        return opened.and_then(|store| {
1469            let primary_pool_path = data_dir.join(SHARED_BLOB_POOL_DIR_NAME);
1470            let local = match store {
1471                ConfiguredLmdbBlobStore::Single(store) => {
1472                    if pool_read_fallback_config.is_some() {
1473                        return Err(StoreError::Other(
1474                            "Pool read fallback requires a writable PoolStore primary".into(),
1475                        ));
1476                    }
1477                    LocalStore::Lmdb(store)
1478                }
1479                ConfiguredLmdbBlobStore::Pool(store) => {
1480                    let fallbacks = open_pool_fallbacks(data_dir)?;
1481                    let pool_read_fallback = pool_read_fallback_config
1482                        .clone()
1483                        .map(|config| {
1484                            open_pinned_pool_read_fallback(config, &primary_pool_path)
1485                        })
1486                        .transpose()?;
1487                    LocalStore::Pool(Box::new(
1488                        PoolStoreWithFallbacks::during_migration(*store, fallbacks)
1489                            .with_pool_read_fallback(pool_read_fallback),
1490                    ))
1491                }
1492                ConfiguredLmdbBlobStore::ReadOnlyPool(store) => {
1493                    let fallbacks = open_pool_fallbacks(data_dir)?;
1494                    let pool_read_fallback = pool_read_fallback_config
1495                        .clone()
1496                        .map(|config| {
1497                            open_pinned_pool_read_fallback(config, &primary_pool_path)
1498                        })
1499                        .transpose()?;
1500                    tracing::warn!(
1501                        env = hashtree_lmdb::POOL_AUDIT_READ_ONLY_ENV,
1502                        "Pool audit-serving read-only mode enabled; all local mutations are rejected"
1503                    );
1504                    LocalStore::ReadOnlyPool(Box::new(
1505                        ReadOnlyPoolStoreWithFallbacks::new(*store, fallbacks)
1506                            .with_pool_read_fallback(pool_read_fallback),
1507                    ))
1508                }
1509            };
1510            Ok(Arc::new(local))
1511        });
1512    }
1513
1514    #[cfg(not(feature = "lmdb"))]
1515    let _ = max_size_bytes;
1516
1517    LocalStore::new_unbounded(data_dir.as_ref().join("blobs"), backend).map(Arc::new)
1518}
1519
1520#[cfg(feature = "s3")]
1521use tokio::sync::mpsc;
1522
1523use crate::config::S3Config;
1524
1525/// Message for background S3 sync
1526#[cfg(feature = "s3")]
1527enum S3SyncMessage {
1528    Upload { hash: Hash, data: Vec<u8> },
1529    Delete { hash: Hash },
1530}
1531
1532/// Storage router - local store primary with optional S3 backup
1533///
1534/// Write path: local first (fast), then queue S3 upload (non-blocking)
1535/// Read path: local first, fall back to S3 if miss
1536pub struct StorageRouter {
1537    /// Primary local store (always used)
1538    local: Arc<LocalStore>,
1539    /// Optional S3 client for backup
1540    #[cfg(feature = "s3")]
1541    s3_client: Option<aws_sdk_s3::Client>,
1542    #[cfg(feature = "s3")]
1543    s3_bucket: Option<String>,
1544    #[cfg(feature = "s3")]
1545    s3_prefix: String,
1546    /// Channel to send uploads to background task
1547    #[cfg(feature = "s3")]
1548    sync_tx: Option<mpsc::UnboundedSender<S3SyncMessage>>,
1549}
1550
1551impl StorageRouter {
1552    #[cfg(feature = "s3")]
1553    fn s3_sync_timeout() -> std::time::Duration {
1554        let millis = std::env::var(S3_SYNC_TIMEOUT_MS_ENV)
1555            .ok()
1556            .and_then(|value| value.parse::<u64>().ok())
1557            .filter(|value| *value > 0)
1558            .unwrap_or(DEFAULT_S3_SYNC_TIMEOUT_MS);
1559        std::time::Duration::from_millis(millis)
1560    }
1561
1562    #[cfg(feature = "s3")]
1563    fn s3_sync_timeout_error(timeout: std::time::Duration) -> StoreError {
1564        StoreError::Other(format!(
1565            "S3 sync operation timed out after {}ms",
1566            timeout.as_millis()
1567        ))
1568    }
1569
1570    #[cfg(feature = "s3")]
1571    fn run_s3_future_sync<F, T>(future: F) -> Result<T, StoreError>
1572    where
1573        F: Future<Output = T> + Send + 'static,
1574        T: Send + 'static,
1575    {
1576        let timeout = Self::s3_sync_timeout();
1577        if tokio::runtime::Handle::try_current().is_ok() {
1578            return std::thread::Builder::new()
1579                .name("storage-s3-sync".to_string())
1580                .spawn(move || {
1581                    let runtime = tokio::runtime::Builder::new_current_thread()
1582                        .enable_all()
1583                        .build()
1584                        .map_err(|err| {
1585                            StoreError::Other(format!("build storage s3 sync runtime: {err}"))
1586                        })?;
1587                    runtime.block_on(async move {
1588                        tokio::time::timeout(timeout, future)
1589                            .await
1590                            .map_err(|_| Self::s3_sync_timeout_error(timeout))
1591                    })
1592                })
1593                .map_err(|err| StoreError::Other(format!("spawn S3 sync helper thread: {err}")))?
1594                .join()
1595                .map_err(|_| StoreError::Other("S3 sync helper thread panicked".to_string()))?;
1596        }
1597
1598        let runtime = tokio::runtime::Builder::new_current_thread()
1599            .enable_all()
1600            .build()
1601            .map_err(|err| StoreError::Other(format!("build storage s3 sync runtime: {err}")))?;
1602        runtime.block_on(async move {
1603            tokio::time::timeout(timeout, future)
1604                .await
1605                .map_err(|_| Self::s3_sync_timeout_error(timeout))
1606        })
1607    }
1608
1609    /// Create router with local storage only
1610    pub fn new(local: Arc<LocalStore>) -> Self {
1611        Self {
1612            local,
1613            #[cfg(feature = "s3")]
1614            s3_client: None,
1615            #[cfg(feature = "s3")]
1616            s3_bucket: None,
1617            #[cfg(feature = "s3")]
1618            s3_prefix: String::new(),
1619            #[cfg(feature = "s3")]
1620            sync_tx: None,
1621        }
1622    }
1623
1624    pub fn force_sync(&self) -> Result<(), StoreError> {
1625        self.local.force_sync()
1626    }
1627
1628    /// Create router with local storage + S3 backup
1629    #[cfg(feature = "s3")]
1630    pub async fn with_s3(local: Arc<LocalStore>, config: &S3Config) -> Result<Self, anyhow::Error> {
1631        use aws_sdk_s3::Client as S3Client;
1632
1633        // Build AWS config
1634        let mut aws_config_loader = aws_config::from_env();
1635        aws_config_loader =
1636            aws_config_loader.region(aws_sdk_s3::config::Region::new(config.region.clone()));
1637        let aws_config = aws_config_loader.load().await;
1638
1639        // Build S3 client with custom endpoint
1640        let mut s3_config_builder = aws_sdk_s3::config::Builder::from(&aws_config);
1641        s3_config_builder = s3_config_builder
1642            .endpoint_url(&config.endpoint)
1643            .force_path_style(true);
1644
1645        let s3_client = S3Client::from_conf(s3_config_builder.build());
1646        let bucket = config.bucket.clone();
1647        let prefix = config.prefix.clone().unwrap_or_default();
1648
1649        // Create background sync channel
1650        let (sync_tx, mut sync_rx) = mpsc::unbounded_channel::<S3SyncMessage>();
1651
1652        // Spawn background sync task with bounded concurrent uploads
1653        let sync_client = s3_client.clone();
1654        let sync_bucket = bucket.clone();
1655        let sync_prefix = prefix.clone();
1656
1657        tokio::spawn(async move {
1658            use aws_sdk_s3::primitives::ByteStream;
1659
1660            tracing::info!("S3 background sync task started");
1661
1662            // Keep S3 writes parallel, but avoid dispatch failures during mirror backfill bursts.
1663            let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(8));
1664            let client = std::sync::Arc::new(sync_client);
1665            let bucket = std::sync::Arc::new(sync_bucket);
1666            let prefix = std::sync::Arc::new(sync_prefix);
1667
1668            while let Some(msg) = sync_rx.recv().await {
1669                let client = client.clone();
1670                let bucket = bucket.clone();
1671                let prefix = prefix.clone();
1672                let semaphore = semaphore.clone();
1673
1674                // Spawn each upload with semaphore-bounded concurrency
1675                tokio::spawn(async move {
1676                    // Acquire permit before uploading
1677                    let _permit = semaphore.acquire().await;
1678
1679                    match msg {
1680                        S3SyncMessage::Upload { hash, data } => {
1681                            let key = format!("{}{}.bin", prefix, to_hex(&hash));
1682                            tracing::debug!("S3 uploading {} ({} bytes)", &key, data.len());
1683
1684                            let mut attempt = 1u8;
1685                            loop {
1686                                match client
1687                                    .put_object()
1688                                    .bucket(bucket.as_str())
1689                                    .key(&key)
1690                                    .body(ByteStream::from(data.clone()))
1691                                    .send()
1692                                    .await
1693                                {
1694                                    Ok(_) => {
1695                                        tracing::debug!("S3 upload succeeded: {}", &key);
1696                                        break;
1697                                    }
1698                                    Err(e) if attempt < 3 => {
1699                                        tracing::warn!(
1700                                            "S3 upload retrying {}: attempt={} error={}",
1701                                            &key,
1702                                            attempt,
1703                                            e
1704                                        );
1705                                        tokio::time::sleep(std::time::Duration::from_millis(
1706                                            250 * u64::from(attempt),
1707                                        ))
1708                                        .await;
1709                                        attempt += 1;
1710                                    }
1711                                    Err(e) => {
1712                                        tracing::error!(
1713                                            "S3 upload failed {} after {} attempts: {}",
1714                                            &key,
1715                                            attempt,
1716                                            e
1717                                        );
1718                                        break;
1719                                    }
1720                                }
1721                            }
1722                        }
1723                        S3SyncMessage::Delete { hash } => {
1724                            let key = format!("{}{}.bin", prefix, to_hex(&hash));
1725                            tracing::debug!("S3 deleting {}", &key);
1726
1727                            let mut attempt = 1u8;
1728                            loop {
1729                                match client
1730                                    .delete_object()
1731                                    .bucket(bucket.as_str())
1732                                    .key(&key)
1733                                    .send()
1734                                    .await
1735                                {
1736                                    Ok(_) => break,
1737                                    Err(e) if attempt < 3 => {
1738                                        tracing::warn!(
1739                                            "S3 delete retrying {}: attempt={} error={}",
1740                                            &key,
1741                                            attempt,
1742                                            e
1743                                        );
1744                                        tokio::time::sleep(std::time::Duration::from_millis(
1745                                            250 * u64::from(attempt),
1746                                        ))
1747                                        .await;
1748                                        attempt += 1;
1749                                    }
1750                                    Err(e) => {
1751                                        tracing::error!(
1752                                            "S3 delete failed {} after {} attempts: {}",
1753                                            &key,
1754                                            attempt,
1755                                            e
1756                                        );
1757                                        break;
1758                                    }
1759                                }
1760                            }
1761                        }
1762                    }
1763                });
1764            }
1765        });
1766
1767        tracing::info!(
1768            "S3 storage initialized: bucket={}, prefix={}",
1769            bucket,
1770            prefix
1771        );
1772
1773        Ok(Self {
1774            local,
1775            s3_client: Some(s3_client),
1776            s3_bucket: Some(bucket),
1777            s3_prefix: prefix,
1778            sync_tx: Some(sync_tx),
1779        })
1780    }
1781
1782    /// Store data - writes to LMDB, queues S3 upload in background
1783    pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
1784        // Always write to local first
1785        let is_new = self.local.put_sync(hash, data)?;
1786
1787        // Queue S3 upload only for newly inserted blobs.
1788        // Existing local blobs were already persisted or are handled by explicit repair/push flows.
1789        #[cfg(feature = "s3")]
1790        if is_new {
1791            if let Some(ref tx) = self.sync_tx {
1792                tracing::debug!(
1793                    "Queueing S3 upload for {} ({} bytes)",
1794                    crate::storage::to_hex(&hash)[..16].to_string(),
1795                    data.len(),
1796                );
1797                if let Err(e) = tx.send(S3SyncMessage::Upload {
1798                    hash,
1799                    data: data.to_vec(),
1800                }) {
1801                    tracing::error!("Failed to queue S3 upload: {}", e);
1802                }
1803            }
1804        }
1805
1806        Ok(is_new)
1807    }
1808
1809    /// Store multiple blobs with a single local batch write when supported.
1810    pub fn put_many_report_sync(
1811        &self,
1812        items: &[(Hash, Vec<u8>)],
1813    ) -> Result<PutManyReport, StoreError> {
1814        let report = self.local.put_many_report_sync(items)?;
1815
1816        #[cfg(feature = "s3")]
1817        if let Some(ref tx) = self.sync_tx {
1818            if !report.inserted_hashes.is_empty() {
1819                let inserted: HashSet<Hash> = report.inserted_hashes.iter().copied().collect();
1820                let mut queued = HashSet::new();
1821                for (hash, data) in items {
1822                    if inserted.contains(hash) && queued.insert(*hash) {
1823                        if let Err(e) = tx.send(S3SyncMessage::Upload {
1824                            hash: *hash,
1825                            data: data.clone(),
1826                        }) {
1827                            tracing::error!("Failed to queue S3 upload: {}", e);
1828                        }
1829                    }
1830                }
1831            }
1832        }
1833
1834        Ok(report)
1835    }
1836
1837    /// Store multiple blobs with a single local batch write when supported.
1838    pub fn put_many_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
1839        self.put_many_report_sync(items)
1840            .map(|report| report.inserted)
1841    }
1842
1843    /// Store a locally generated content-addressed batch without rereading
1844    /// committed PoolStore candidates that are already catalogued.
1845    pub fn put_many_optimistic_report_sync(
1846        &self,
1847        items: &[(Hash, Vec<u8>)],
1848    ) -> Result<PutManyReport, StoreError> {
1849        let report = self.local.put_many_optimistic_report_sync(items)?;
1850
1851        #[cfg(feature = "s3")]
1852        if let Some(ref tx) = self.sync_tx {
1853            if !report.inserted_hashes.is_empty() {
1854                let inserted: HashSet<Hash> = report.inserted_hashes.iter().copied().collect();
1855                let mut queued = HashSet::new();
1856                for (hash, data) in items {
1857                    if inserted.contains(hash) && queued.insert(*hash) {
1858                        if let Err(e) = tx.send(S3SyncMessage::Upload {
1859                            hash: *hash,
1860                            data: data.clone(),
1861                        }) {
1862                            tracing::error!("Failed to queue S3 upload: {}", e);
1863                        }
1864                    }
1865                }
1866            }
1867        }
1868
1869        Ok(report)
1870    }
1871
1872    pub fn put_many_optimistic_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
1873        self.put_many_optimistic_report_sync(items)
1874            .map(|report| report.inserted)
1875    }
1876
1877    /// Get data - tries LMDB first, falls back to S3
1878    pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
1879        // Try local first
1880        if let Some(data) = self.local.get_sync(hash)? {
1881            return Ok(Some(data));
1882        }
1883
1884        // Fall back to S3 if configured
1885        #[cfg(feature = "s3")]
1886        if let (Some(ref client), Some(ref bucket)) = (&self.s3_client, &self.s3_bucket) {
1887            let key = format!("{}{}.bin", self.s3_prefix, to_hex(hash));
1888            let client = client.clone();
1889            let bucket = bucket.clone();
1890
1891            match Self::run_s3_future_sync(async move {
1892                client.get_object().bucket(bucket).key(key).send().await
1893            }) {
1894                Ok(Ok(output)) => {
1895                    match Self::run_s3_future_sync(async move { output.body.collect().await }) {
1896                        Ok(Ok(body)) => {
1897                            let data = body.into_bytes().to_vec();
1898                            // Cache locally for future reads
1899                            let _ = self.local.put_sync(*hash, &data);
1900                            return Ok(Some(data));
1901                        }
1902                        Ok(Err(err)) => {
1903                            tracing::warn!("S3 body collect failed: {}", err);
1904                        }
1905                        Err(err) => {
1906                            tracing::warn!("S3 body collect runtime failed: {}", err);
1907                        }
1908                    }
1909                }
1910                Ok(Err(err)) => {
1911                    let service_err = err.into_service_error();
1912                    if !service_err.is_no_such_key() {
1913                        tracing::warn!("S3 get failed: {}", service_err);
1914                    }
1915                }
1916                Err(err) => {
1917                    tracing::warn!("S3 get runtime failed: {}", err);
1918                }
1919            }
1920        }
1921
1922        Ok(None)
1923    }
1924
1925    pub fn get_range_sync(
1926        &self,
1927        hash: &Hash,
1928        start: u64,
1929        end_inclusive: u64,
1930    ) -> Result<Option<Vec<u8>>, StoreError> {
1931        self.local.get_range_sync(hash, start, end_inclusive)
1932    }
1933
1934    pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1935        self.local.blob_size_sync(hash)
1936    }
1937
1938    #[cfg(feature = "lmdb")]
1939    pub fn pool_read_fallback_status(&self) -> Option<PoolReadFallbackStatus> {
1940        self.local.pool_read_fallback_status()
1941    }
1942
1943    pub fn touch_accessed_sync(&self, hash: &Hash, now: u64) -> Result<bool, StoreError> {
1944        self.local.touch_accessed_sync(hash, now)
1945    }
1946
1947    pub fn touch_many_accessed_sync(&self, hashes: &[Hash], now: u64) -> Result<usize, StoreError> {
1948        self.local.touch_many_accessed_sync(hashes, now)
1949    }
1950
1951    pub fn last_accessed_at_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1952        self.local.last_accessed_at_sync(hash)
1953    }
1954
1955    pub fn many_last_accessed_at_sync(
1956        &self,
1957        hashes: &[Hash],
1958    ) -> Result<Vec<(Hash, u64)>, StoreError> {
1959        self.local.many_last_accessed_at_sync(hashes)
1960    }
1961
1962    /// Check if hash exists
1963    pub fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
1964        // Check local first
1965        if self.local.exists(hash)? {
1966            return Ok(true);
1967        }
1968
1969        // Check S3 if configured
1970        #[cfg(feature = "s3")]
1971        if let (Some(ref client), Some(ref bucket)) = (&self.s3_client, &self.s3_bucket) {
1972            let key = format!("{}{}.bin", self.s3_prefix, to_hex(hash));
1973            let client = client.clone();
1974            let bucket = bucket.clone();
1975
1976            match Self::run_s3_future_sync(async move {
1977                client.head_object().bucket(bucket).key(&key).send().await
1978            }) {
1979                Ok(Ok(_)) => return Ok(true),
1980                Ok(Err(err)) => {
1981                    let service_err = err.into_service_error();
1982                    if !service_err.is_not_found() {
1983                        tracing::warn!("S3 head failed: {}", service_err);
1984                    }
1985                }
1986                Err(err) => {
1987                    tracing::warn!("S3 head runtime failed: {}", err);
1988                }
1989            }
1990        }
1991
1992        Ok(false)
1993    }
1994
1995    /// Delete data from both local and S3 stores
1996    pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
1997        let deleted = self.local.delete_sync(hash)?;
1998
1999        // Queue S3 delete if configured
2000        #[cfg(feature = "s3")]
2001        if let Some(ref tx) = self.sync_tx {
2002            let _ = tx.send(S3SyncMessage::Delete { hash: *hash });
2003        }
2004
2005        Ok(deleted)
2006    }
2007
2008    pub fn delete_many_sync(&self, hashes: &[Hash]) -> Result<usize, StoreError> {
2009        let deleted = self.local.delete_many_sync(hashes)?;
2010
2011        #[cfg(feature = "s3")]
2012        if let Some(ref tx) = self.sync_tx {
2013            for hash in hashes {
2014                let _ = tx.send(S3SyncMessage::Delete { hash: *hash });
2015            }
2016        }
2017
2018        Ok(deleted)
2019    }
2020
2021    /// Delete data from local store only (don't propagate to S3)
2022    /// Used for eviction where we want to keep archives and cold tiers intact.
2023    pub fn delete_local_only(&self, hash: &Hash) -> Result<bool, StoreError> {
2024        self.local.delete_writable_sync(hash)
2025    }
2026
2027    pub fn delete_many_local_only(&self, hashes: &[Hash]) -> Result<usize, StoreError> {
2028        self.local.delete_many_writable_sync(hashes)
2029    }
2030
2031    pub fn full_deletes_blocked(&self) -> bool {
2032        self.local.full_deletes_blocked()
2033    }
2034
2035    /// Get stats from local store
2036    pub fn stats(&self) -> Result<LocalStoreStats, StoreError> {
2037        self.local.stats()
2038    }
2039
2040    /// Get stats for the writable local tier used for quota and eviction pressure.
2041    pub fn writable_stats(&self) -> Result<LocalStoreStats, StoreError> {
2042        self.local.writable_stats()
2043    }
2044
2045    /// List all hashes from local store
2046    pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
2047        self.local.list()
2048    }
2049
2050    /// List hashes from the writable local tier used for quota and eviction pressure.
2051    pub fn list_writable(&self) -> Result<Vec<Hash>, StoreError> {
2052        self.local.list_writable()
2053    }
2054
2055    /// Scan hashes from the writable local tier without materializing it.
2056    pub fn scan_writable_hashes_after(
2057        &self,
2058        after: Option<Hash>,
2059        limit: usize,
2060    ) -> Result<Vec<Hash>, StoreError> {
2061        self.local.scan_writable_hashes_after(after, limit)
2062    }
2063
2064    /// Mark which sorted candidate hashes already exist in local storage.
2065    pub fn existing_local_hashes_in_sorted_candidates(
2066        &self,
2067        sorted_hashes: &[Hash],
2068    ) -> Result<Vec<bool>, StoreError> {
2069        self.local
2070            .existing_hashes_in_sorted_candidates(sorted_hashes)
2071    }
2072
2073    /// Get the underlying local store for HashTree operations
2074    pub fn local_store(&self) -> Arc<LocalStore> {
2075        Arc::clone(&self.local)
2076    }
2077}
2078
2079#[derive(Clone)]
2080struct AccessRecordingStore {
2081    inner: Arc<StorageRouter>,
2082    accessed: Arc<Mutex<HashSet<Hash>>>,
2083}
2084
2085impl AccessRecordingStore {
2086    fn new(inner: Arc<StorageRouter>) -> Self {
2087        Self {
2088            inner,
2089            accessed: Arc::new(Mutex::new(HashSet::new())),
2090        }
2091    }
2092
2093    fn take_accessed_hashes(&self) -> Vec<Hash> {
2094        let Ok(mut accessed) = self.accessed.lock() else {
2095            return Vec::new();
2096        };
2097        accessed.drain().collect()
2098    }
2099
2100    fn record_access(&self, hash: &Hash) {
2101        let Ok(mut accessed) = self.accessed.lock() else {
2102            return;
2103        };
2104        accessed.insert(*hash);
2105    }
2106}
2107
2108#[async_trait]
2109impl Store for AccessRecordingStore {
2110    async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
2111        self.inner.put(hash, data).await
2112    }
2113
2114    async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
2115        self.inner.put_many(items).await
2116    }
2117
2118    async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
2119        let data = self.inner.get(hash).await?;
2120        if data.is_some() {
2121            self.record_access(hash);
2122        }
2123        Ok(data)
2124    }
2125
2126    async fn get_range(
2127        &self,
2128        hash: &Hash,
2129        start: u64,
2130        end_inclusive: u64,
2131    ) -> Result<Option<Vec<u8>>, StoreError> {
2132        let data = self.inner.get_range(hash, start, end_inclusive).await?;
2133        if data.is_some() {
2134            self.record_access(hash);
2135        }
2136        Ok(data)
2137    }
2138
2139    async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
2140        self.inner.blob_size(hash).await
2141    }
2142
2143    async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
2144        self.inner.has(hash).await
2145    }
2146
2147    async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
2148        self.inner.delete(hash).await
2149    }
2150
2151    async fn delete_many(&self, hashes: Vec<Hash>) -> Result<usize, StoreError> {
2152        self.inner.delete_many(hashes).await
2153    }
2154}
2155
2156// Implement async Store trait for StorageRouter so it can be used directly with HashTree
2157// This ensures all writes go through S3 sync
2158#[async_trait]
2159impl Store for StorageRouter {
2160    async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
2161        self.put_sync(hash, &data)
2162    }
2163
2164    async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
2165        self.put_many_sync(&items)
2166    }
2167
2168    async fn put_many_optimistic(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
2169        self.put_many_optimistic_sync(&items)
2170    }
2171
2172    async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
2173        self.get_sync(hash)
2174    }
2175
2176    async fn get_range(
2177        &self,
2178        hash: &Hash,
2179        start: u64,
2180        end_inclusive: u64,
2181    ) -> Result<Option<Vec<u8>>, StoreError> {
2182        if let Some(data) = self.get_range_sync(hash, start, end_inclusive)? {
2183            return Ok(Some(data));
2184        }
2185        let Some(data) = self.get_sync(hash)? else {
2186            return Ok(None);
2187        };
2188        Ok(Some(slice_blob_range(&data, start, end_inclusive)?))
2189    }
2190
2191    async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
2192        if let Some(size) = self.blob_size_sync(hash)? {
2193            return Ok(Some(size));
2194        }
2195        Ok(self.get_sync(hash)?.map(|data| data.len() as u64))
2196    }
2197
2198    async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
2199        self.exists(hash)
2200    }
2201
2202    async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
2203        self.delete_sync(hash)
2204    }
2205
2206    async fn delete_many(&self, hashes: Vec<Hash>) -> Result<usize, StoreError> {
2207        self.delete_many_sync(&hashes)
2208    }
2209}
2210
2211#[derive(Debug, Clone, Copy)]
2212struct OrphanSweep {
2213    /// Cursor immediately before this sweep began. A non-`None` sweep wraps at
2214    /// the end of the keyspace and finishes once it reaches this boundary.
2215    start_after: Option<Hash>,
2216    wrapped: bool,
2217}
2218
2219#[derive(Debug, Default)]
2220struct OrphanScanState {
2221    /// Last candidate examined. It need not still exist after cleanup.
2222    cursor: Option<Hash>,
2223    sweep: Option<OrphanSweep>,
2224    socialgraph_roots: Option<Vec<String>>,
2225    socialgraph_protected: Arc<HashSet<Hash>>,
2226}
2227
2228pub struct HashtreeStore {
2229    base_path: PathBuf,
2230    env: ManagedEnv,
2231    /// Set of pinned hashes (32-byte raw hashes, prevents garbage collection)
2232    pins: Database<Bytes, Unit>,
2233    /// Mutable published refs that should stay subscribed and keep following updates
2234    pinned_refs: Database<Str, Unit>,
2235    /// Authors whose hashtree publications should be mirrored continuously
2236    tracked_authors: Database<Str, Unit>,
2237    /// Blob ownership: sha256 (32 bytes) ++ pubkey (32 bytes) -> () (composite key for multi-owner)
2238    blob_owners: Database<Bytes, Unit>,
2239    /// Maps pubkey (32 bytes) -> blob metadata JSON (for blossom list)
2240    pubkey_blobs: Database<Bytes, Bytes>,
2241    /// Pubkey listing index: pubkey (32 bytes) ++ sha256 (32 bytes) -> BlobMetadata JSON
2242    pubkey_blob_index: Database<Bytes, Bytes>,
2243    /// Tree metadata for eviction: tree_root_hash (32 bytes) -> TreeMeta (msgpack)
2244    tree_meta: Database<Bytes, Bytes>,
2245    /// Blob-to-tree mapping: blob_hash ++ tree_hash (64 bytes) -> ()
2246    blob_trees: Database<Bytes, Unit>,
2247    /// Tree refs: "npub/path" -> tree_root_hash (32 bytes) - for replacing old versions
2248    tree_refs: Database<Str, Bytes>,
2249    /// Cached roots from Nostr: "pubkey_hex/tree_name" -> CachedRoot (msgpack)
2250    cached_roots: Database<Str, Bytes>,
2251    /// Storage router - handles LMDB + optional S3 (Arc for sharing with HashTree)
2252    router: Arc<StorageRouter>,
2253    #[cfg(feature = "lmdb")]
2254    physical_space: Option<PhysicalSpaceGuard>,
2255    /// Maximum storage size in bytes (from config)
2256    max_size_bytes: u64,
2257    /// Whether quota enforcement may delete local blobs not tracked by any indexed tree.
2258    evict_orphans: bool,
2259    /// Coalesces retention cleanup, accounts cache writes, and protects durable metadata gaps.
2260    cache_quota: CacheQuotaController,
2261    /// Resumable bounded orphan scan and its per-sweep socialgraph protection.
2262    orphan_scan: Mutex<OrphanScanState>,
2263    /// Best-effort in-memory throttle for blob access metadata writes.
2264    blob_access_update_gate: BlobAccessUpdateGate,
2265    /// Keeps access-time maintenance out of foreground blob reads.
2266    blob_access_update_inflight: Arc<AtomicBool>,
2267    /// Immutable file chunk metadata cache for hot range-read workloads.
2268    file_metadata_cache: Mutex<LruCache<Hash, Arc<FileChunkMetadata>>>,
2269}
2270
2271impl HashtreeStore {
2272    /// Create a new store with the configured local storage limit.
2273    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
2274        let config = hashtree_config::Config::load_or_default();
2275        let max_size_bytes = config
2276            .storage
2277            .max_size_gb
2278            .saturating_mul(1024 * 1024 * 1024);
2279        Self::with_options_and_backend(
2280            path,
2281            None,
2282            max_size_bytes,
2283            config.storage.evict_orphans,
2284            &config.storage.backend,
2285        )
2286    }
2287
2288    /// Create a new store with an explicit local backend and size limit.
2289    pub fn new_with_backend<P: AsRef<Path>>(
2290        path: P,
2291        backend: hashtree_config::StorageBackend,
2292        max_size_bytes: u64,
2293    ) -> Result<Self> {
2294        Self::with_options_and_backend(path, None, max_size_bytes, true, &backend)
2295    }
2296
2297    /// Create a new store with optional S3 backend and the configured local storage limit.
2298    pub fn with_s3<P: AsRef<Path>>(path: P, s3_config: Option<&S3Config>) -> Result<Self> {
2299        let config = hashtree_config::Config::load_or_default();
2300        let max_size_bytes = config
2301            .storage
2302            .max_size_gb
2303            .saturating_mul(1024 * 1024 * 1024);
2304        Self::with_options_and_backend(
2305            path,
2306            s3_config,
2307            max_size_bytes,
2308            config.storage.evict_orphans,
2309            &config.storage.backend,
2310        )
2311    }
2312
2313    /// Create a new store with optional S3 backend and custom size limit.
2314    ///
2315    /// The raw local blob backend remains unbounded. `HashtreeStore` enforces
2316    /// `max_size_bytes` at the tree-management layer so eviction can honor pins,
2317    /// orphan handling, and local-only eviction when S3 is used as archive.
2318    pub fn with_options<P: AsRef<Path>>(
2319        path: P,
2320        s3_config: Option<&S3Config>,
2321        max_size_bytes: u64,
2322    ) -> Result<Self> {
2323        let config = hashtree_config::Config::load_or_default();
2324        Self::with_options_and_backend(
2325            path,
2326            s3_config,
2327            max_size_bytes,
2328            config.storage.evict_orphans,
2329            &config.storage.backend,
2330        )
2331    }
2332
2333    /// Catch-up alone opts into fd-based write admission on every local backend.
2334    /// Unsupported backends fail before opening any writable environment.
2335    pub fn with_catchup_physical_space<P: AsRef<Path>>(
2336        path: P,
2337        s3_config: Option<&S3Config>,
2338        max_size_bytes: u64,
2339        minimum_free_bytes: u64,
2340    ) -> Result<Self> {
2341        let config = hashtree_config::Config::load_or_default();
2342        anyhow::ensure!(
2343            config.storage.backend == StorageBackend::Lmdb && s3_config.is_none(),
2344            "physical catch-up admission requires the local LMDB backend without S3"
2345        );
2346        Self::with_options_and_backend_and_env_flags(
2347            path,
2348            None,
2349            max_size_bytes,
2350            false,
2351            &config.storage.backend,
2352            EnvFlags::empty(),
2353            Some(minimum_free_bytes),
2354        )
2355    }
2356
2357    pub fn physical_space_status(&self) -> String {
2358        #[cfg(feature = "lmdb")]
2359        if let Some(guard) = &self.physical_space {
2360            return guard.status();
2361        }
2362        "physical-space admission disabled".into()
2363    }
2364
2365    /// The atomic checkpoint consists of one small file and same-directory
2366    /// publication; charge its exact bytes plus the common metadata margin.
2367    pub fn admit_checkpoint_write(&self, path: &Path, bytes: usize) -> Result<()> {
2368        #[cfg(feature = "lmdb")]
2369        if let Some(guard) = &self.physical_space {
2370            let parent = path.parent().context("checkpoint has no parent")?;
2371            guard.create_dir_all(parent)?;
2372            guard
2373                .admit_file(&std::fs::File::open(parent)?, 0, bytes)
2374                .with_context(|| guard.status())?;
2375        }
2376        #[cfg(not(feature = "lmdb"))]
2377        let _ = (path, bytes);
2378        Ok(())
2379    }
2380
2381    pub fn with_options_and_backend<P: AsRef<Path>>(
2382        path: P,
2383        s3_config: Option<&S3Config>,
2384        max_size_bytes: u64,
2385        evict_orphans: bool,
2386        backend: &hashtree_config::StorageBackend,
2387    ) -> Result<Self> {
2388        Self::with_options_and_backend_and_env_flags(
2389            path,
2390            s3_config,
2391            max_size_bytes,
2392            evict_orphans,
2393            backend,
2394            EnvFlags::empty(),
2395            None,
2396        )
2397    }
2398
2399    /// Create a store for an embedded, single-process hashtree host.
2400    ///
2401    /// The macOS app sandbox denies LMDB's default System V semaphore locks.
2402    /// The embedded host owns this data directory in one process, so it uses
2403    /// external process isolation plus LMDB `NO_LOCK` for metadata and the
2404    /// filesystem blob backend to avoid opening a second LMDB environment.
2405    pub fn with_embedded_options<P: AsRef<Path>>(
2406        path: P,
2407        s3_config: Option<&S3Config>,
2408        max_size_bytes: u64,
2409    ) -> Result<Self> {
2410        Self::with_options_and_backend_and_env_flags(
2411            path,
2412            s3_config,
2413            max_size_bytes,
2414            true,
2415            &hashtree_config::StorageBackend::Fs,
2416            EnvFlags::NO_LOCK,
2417            None,
2418        )
2419    }
2420
2421    fn with_options_and_backend_and_env_flags<P: AsRef<Path>>(
2422        path: P,
2423        s3_config: Option<&S3Config>,
2424        max_size_bytes: u64,
2425        evict_orphans: bool,
2426        backend: &hashtree_config::StorageBackend,
2427        env_flags: EnvFlags,
2428        minimum_free_bytes: Option<u64>,
2429    ) -> Result<Self> {
2430        #[cfg(not(feature = "lmdb"))]
2431        anyhow::ensure!(
2432            minimum_free_bytes.is_none(),
2433            "physical catch-up admission requires the LMDB feature"
2434        );
2435        #[cfg(feature = "lmdb")]
2436        let physical_space = minimum_free_bytes
2437            .map(PhysicalSpaceGuard::new)
2438            .transpose()?;
2439        let env_flags = env_flags | lmdb_env_flags_from_env();
2440        let path = path.as_ref();
2441        #[cfg(feature = "lmdb")]
2442        if let Some(guard) = &physical_space {
2443            guard.create_dir_all(path)?;
2444        }
2445        std::fs::create_dir_all(path)?;
2446        let metadata_map_size = lmdb_map_size_for_existing_env(
2447            path,
2448            lmdb_metadata_map_size_for_storage_budget(max_size_bytes),
2449        )?;
2450
2451        let mut env_options = EnvOpenOptions::new();
2452        #[cfg(feature = "lmdb")]
2453        if let Some(guard) = &physical_space {
2454            guard.install(&mut env_options)?;
2455        }
2456        env_options
2457            .map_size(metadata_map_size)
2458            .max_dbs(11) // pins, pinned_refs, tracked_authors, blob_owners, pubkey_blobs, pubkey_blob_index, tree_meta, blob_trees, tree_refs, cached_roots, blobs
2459            .max_readers(LMDB_MAX_READERS);
2460        unsafe {
2461            env_options.flags(env_flags);
2462        }
2463        let env = unsafe { ManagedEnv::open(&env_options, path)? };
2464        let _ = env.clear_stale_readers();
2465        if env.info().map_size < metadata_map_size {
2466            unsafe { env.resize(metadata_map_size) }?;
2467        }
2468
2469        let mut wtxn = env.write_txn()?;
2470        let pins = env.create_database(&mut wtxn, Some("pins"))?;
2471        let pinned_refs = env.create_database(&mut wtxn, Some("pinned_refs"))?;
2472        let tracked_authors = env.create_database(&mut wtxn, Some("tracked_authors"))?;
2473        let blob_owners = env.create_database(&mut wtxn, Some("blob_owners"))?;
2474        let pubkey_blobs = env.create_database(&mut wtxn, Some("pubkey_blobs"))?;
2475        let pubkey_blob_index = env.create_database(&mut wtxn, Some("pubkey_blob_index"))?;
2476        let tree_meta = env.create_database(&mut wtxn, Some("tree_meta"))?;
2477        let blob_trees = env.create_database(&mut wtxn, Some("blob_trees"))?;
2478        let tree_refs = env.create_database(&mut wtxn, Some("tree_refs"))?;
2479        let cached_roots = env.create_database(&mut wtxn, Some("cached_roots"))?;
2480        wtxn.commit()?;
2481
2482        // Intentionally keep the raw blob backend unbounded here. HashtreeStore
2483        // owns quota policy above this layer, where it can coordinate eviction
2484        // with tree refs, blob ownership, pins, and S3 archival behavior.
2485        let local_store = open_local_blob_store_with_options(
2486            path,
2487            backend,
2488            max_size_bytes,
2489            #[cfg(feature = "lmdb")]
2490            physical_space.as_ref(),
2491        )
2492        .map_err(|e| anyhow::anyhow!("Failed to create blob store: {}", e))?;
2493        let pool_audit_read_only = local_store.is_pool_audit_read_only();
2494
2495        // Create storage router with optional S3
2496        #[cfg(feature = "s3")]
2497        let router = Arc::new(if let Some(s3_cfg) = s3_config {
2498            tracing::info!(
2499                "Initializing S3 storage backend: bucket={}, endpoint={}",
2500                s3_cfg.bucket,
2501                s3_cfg.endpoint
2502            );
2503
2504            sync_block_on(async { StorageRouter::with_s3(local_store, s3_cfg).await })?
2505        } else {
2506            StorageRouter::new(local_store)
2507        });
2508
2509        #[cfg(not(feature = "s3"))]
2510        let router = Arc::new({
2511            if s3_config.is_some() {
2512                tracing::warn!(
2513                    "S3 config provided but S3 feature not enabled. Using local storage only."
2514                );
2515            }
2516            StorageRouter::new(local_store)
2517        });
2518
2519        Ok(Self {
2520            base_path: path.to_path_buf(),
2521            env,
2522            pins,
2523            pinned_refs,
2524            tracked_authors,
2525            blob_owners,
2526            pubkey_blobs,
2527            pubkey_blob_index,
2528            tree_meta,
2529            blob_trees,
2530            tree_refs,
2531            cached_roots,
2532            router,
2533            #[cfg(feature = "lmdb")]
2534            physical_space,
2535            max_size_bytes,
2536            evict_orphans: evict_orphans && !pool_audit_read_only,
2537            cache_quota: CacheQuotaController::default(),
2538            orphan_scan: Mutex::new(OrphanScanState::default()),
2539            blob_access_update_gate: BlobAccessUpdateGate::default(),
2540            blob_access_update_inflight: Arc::new(AtomicBool::new(false)),
2541            file_metadata_cache: Mutex::new(LruCache::new(file_metadata_cache_entries())),
2542        })
2543    }
2544
2545    pub fn base_path(&self) -> &Path {
2546        &self.base_path
2547    }
2548
2549    pub fn is_pool_audit_read_only(&self) -> bool {
2550        self.router.local_store().is_pool_audit_read_only()
2551    }
2552
2553    /// Get the storage router
2554    pub fn router(&self) -> &StorageRouter {
2555        &self.router
2556    }
2557
2558    /// Get the storage router as Arc (for use with HashTree which needs Arc<dyn Store>)
2559    /// All writes through this go to both LMDB and S3
2560    pub fn store_arc(&self) -> Arc<StorageRouter> {
2561        Arc::clone(&self.router)
2562    }
2563
2564    pub fn force_sync(&self) -> Result<()> {
2565        if self.is_pool_audit_read_only() {
2566            return self
2567                .router
2568                .force_sync()
2569                .map_err(|err| anyhow::anyhow!("Failed to sync blob store: {}", err));
2570        }
2571        self.env.force_sync()?;
2572        self.router
2573            .force_sync()
2574            .map_err(|err| anyhow::anyhow!("Failed to sync blob store: {}", err))
2575    }
2576
2577    /// Called only while opening an exclusively owned metadata namespace,
2578    /// before sharing the store with tasks. Its checkpoint commit must remain
2579    /// synchronous even when disposable stores use relaxed process defaults.
2580    pub(crate) fn enable_durable_metadata_commits(&mut self) -> Result<()> {
2581        // SAFETY: the caller owns the namespace writer lock and has not exposed
2582        // this store. No other thread can change its environment flags.
2583        unsafe {
2584            self.env.set_flags(
2585                EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC | EnvFlags::MAP_ASYNC,
2586                heed::FlagSetMode::Disable,
2587            )?;
2588        }
2589        Ok(())
2590    }
2591
2592    fn access_tracking_tree(&self) -> (HashTree<AccessRecordingStore>, AccessRecordingStore) {
2593        let access_store = AccessRecordingStore::new(self.store_arc());
2594        let tree = HashTree::new(HashTreeConfig::new(Arc::new(access_store.clone())).public());
2595        (tree, access_store)
2596    }
2597
2598    pub fn record_blob_accesses<I>(&self, hashes: I)
2599    where
2600        I: IntoIterator<Item = Hash>,
2601    {
2602        if self.is_pool_audit_read_only() {
2603            return;
2604        }
2605        let access_update_batch_limit = access_update_background_batch_limit();
2606        if access_update_batch_limit == 0 {
2607            return;
2608        }
2609
2610        let now = unix_timestamp_now();
2611        let mut due_hashes = self.blob_access_update_gate.due_hashes(hashes, now);
2612        if due_hashes.is_empty() {
2613            return;
2614        }
2615
2616        if self
2617            .blob_access_update_inflight
2618            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
2619            .is_err()
2620        {
2621            return;
2622        }
2623
2624        if due_hashes.len() > access_update_batch_limit {
2625            due_hashes.truncate(access_update_batch_limit);
2626        }
2627
2628        let router = Arc::clone(&self.router);
2629        let inflight = Arc::clone(&self.blob_access_update_inflight);
2630        let spawn_result = std::thread::Builder::new()
2631            .name("blob-access-update".to_string())
2632            .spawn(move || {
2633                if let Err(err) = router.touch_many_accessed_sync(&due_hashes, now) {
2634                    tracing::debug!("Failed to update blob access metadata: {}", err);
2635                }
2636                inflight.store(false, Ordering::Release);
2637            });
2638        if let Err(err) = spawn_result {
2639            self.blob_access_update_inflight
2640                .store(false, Ordering::Release);
2641            tracing::debug!("Failed to spawn blob access metadata updater: {}", err);
2642        }
2643    }
2644
2645    pub fn blob_last_accessed_at(&self, hash: &Hash) -> Result<Option<u64>> {
2646        self.router
2647            .last_accessed_at_sync(hash)
2648            .map_err(|e| anyhow::anyhow!("Failed to read blob access metadata: {}", e))
2649    }
2650
2651    pub fn blob_last_accessed_many(&self, hashes: &[Hash]) -> Result<Vec<(Hash, u64)>> {
2652        self.router
2653            .many_last_accessed_at_sync(hashes)
2654            .map_err(|e| anyhow::anyhow!("Failed to read blob access metadata: {}", e))
2655    }
2656
2657    /// Get tree node by hash (raw bytes)
2658    pub fn get_tree_node(&self, hash: &[u8; 32]) -> Result<Option<TreeNode>> {
2659        let (tree, access_store) = self.access_tracking_tree();
2660
2661        let result = sync_block_on(async {
2662            tree.get_tree_node(hash)
2663                .await
2664                .map_err(|e| anyhow::anyhow!("Failed to get tree node: {}", e))
2665        })?;
2666        if result.is_some() {
2667            self.record_blob_accesses(access_store.take_accessed_hashes());
2668        }
2669        Ok(result)
2670    }
2671
2672    /// Store a raw blob, returns SHA256 hash as hex.
2673    pub fn put_blob(&self, data: &[u8]) -> Result<String> {
2674        let hash = sha256(data);
2675        self.router
2676            .put_sync(hash, data)
2677            .map_err(|e| anyhow::anyhow!("Failed to store blob: {}", e))?;
2678        Ok(to_hex(&hash))
2679    }
2680
2681    /// Store an owned Blossom blob under the configured durable storage limit.
2682    pub fn put_owned_blob_with_inserted(
2683        &self,
2684        data: &[u8],
2685        pubkey: &[u8; 32],
2686    ) -> Result<(String, bool)> {
2687        self.put_owned_blob_with_inserted_after_body(data, pubkey, || {})
2688    }
2689
2690    fn put_owned_blob_with_inserted_after_body(
2691        &self,
2692        data: &[u8],
2693        pubkey: &[u8; 32],
2694        after_body_write: impl FnOnce(),
2695    ) -> Result<(String, bool)> {
2696        let hash = sha256(data);
2697        let incoming_bytes = data.len() as u64;
2698        let _retention_guard = self
2699            .cache_quota
2700            .protect_retention_hashes(vec![hash])
2701            .map_err(|denial| {
2702                anyhow::anyhow!("owned blob write cannot race retention cleanup: {denial}")
2703            })?;
2704        let mut retried_after_cleanup = false;
2705        let inserted = loop {
2706            match self.router.put_sync(hash, data) {
2707                Ok(inserted) => break inserted,
2708                Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
2709                    let freed = self.make_room_for_durable_blob(incoming_bytes)?;
2710                    if freed == 0 {
2711                        return Err(anyhow::anyhow!("Failed to store blob: {}", err));
2712                    }
2713                    retried_after_cleanup = true;
2714                }
2715                Err(err) => return Err(anyhow::anyhow!("Failed to store blob: {}", err)),
2716            }
2717        };
2718
2719        after_body_write();
2720        self.set_blob_owner_with_size(&hash, pubkey, incoming_bytes)?;
2721        if inserted {
2722            if let Err(err) = self.enforce_durable_blob_budget_after_insert(incoming_bytes) {
2723                let _ = self.delete_blossom_blob(&hash, pubkey);
2724                return Err(err);
2725            }
2726        }
2727
2728        Ok((to_hex(&hash), inserted))
2729    }
2730
2731    pub fn put_owned_blob(&self, data: &[u8], pubkey: &[u8; 32]) -> Result<String> {
2732        self.put_owned_blob_with_inserted(data, pubkey)
2733            .map(|(hash, _)| hash)
2734    }
2735
2736    fn put_blob_owners_for_batch(
2737        &self,
2738        items: &[(Hash, Vec<u8>)],
2739        pubkey: &[u8; 32],
2740    ) -> Result<()> {
2741        let now = SystemTime::now()
2742            .duration_since(UNIX_EPOCH)
2743            .unwrap()
2744            .as_secs();
2745        let mut wtxn = self.env.write_txn()?;
2746        for (hash, data) in items {
2747            let owner_key = Self::blob_owner_key(hash, pubkey);
2748            match self.blob_owners.put_with_flags(
2749                &mut wtxn,
2750                PutFlags::NO_OVERWRITE,
2751                &owner_key[..],
2752                &(),
2753            ) {
2754                Ok(()) => {}
2755                Err(HeedError::Mdb(MdbError::KeyExist)) => continue,
2756                Err(error) => return Err(error.into()),
2757            }
2758
2759            let index_key = Self::pubkey_blob_key(pubkey, hash);
2760            let metadata = BlobMetadata {
2761                sha256: to_hex(hash),
2762                size: data.len() as u64,
2763                mime_type: "application/octet-stream".to_string(),
2764                uploaded: now,
2765            };
2766            self.pubkey_blob_index.put(
2767                &mut wtxn,
2768                &index_key[..],
2769                &serde_json::to_vec(&metadata)?,
2770            )?;
2771        }
2772        wtxn.commit()?;
2773        Ok(())
2774    }
2775
2776    fn put_many_durable_blob_bodies(
2777        &self,
2778        items: &[(Hash, Vec<u8>)],
2779        incoming_bytes: u64,
2780    ) -> Result<PutManyReport> {
2781        let mut retried_after_cleanup = false;
2782        loop {
2783            match self.router.put_many_report_sync(items) {
2784                Ok(report) => return Ok(report),
2785                Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
2786                    let freed = self.make_room_for_durable_blob(incoming_bytes)?;
2787                    if freed == 0 {
2788                        return Err(anyhow::anyhow!("Failed to store blob batch: {}", err));
2789                    }
2790                    retried_after_cleanup = true;
2791                }
2792                Err(err) => return Err(anyhow::anyhow!("Failed to store blob batch: {}", err)),
2793            }
2794        }
2795    }
2796
2797    /// Store multiple owned Blossom blobs, batching raw blob and owner-index writes.
2798    pub fn put_owned_blobs_report(
2799        &self,
2800        items: &[(Hash, Vec<u8>)],
2801        pubkey: &[u8; 32],
2802    ) -> Result<PutManyReport> {
2803        self.put_owned_blobs_report_after_bodies(items, pubkey, || {})
2804    }
2805
2806    fn put_owned_blobs_report_after_bodies(
2807        &self,
2808        items: &[(Hash, Vec<u8>)],
2809        pubkey: &[u8; 32],
2810        after_body_write: impl FnOnce(),
2811    ) -> Result<PutManyReport> {
2812        let started_at = Instant::now();
2813        let slow_log_ms = slow_owned_blob_batch_log_ms();
2814        if items.is_empty() {
2815            return Ok(PutManyReport::default());
2816        }
2817        let _retention_guard = self
2818            .cache_quota
2819            .protect_retention_hashes(items.iter().map(|(hash, _)| *hash).collect())
2820            .map_err(|denial| {
2821                anyhow::anyhow!("owned blob batch cannot race retention cleanup: {denial}")
2822            })?;
2823        let incoming_bytes = items.iter().fold(0u64, |total, (_, data)| {
2824            total.saturating_add(data.len() as u64)
2825        });
2826        let count = items.len();
2827        let raw_started = Instant::now();
2828        let report = self.put_many_durable_blob_bodies(items, incoming_bytes)?;
2829        let raw_write_ms = raw_started.elapsed().as_millis();
2830
2831        after_body_write();
2832        let owner_started = Instant::now();
2833        self.put_blob_owners_for_batch(items, pubkey)?;
2834        let owner_index_ms = owner_started.elapsed().as_millis();
2835        let quota_started = Instant::now();
2836        if report.inserted_bytes > 0 {
2837            if let Err(err) = self.enforce_durable_blob_budget_after_insert(report.inserted_bytes) {
2838                for hash in &report.inserted_hashes {
2839                    let _ = self.delete_blossom_blob(hash, pubkey);
2840                }
2841                return Err(err);
2842            }
2843        }
2844        let quota_ms = quota_started.elapsed().as_millis();
2845        let total_ms = started_at.elapsed().as_millis();
2846        if slow_log_ms.is_some_and(|threshold| total_ms >= threshold) {
2847            tracing::warn!(
2848                blobs = count,
2849                inserted = report.inserted,
2850                incoming_bytes,
2851                inserted_bytes = report.inserted_bytes,
2852                total_ms,
2853                raw_write_ms,
2854                owner_index_ms,
2855                quota_ms,
2856                "slow owned Blossom blob batch write"
2857            );
2858        }
2859        Ok(report)
2860    }
2861
2862    /// Store multiple owned Blossom blobs, returning only the number of new blobs.
2863    pub fn put_owned_blobs(&self, items: &[(Hash, Vec<u8>)], pubkey: &[u8; 32]) -> Result<usize> {
2864        self.put_owned_blobs_report(items, pubkey)
2865            .map(|report| report.inserted)
2866    }
2867
2868    /// Store an opportunistically cached blob.
2869    ///
2870    /// Unlike durable `put_blob` writes, this path may evict disposable orphaned
2871    /// blobs to make room under storage pressure. It intentionally avoids touching
2872    /// indexed trees, social-graph roots, explicit pins, and owned Blossom blobs.
2873    pub fn put_cached_blob_with_inserted(&self, data: &[u8]) -> Result<(String, bool)> {
2874        let hash = sha256(data);
2875        let incoming_bytes = data.len() as u64;
2876
2877        // Existing blobs are a no-op and must remain available even while a
2878        // cleanup leader is working or a failed cleanup is backing off.
2879        let locally_present = self
2880            .router
2881            .existing_local_hashes_in_sorted_candidates(std::slice::from_ref(&hash))
2882            .map_err(|error| anyhow::anyhow!("Failed to check cached blob: {error}"))?
2883            .first()
2884            .copied()
2885            .unwrap_or(false);
2886        if locally_present {
2887            let inserted = self
2888                .router
2889                .put_sync(hash, data)
2890                .map_err(|error| anyhow::anyhow!("Failed to store cached blob: {error}"))?;
2891            return Ok((to_hex(&hash), inserted));
2892        }
2893
2894        let mut permit = self.prepare_cached_blob_write(incoming_bytes, vec![hash], false)?;
2895        let mut retried_after_cleanup = false;
2896        loop {
2897            match self.router.put_sync(hash, data) {
2898                Ok(inserted) => {
2899                    permit.commit(if inserted { incoming_bytes } else { 0 });
2900                    return Ok((to_hex(&hash), inserted));
2901                }
2902                Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
2903                    drop(permit);
2904                    permit = self.prepare_cached_blob_write(incoming_bytes, vec![hash], true)?;
2905                    retried_after_cleanup = true;
2906                }
2907                Err(err) => return Err(anyhow::anyhow!("Failed to store cached blob: {}", err)),
2908            }
2909        }
2910    }
2911
2912    pub fn put_cached_blob(&self, data: &[u8]) -> Result<String> {
2913        self.put_cached_blob_with_inserted(data)
2914            .map(|(hash, _)| hash)
2915    }
2916
2917    /// Store multiple opportunistically cached blobs in one raw storage batch.
2918    pub fn put_cached_blobs_report(&self, items: &[(Hash, Vec<u8>)]) -> Result<PutManyReport> {
2919        let started_at = Instant::now();
2920        let slow_log_ms = slow_cached_blob_batch_log_ms();
2921        if items.is_empty() {
2922            return Ok(PutManyReport::default());
2923        }
2924
2925        let candidate_bytes = items.iter().fold(0u64, |total, (_, data)| {
2926            total.saturating_add(data.len() as u64)
2927        });
2928
2929        let mut unique_candidates = HashMap::<Hash, u64>::new();
2930        for (hash, data) in items {
2931            unique_candidates.entry(*hash).or_insert(data.len() as u64);
2932        }
2933        let mut sorted_candidates = unique_candidates.into_iter().collect::<Vec<_>>();
2934        sorted_candidates.sort_unstable_by_key(|(hash, _)| *hash);
2935        let sorted_hashes = sorted_candidates
2936            .iter()
2937            .map(|(hash, _)| *hash)
2938            .collect::<Vec<_>>();
2939        let existing = self
2940            .router
2941            .existing_local_hashes_in_sorted_candidates(&sorted_hashes)
2942            .map_err(|error| anyhow::anyhow!("Failed to check cached blob batch: {error}"))?;
2943        let mut missing_hashes = Vec::new();
2944        let mut missing_bytes = 0u64;
2945        for ((hash, size), present) in sorted_candidates.into_iter().zip(existing) {
2946            if !present {
2947                missing_hashes.push(hash);
2948                missing_bytes = missing_bytes.saturating_add(size);
2949            }
2950        }
2951
2952        // A duplicate-only batch is a no-op and must not be rejected merely
2953        // because an unrelated cleanup attempt is active or backing off.
2954        let mut permit = if missing_hashes.is_empty() {
2955            None
2956        } else {
2957            Some(self.prepare_cached_blob_write(missing_bytes, missing_hashes.clone(), false)?)
2958        };
2959        let mut retried_after_cleanup = false;
2960        loop {
2961            let raw_started = Instant::now();
2962            match self.router.put_many_report_sync(items) {
2963                Ok(report) => {
2964                    let raw_write_ms = raw_started.elapsed().as_millis();
2965                    if let Some(permit) = permit.take() {
2966                        permit.commit(report.inserted_bytes);
2967                    }
2968                    let quota_ms = 0u128;
2969                    let total_ms = started_at.elapsed().as_millis();
2970                    if slow_log_ms.is_some_and(|threshold| total_ms >= threshold) {
2971                        tracing::warn!(
2972                            blobs = items.len(),
2973                            inserted = report.inserted,
2974                            candidate_bytes,
2975                            inserted_bytes = report.inserted_bytes,
2976                            total_ms,
2977                            raw_write_ms,
2978                            quota_ms,
2979                            "slow cached Blossom blob batch write"
2980                        );
2981                    }
2982                    return Ok(report);
2983                }
2984                Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
2985                    drop(permit.take());
2986                    if missing_hashes.is_empty() {
2987                        return Err(anyhow::anyhow!(
2988                            "Failed to store cached blob batch: {}",
2989                            err
2990                        ));
2991                    }
2992                    permit = Some(self.prepare_cached_blob_write(
2993                        missing_bytes,
2994                        missing_hashes.clone(),
2995                        true,
2996                    )?);
2997                    retried_after_cleanup = true;
2998                }
2999                Err(err) => {
3000                    return Err(anyhow::anyhow!(
3001                        "Failed to store cached blob batch: {}",
3002                        err
3003                    ));
3004                }
3005            }
3006        }
3007    }
3008
3009    /// Store multiple opportunistically cached blobs, returning only the number of new blobs.
3010    pub fn put_cached_blobs(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize> {
3011        self.put_cached_blobs_report(items)
3012            .map(|report| report.inserted)
3013    }
3014
3015    /// Get a raw blob by SHA256 hash (raw bytes).
3016    pub fn get_blob(&self, hash: &[u8; 32]) -> Result<Option<Vec<u8>>> {
3017        let data = self
3018            .router
3019            .get_sync(hash)
3020            .map_err(|e| anyhow::anyhow!("Failed to get blob: {}", e))?;
3021        if data.is_some() {
3022            self.record_blob_accesses(std::iter::once(*hash));
3023        }
3024        Ok(data)
3025    }
3026
3027    pub fn get_blob_range(
3028        &self,
3029        hash: &[u8; 32],
3030        start: u64,
3031        end_inclusive: u64,
3032    ) -> Result<Option<Vec<u8>>> {
3033        let data = self
3034            .router
3035            .get_range_sync(hash, start, end_inclusive)
3036            .map_err(|e| anyhow::anyhow!("Failed to get blob range: {}", e))?;
3037        if data.is_some() {
3038            self.record_blob_accesses(std::iter::once(*hash));
3039        }
3040        Ok(data)
3041    }
3042
3043    pub fn blob_size(&self, hash: &[u8; 32]) -> Result<Option<u64>> {
3044        self.router
3045            .blob_size_sync(hash)
3046            .map_err(|e| anyhow::anyhow!("Failed to get blob size: {}", e))
3047    }
3048
3049    /// Check if a blob exists by SHA256 hash (raw bytes).
3050    pub fn blob_exists(&self, hash: &[u8; 32]) -> Result<bool> {
3051        self.router
3052            .exists(hash)
3053            .map_err(|e| anyhow::anyhow!("Failed to check blob: {}", e))
3054    }
3055
3056    #[cfg(feature = "lmdb")]
3057    pub fn pool_read_fallback_status(&self) -> Option<PoolReadFallbackStatus> {
3058        self.router.pool_read_fallback_status()
3059    }
3060
3061    // === Blossom ownership tracking ===
3062    // Uses composite key: sha256 (32 bytes) ++ pubkey (32 bytes) -> ()
3063    // This allows efficient multi-owner tracking with O(1) lookups
3064
3065    /// Build composite key for blob_owners: sha256 ++ pubkey (64 bytes total)
3066    fn blob_owner_key(sha256: &[u8; 32], pubkey: &[u8; 32]) -> [u8; 64] {
3067        let mut key = [0u8; 64];
3068        key[..32].copy_from_slice(sha256);
3069        key[32..].copy_from_slice(pubkey);
3070        key
3071    }
3072
3073    fn pubkey_blob_key(pubkey: &[u8; 32], sha256: &[u8; 32]) -> [u8; 64] {
3074        let mut key = [0u8; 64];
3075        key[..32].copy_from_slice(pubkey);
3076        key[32..].copy_from_slice(sha256);
3077        key
3078    }
3079
3080    /// Add an owner (pubkey) to a blob for Blossom protocol
3081    /// Multiple users can own the same blob - it's only deleted when all owners remove it
3082    pub fn set_blob_owner(&self, sha256: &[u8; 32], pubkey: &[u8; 32]) -> Result<()> {
3083        let _retention_guard = self
3084            .cache_quota
3085            .protect_retention_hashes(vec![*sha256])
3086            .map_err(|denial| {
3087                anyhow::anyhow!("blob ownership update cannot race retention cleanup: {denial}")
3088            })?;
3089        let size = self
3090            .router
3091            .blob_size_sync(sha256)
3092            .map_err(|e| anyhow::anyhow!("Failed to get blob size: {}", e))?
3093            .unwrap_or(0);
3094        self.set_blob_owner_with_size(sha256, pubkey, size)
3095    }
3096
3097    fn set_blob_owner_with_size(
3098        &self,
3099        sha256: &[u8; 32],
3100        pubkey: &[u8; 32],
3101        size: u64,
3102    ) -> Result<()> {
3103        let key = Self::blob_owner_key(sha256, pubkey);
3104        let index_key = Self::pubkey_blob_key(pubkey, sha256);
3105        let mut wtxn = self.env.write_txn()?;
3106
3107        match self
3108            .blob_owners
3109            .put_with_flags(&mut wtxn, PutFlags::NO_OVERWRITE, &key[..], &())
3110        {
3111            Ok(()) => {}
3112            Err(HeedError::Mdb(MdbError::KeyExist)) => {
3113                wtxn.commit()?;
3114                return Ok(());
3115            }
3116            Err(error) => return Err(error.into()),
3117        }
3118
3119        let now = SystemTime::now()
3120            .duration_since(UNIX_EPOCH)
3121            .unwrap()
3122            .as_secs();
3123        let metadata = BlobMetadata {
3124            sha256: to_hex(sha256),
3125            size,
3126            mime_type: "application/octet-stream".to_string(),
3127            uploaded: now,
3128        };
3129        self.pubkey_blob_index
3130            .put(&mut wtxn, &index_key[..], &serde_json::to_vec(&metadata)?)?;
3131
3132        wtxn.commit()?;
3133        Ok(())
3134    }
3135
3136    /// Check if a pubkey owns a blob
3137    pub fn is_blob_owner(&self, sha256: &[u8; 32], pubkey: &[u8; 32]) -> Result<bool> {
3138        let key = Self::blob_owner_key(sha256, pubkey);
3139        let rtxn = self.env.read_txn()?;
3140        Ok(self.blob_owners.get(&rtxn, &key[..])?.is_some())
3141    }
3142
3143    /// Get all owners (pubkeys) of a blob via prefix scan (returns raw bytes)
3144    pub fn get_blob_owners(&self, sha256: &[u8; 32]) -> Result<Vec<[u8; 32]>> {
3145        let rtxn = self.env.read_txn()?;
3146
3147        let mut owners = Vec::new();
3148        for item in self.blob_owners.prefix_iter(&rtxn, &sha256[..])? {
3149            let (key, _) = item?;
3150            if key.len() == 64 {
3151                // Extract pubkey from composite key (bytes 32-64)
3152                let mut pubkey = [0u8; 32];
3153                pubkey.copy_from_slice(&key[32..64]);
3154                owners.push(pubkey);
3155            }
3156        }
3157        Ok(owners)
3158    }
3159
3160    /// Check if blob has any owners
3161    pub fn blob_has_owners(&self, sha256: &[u8; 32]) -> Result<bool> {
3162        let rtxn = self.env.read_txn()?;
3163
3164        // Just check if any entry exists with this prefix
3165        for item in self.blob_owners.prefix_iter(&rtxn, &sha256[..])? {
3166            if item.is_ok() {
3167                return Ok(true);
3168            }
3169        }
3170        Ok(false)
3171    }
3172
3173    /// Get the first owner (pubkey) of a blob (for backwards compatibility)
3174    pub fn get_blob_owner(&self, sha256: &[u8; 32]) -> Result<Option<[u8; 32]>> {
3175        Ok(self.get_blob_owners(sha256)?.into_iter().next())
3176    }
3177
3178    /// Remove a user's ownership of a blossom blob
3179    /// Only deletes the actual blob when no owners remain
3180    /// Returns true if the blob was actually deleted (no owners left)
3181    pub fn delete_blossom_blob(&self, sha256: &[u8; 32], pubkey: &[u8; 32]) -> Result<bool> {
3182        let key = Self::blob_owner_key(sha256, pubkey);
3183        let mut wtxn = self.env.write_txn()?;
3184
3185        // Remove this pubkey's ownership entry
3186        self.blob_owners.delete(&mut wtxn, &key[..])?;
3187        self.pubkey_blob_index
3188            .delete(&mut wtxn, &Self::pubkey_blob_key(pubkey, sha256)[..])?;
3189
3190        // Hex strings for logging and BlobMetadata (which stores sha256 as hex string)
3191        let sha256_hex = to_hex(sha256);
3192
3193        // Remove from pubkey's blob list
3194        if let Some(blobs_bytes) = self.pubkey_blobs.get(&wtxn, pubkey)? {
3195            if let Ok(mut blobs) = serde_json::from_slice::<Vec<BlobMetadata>>(blobs_bytes) {
3196                blobs.retain(|b| b.sha256 != sha256_hex);
3197                let blobs_json = serde_json::to_vec(&blobs)?;
3198                self.pubkey_blobs.put(&mut wtxn, pubkey, &blobs_json)?;
3199            }
3200        }
3201
3202        // Check if any other owners remain (prefix scan)
3203        let mut has_other_owners = false;
3204        for item in self.blob_owners.prefix_iter(&wtxn, &sha256[..])? {
3205            if item.is_ok() {
3206                has_other_owners = true;
3207                break;
3208            }
3209        }
3210
3211        if has_other_owners {
3212            wtxn.commit()?;
3213            tracing::debug!(
3214                "Removed {} from blob {} owners, other owners remain",
3215                &to_hex(pubkey)[..8],
3216                &sha256_hex[..8]
3217            );
3218            return Ok(false);
3219        }
3220
3221        // No owners left - delete the blob completely
3222        tracing::info!(
3223            "All owners removed from blob {}, deleting",
3224            &sha256_hex[..8]
3225        );
3226
3227        if self.router.full_deletes_blocked() {
3228            return Err(anyhow::anyhow!(POOL_MIGRATION_DELETE_DISABLED));
3229        }
3230
3231        // Delete raw blob (by content hash) - this deletes from S3 too
3232        self.router.delete_sync(sha256)?;
3233
3234        wtxn.commit()?;
3235        Ok(true)
3236    }
3237
3238    /// List all blobs owned by a pubkey (for Blossom /list endpoint)
3239    pub fn list_blobs_by_pubkey(
3240        &self,
3241        pubkey: &[u8; 32],
3242    ) -> Result<Vec<crate::server::blossom::BlobDescriptor>> {
3243        let rtxn = self.env.read_txn()?;
3244
3245        let mut blobs: Vec<BlobMetadata> = self
3246            .pubkey_blobs
3247            .get(&rtxn, pubkey)?
3248            .and_then(|b| serde_json::from_slice(b).ok())
3249            .unwrap_or_default();
3250        let mut seen: HashSet<String> = blobs.iter().map(|blob| blob.sha256.clone()).collect();
3251
3252        for item in self.pubkey_blob_index.prefix_iter(&rtxn, pubkey)? {
3253            let (_, metadata_bytes) = item?;
3254            let metadata: BlobMetadata = match serde_json::from_slice(metadata_bytes) {
3255                Ok(metadata) => metadata,
3256                Err(_) => continue,
3257            };
3258            if seen.insert(metadata.sha256.clone()) {
3259                blobs.push(metadata);
3260            }
3261        }
3262
3263        Ok(blobs
3264            .into_iter()
3265            .map(|b| crate::server::blossom::BlobDescriptor {
3266                url: format!("/{}", b.sha256),
3267                sha256: b.sha256,
3268                size: b.size,
3269                mime_type: b.mime_type,
3270                uploaded: b.uploaded,
3271            })
3272            .collect())
3273    }
3274
3275    /// Get a single chunk/blob by hash (raw bytes)
3276    pub fn get_chunk(&self, hash: &[u8; 32]) -> Result<Option<Vec<u8>>> {
3277        let data = self
3278            .router
3279            .get_sync(hash)
3280            .map_err(|e| anyhow::anyhow!("Failed to get chunk: {}", e))?;
3281        if data.is_some() {
3282            self.record_blob_accesses(std::iter::once(*hash));
3283        }
3284        Ok(data)
3285    }
3286
3287    /// Get file content by hash (raw bytes)
3288    /// Returns raw bytes (caller handles decryption if needed)
3289    pub fn get_file(&self, hash: &[u8; 32]) -> Result<Option<Vec<u8>>> {
3290        let (tree, access_store) = self.access_tracking_tree();
3291
3292        let result = sync_block_on(async {
3293            tree.read_file(hash)
3294                .await
3295                .map_err(|e| anyhow::anyhow!("Failed to read file: {}", e))
3296        })?;
3297        if result.is_some() {
3298            self.record_blob_accesses(access_store.take_accessed_hashes());
3299        }
3300        Ok(result)
3301    }
3302
3303    /// Get file content by Cid (hash + optional decryption key as raw bytes)
3304    /// Handles decryption automatically if key is present
3305    pub fn get_file_by_cid(&self, cid: &Cid) -> Result<Option<Vec<u8>>> {
3306        let (tree, access_store) = self.access_tracking_tree();
3307
3308        let result = sync_block_on(async {
3309            tree.get(cid, None)
3310                .await
3311                .map_err(|e| anyhow::anyhow!("Failed to read file: {}", e))
3312        })?;
3313        if result.is_some() {
3314            self.record_blob_accesses(access_store.take_accessed_hashes());
3315        }
3316        Ok(result)
3317    }
3318
3319    fn ensure_cid_exists(&self, cid: &Cid) -> Result<()> {
3320        let exists = self
3321            .router
3322            .exists(&cid.hash)
3323            .map_err(|e| anyhow::anyhow!("Failed to check cid existence: {}", e))?;
3324        if !exists {
3325            anyhow::bail!("CID not found: {}", to_hex(&cid.hash));
3326        }
3327        Ok(())
3328    }
3329
3330    /// Stream file content identified by Cid into a writer without buffering full file in memory.
3331    pub fn write_file_by_cid_to_writer<W: Write>(&self, cid: &Cid, writer: &mut W) -> Result<u64> {
3332        self.ensure_cid_exists(cid)?;
3333
3334        let (tree, access_store) = self.access_tracking_tree();
3335        let mut total_bytes = 0u64;
3336        let mut streamed_any_chunk = false;
3337
3338        sync_block_on(async {
3339            let mut stream = tree.get_stream(cid);
3340            while let Some(chunk) = stream.next().await {
3341                streamed_any_chunk = true;
3342                let chunk =
3343                    chunk.map_err(|e| anyhow::anyhow!("Failed to stream file chunk: {}", e))?;
3344                writer
3345                    .write_all(&chunk)
3346                    .map_err(|e| anyhow::anyhow!("Failed to write file chunk: {}", e))?;
3347                total_bytes += chunk.len() as u64;
3348            }
3349            Ok::<(), anyhow::Error>(())
3350        })?;
3351
3352        if !streamed_any_chunk {
3353            anyhow::bail!("CID not found: {}", to_hex(&cid.hash));
3354        }
3355        self.record_blob_accesses(access_store.take_accessed_hashes());
3356
3357        writer
3358            .flush()
3359            .map_err(|e| anyhow::anyhow!("Failed to flush output: {}", e))?;
3360        Ok(total_bytes)
3361    }
3362
3363    /// Stream file content identified by Cid directly into a destination path.
3364    pub fn write_file_by_cid<P: AsRef<Path>>(&self, cid: &Cid, output_path: P) -> Result<u64> {
3365        self.ensure_cid_exists(cid)?;
3366
3367        let output_path = output_path.as_ref();
3368        if let Some(parent) = output_path.parent() {
3369            if !parent.as_os_str().is_empty() {
3370                std::fs::create_dir_all(parent).with_context(|| {
3371                    format!("Failed to create output directory {}", parent.display())
3372                })?;
3373            }
3374        }
3375
3376        let mut file = std::fs::File::create(output_path)
3377            .with_context(|| format!("Failed to create output file {}", output_path.display()))?;
3378        self.write_file_by_cid_to_writer(cid, &mut file)
3379    }
3380
3381    /// Stream a public (unencrypted) file by hash directly into a destination path.
3382    pub fn write_file<P: AsRef<Path>>(&self, hash: &[u8; 32], output_path: P) -> Result<u64> {
3383        self.write_file_by_cid(&Cid::public(*hash), output_path)
3384    }
3385
3386    /// Resolve a path within a tree (returns Cid with key if encrypted)
3387    pub fn resolve_path(&self, cid: &Cid, path: &str) -> Result<Option<Cid>> {
3388        let (tree, access_store) = self.access_tracking_tree();
3389
3390        let result = sync_block_on(async {
3391            tree.resolve_path(cid, path)
3392                .await
3393                .map_err(|e| anyhow::anyhow!("Failed to resolve path: {}", e))
3394        })?;
3395        if result.is_some() {
3396            self.record_blob_accesses(access_store.take_accessed_hashes());
3397        }
3398        Ok(result)
3399    }
3400
3401    /// Get chunk metadata for a file (chunk list, sizes, total size)
3402    pub fn get_file_chunk_metadata(
3403        &self,
3404        hash: &[u8; 32],
3405    ) -> Result<Option<Arc<FileChunkMetadata>>> {
3406        if let Ok(mut cache) = self.file_metadata_cache.lock() {
3407            if let Some(metadata) = cache.get(hash).cloned() {
3408                self.record_blob_accesses(std::iter::once(*hash));
3409                return Ok(Some(metadata));
3410            }
3411        }
3412
3413        let access_store = AccessRecordingStore::new(self.store_arc());
3414        let tree = HashTree::new(HashTreeConfig::new(Arc::new(access_store.clone())).public());
3415
3416        let metadata: Result<Option<FileChunkMetadata>> = sync_block_on(async {
3417            // First check if the hash exists in the store at all
3418            // (either as a blob or tree node)
3419            let exists = access_store
3420                .has(hash)
3421                .await
3422                .map_err(|e| anyhow::anyhow!("Failed to check existence: {}", e))?;
3423
3424            if !exists {
3425                return Ok(None);
3426            }
3427
3428            // Get total size
3429            let total_size = tree
3430                .get_size(hash)
3431                .await
3432                .map_err(|e| anyhow::anyhow!("Failed to get size: {}", e))?;
3433
3434            // Check if it's a tree (chunked) or blob
3435            let is_tree_node = tree
3436                .is_tree(hash)
3437                .await
3438                .map_err(|e| anyhow::anyhow!("Failed to check tree: {}", e))?;
3439
3440            if !is_tree_node {
3441                // Single blob, not chunked
3442                return Ok(Some(FileChunkMetadata::single_blob(total_size)));
3443            }
3444
3445            // Get tree node to extract chunk info
3446            let node = match tree
3447                .get_tree_node(hash)
3448                .await
3449                .map_err(|e| anyhow::anyhow!("Failed to get tree node: {}", e))?
3450            {
3451                Some(n) => n,
3452                None => return Ok(None),
3453            };
3454
3455            // Check if it's a directory (has named links)
3456            let is_directory = tree
3457                .is_directory(hash)
3458                .await
3459                .map_err(|e| anyhow::anyhow!("Failed to check directory: {}", e))?;
3460
3461            if is_directory {
3462                return Ok(None); // Not a file
3463            }
3464
3465            // Extract chunk info from links
3466            let chunk_hashes: Vec<Hash> = node.links.iter().map(|l| l.hash).collect();
3467            let chunk_sizes: Vec<u64> = node.links.iter().map(|l| l.size).collect();
3468
3469            Ok(Some(FileChunkMetadata::new(
3470                total_size,
3471                chunk_hashes,
3472                chunk_sizes,
3473            )))
3474        });
3475        let metadata = metadata?;
3476        if metadata.is_some() {
3477            self.record_blob_accesses(access_store.take_accessed_hashes());
3478        }
3479        let Some(metadata) = metadata else {
3480            return Ok(None);
3481        };
3482        let metadata = Arc::new(metadata);
3483        if let Ok(mut cache) = self.file_metadata_cache.lock() {
3484            cache.put(*hash, Arc::clone(&metadata));
3485        }
3486        Ok(Some(metadata))
3487    }
3488
3489    /// Get byte range from file
3490    pub fn get_file_range(
3491        &self,
3492        hash: &[u8; 32],
3493        start: u64,
3494        end: Option<u64>,
3495    ) -> Result<Option<(Vec<u8>, u64)>> {
3496        let metadata = match self.get_file_chunk_metadata(hash)? {
3497            Some(m) => m,
3498            None => return Ok(None),
3499        };
3500
3501        if metadata.total_size == 0 {
3502            return Ok(Some((Vec::new(), 0)));
3503        }
3504
3505        if start >= metadata.total_size {
3506            return Ok(None);
3507        }
3508
3509        let end = end
3510            .unwrap_or(metadata.total_size - 1)
3511            .min(metadata.total_size - 1);
3512
3513        // For non-chunked files, read only the requested blob range.
3514        if !metadata.is_chunked {
3515            let range_content = match self.get_blob_range(hash, start, end)? {
3516                Some(content) => content,
3517                None => return Ok(None),
3518            };
3519            return Ok(Some((range_content, metadata.total_size)));
3520        }
3521
3522        // For chunked files, load only needed chunks
3523        let mut result = Vec::new();
3524        let (start_idx, mut current_offset) = metadata.chunk_start_for_range(start);
3525
3526        for (i, chunk_hash) in metadata.chunk_hashes.iter().enumerate().skip(start_idx) {
3527            let chunk_size = metadata.chunk_sizes[i];
3528            let chunk_end = current_offset + chunk_size - 1;
3529
3530            // Check if this chunk overlaps with requested range
3531            if chunk_end >= start && current_offset <= end {
3532                let chunk_read_start = start.saturating_sub(current_offset);
3533
3534                let chunk_read_end = if chunk_end <= end {
3535                    chunk_size - 1
3536                } else {
3537                    end - current_offset
3538                };
3539
3540                let chunk_content =
3541                    match self.get_blob_range(chunk_hash, chunk_read_start, chunk_read_end)? {
3542                        Some(content) => content,
3543                        None => {
3544                            return Err(anyhow::anyhow!("Chunk {} not found", to_hex(chunk_hash)));
3545                        }
3546                    };
3547
3548                let expected_len = chunk_read_end.saturating_sub(chunk_read_start) + 1;
3549                if chunk_content.len() as u64 != expected_len {
3550                    return Err(anyhow::anyhow!(
3551                        "Chunk {} range returned {} bytes, expected {}",
3552                        to_hex(chunk_hash),
3553                        chunk_content.len(),
3554                        expected_len
3555                    ));
3556                }
3557
3558                result.extend_from_slice(&chunk_content);
3559            }
3560
3561            current_offset += chunk_size;
3562
3563            if current_offset > end {
3564                break;
3565            }
3566        }
3567
3568        Ok(Some((result, metadata.total_size)))
3569    }
3570
3571    /// Stream file range as chunks using Arc for async/Send contexts
3572    pub fn stream_file_range_chunks_owned(
3573        self: Arc<Self>,
3574        hash: &[u8; 32],
3575        start: u64,
3576        end: u64,
3577    ) -> Result<Option<FileRangeChunksOwned>> {
3578        let metadata = match self.get_file_chunk_metadata(hash)? {
3579            Some(m) => m,
3580            None => return Ok(None),
3581        };
3582
3583        if metadata.total_size == 0 || start >= metadata.total_size {
3584            return Ok(None);
3585        }
3586
3587        let end = end.min(metadata.total_size - 1);
3588
3589        let (current_chunk_idx, current_offset) = metadata.chunk_start_for_range(start);
3590
3591        Ok(Some(FileRangeChunksOwned {
3592            store: self,
3593            metadata,
3594            start,
3595            end,
3596            current_chunk_idx,
3597            current_offset,
3598        }))
3599    }
3600
3601    /// Get directory structure by hash (raw bytes)
3602    pub fn get_directory_listing(&self, hash: &[u8; 32]) -> Result<Option<DirectoryListing>> {
3603        let (tree, access_store) = self.access_tracking_tree();
3604
3605        let listing: Result<Option<DirectoryListing>> = sync_block_on(async {
3606            // Check if it's a directory
3607            let is_dir = tree
3608                .is_directory(hash)
3609                .await
3610                .map_err(|e| anyhow::anyhow!("Failed to check directory: {}", e))?;
3611
3612            if !is_dir {
3613                return Ok(None);
3614            }
3615
3616            // Get directory entries (public Cid - no encryption key)
3617            let cid = hashtree_core::Cid::public(*hash);
3618            let tree_entries = tree
3619                .list_directory(&cid)
3620                .await
3621                .map_err(|e| anyhow::anyhow!("Failed to list directory: {}", e))?;
3622
3623            let entries: Vec<DirEntry> = tree_entries
3624                .into_iter()
3625                .map(|e| DirEntry {
3626                    name: e.name,
3627                    cid: to_hex(&e.hash),
3628                    is_directory: e.link_type.is_tree(),
3629                    size: e.size,
3630                })
3631                .collect();
3632
3633            Ok(Some(DirectoryListing {
3634                dir_name: String::new(),
3635                entries,
3636            }))
3637        });
3638        let listing = listing?;
3639        if listing.is_some() {
3640            self.record_blob_accesses(access_store.take_accessed_hashes());
3641        }
3642        Ok(listing)
3643    }
3644
3645    /// Get directory structure by CID, supporting encrypted directories.
3646    pub fn get_directory_listing_by_cid(&self, cid: &Cid) -> Result<Option<DirectoryListing>> {
3647        let (tree, access_store) = self.access_tracking_tree();
3648        let cid = cid.clone();
3649
3650        let listing: Result<Option<DirectoryListing>> = sync_block_on(async {
3651            let is_dir = tree
3652                .is_dir(&cid)
3653                .await
3654                .map_err(|e| anyhow::anyhow!("Failed to check directory: {}", e))?;
3655
3656            if !is_dir {
3657                return Ok(None);
3658            }
3659
3660            let tree_entries = tree
3661                .list_directory(&cid)
3662                .await
3663                .map_err(|e| anyhow::anyhow!("Failed to list directory: {}", e))?;
3664
3665            let entries: Vec<DirEntry> = tree_entries
3666                .into_iter()
3667                .map(|e| DirEntry {
3668                    name: e.name,
3669                    cid: Cid {
3670                        hash: e.hash,
3671                        key: e.key,
3672                    }
3673                    .to_string(),
3674                    is_directory: e.link_type.is_tree(),
3675                    size: e.size,
3676                })
3677                .collect();
3678
3679            Ok(Some(DirectoryListing {
3680                dir_name: String::new(),
3681                entries,
3682            }))
3683        });
3684        let listing = listing?;
3685        if listing.is_some() {
3686            self.record_blob_accesses(access_store.take_accessed_hashes());
3687        }
3688        Ok(listing)
3689    }
3690
3691    // === Cached roots ===
3692
3693    /// Persist a mutable published ref that should stay subscribed.
3694    pub fn add_pinned_ref(&self, key: &str) -> Result<()> {
3695        let mut wtxn = self.env.write_txn()?;
3696        self.pinned_refs.put(&mut wtxn, key, &())?;
3697        wtxn.commit()?;
3698        Ok(())
3699    }
3700
3701    /// Remove a mutable published ref from the live pinned set.
3702    pub fn remove_pinned_ref(&self, key: &str) -> Result<bool> {
3703        let mut wtxn = self.env.write_txn()?;
3704        let removed = self.pinned_refs.delete(&mut wtxn, key)?;
3705        wtxn.commit()?;
3706        Ok(removed)
3707    }
3708
3709    /// List mutable published refs that should stay subscribed.
3710    pub fn list_pinned_refs(&self) -> Result<Vec<String>> {
3711        let rtxn = self.env.read_txn()?;
3712        let mut refs = Vec::new();
3713
3714        for item in self.pinned_refs.iter(&rtxn)? {
3715            let (key, _) = item?;
3716            refs.push(key.to_string());
3717        }
3718
3719        refs.sort();
3720        Ok(refs)
3721    }
3722
3723    /// Persist an author whose published trees should stay mirrored.
3724    pub fn add_tracked_author(&self, npub: &str) -> Result<bool> {
3725        let mut wtxn = self.env.write_txn()?;
3726        let inserted = self.tracked_authors.get(&wtxn, npub)?.is_none();
3727        self.tracked_authors.put(&mut wtxn, npub, &())?;
3728        wtxn.commit()?;
3729        Ok(inserted)
3730    }
3731
3732    /// Remove an author from the continuous mirror set.
3733    pub fn remove_tracked_author(&self, npub: &str) -> Result<bool> {
3734        let mut wtxn = self.env.write_txn()?;
3735        let removed = self.tracked_authors.delete(&mut wtxn, npub)?;
3736        wtxn.commit()?;
3737        Ok(removed)
3738    }
3739
3740    /// List authors whose published trees should stay mirrored.
3741    pub fn list_tracked_authors(&self) -> Result<Vec<String>> {
3742        let rtxn = self.env.read_txn()?;
3743        let mut authors = Vec::new();
3744
3745        for item in self.tracked_authors.iter(&rtxn)? {
3746            let (npub, _) = item?;
3747            authors.push(npub.to_string());
3748        }
3749
3750        authors.sort();
3751        Ok(authors)
3752    }
3753
3754    /// Get cached root for a pubkey/tree_name pair
3755    pub fn get_cached_root(&self, pubkey_hex: &str, tree_name: &str) -> Result<Option<CachedRoot>> {
3756        let key = format!("{}/{}", pubkey_hex, tree_name);
3757        let rtxn = self.env.read_txn()?;
3758        if let Some(bytes) = self.cached_roots.get(&rtxn, &key)? {
3759            let root: CachedRoot = rmp_serde::from_slice(bytes)
3760                .map_err(|e| anyhow::anyhow!("Failed to deserialize CachedRoot: {}", e))?;
3761            Ok(Some(root))
3762        } else {
3763            Ok(None)
3764        }
3765    }
3766
3767    /// Set cached root for a pubkey/tree_name pair
3768    pub fn set_cached_root(
3769        &self,
3770        pubkey_hex: &str,
3771        tree_name: &str,
3772        hash: &str,
3773        key: Option<&str>,
3774        visibility: &str,
3775        updated_at: u64,
3776    ) -> Result<()> {
3777        let db_key = format!("{}/{}", pubkey_hex, tree_name);
3778        let root = CachedRoot {
3779            hash: hash.to_string(),
3780            key: key.map(|k| k.to_string()),
3781            updated_at,
3782            visibility: visibility.to_string(),
3783        };
3784        let bytes = rmp_serde::to_vec(&root)
3785            .map_err(|e| anyhow::anyhow!("Failed to serialize CachedRoot: {}", e))?;
3786        let mut wtxn = self.env.write_txn()?;
3787        self.cached_roots.put(&mut wtxn, &db_key, &bytes)?;
3788        wtxn.commit()?;
3789        Ok(())
3790    }
3791
3792    /// List all cached roots for a pubkey
3793    pub fn list_cached_roots(&self, pubkey_hex: &str) -> Result<Vec<(String, CachedRoot)>> {
3794        let prefix = format!("{}/", pubkey_hex);
3795        let rtxn = self.env.read_txn()?;
3796        let mut results = Vec::new();
3797
3798        for item in self.cached_roots.iter(&rtxn)? {
3799            let (key, bytes) = item?;
3800            if key.starts_with(&prefix) {
3801                let tree_name = key.strip_prefix(&prefix).unwrap_or(key);
3802                let root: CachedRoot = rmp_serde::from_slice(bytes)
3803                    .map_err(|e| anyhow::anyhow!("Failed to deserialize CachedRoot: {}", e))?;
3804                results.push((tree_name.to_string(), root));
3805            }
3806        }
3807
3808        Ok(results)
3809    }
3810
3811    /// Delete a cached root
3812    pub fn delete_cached_root(&self, pubkey_hex: &str, tree_name: &str) -> Result<bool> {
3813        let key = format!("{}/{}", pubkey_hex, tree_name);
3814        let mut wtxn = self.env.write_txn()?;
3815        let deleted = self.cached_roots.delete(&mut wtxn, &key)?;
3816        wtxn.commit()?;
3817        Ok(deleted)
3818    }
3819}
3820
3821fn is_map_full_store_error(err: &StoreError) -> bool {
3822    let message = err.to_string();
3823    message.contains("MDB_MAP_FULL") || message.contains("MapFull")
3824}
3825
3826#[derive(Debug, Clone)]
3827pub struct FileChunkMetadata {
3828    pub total_size: u64,
3829    pub chunk_hashes: Vec<Hash>,
3830    pub chunk_sizes: Vec<u64>,
3831    pub is_chunked: bool,
3832    uniform_chunk_size: Option<u64>,
3833}
3834
3835impl FileChunkMetadata {
3836    fn new(total_size: u64, chunk_hashes: Vec<Hash>, chunk_sizes: Vec<u64>) -> Self {
3837        let is_chunked = !chunk_hashes.is_empty();
3838        let uniform_chunk_size = uniform_chunk_size(&chunk_sizes);
3839        Self {
3840            total_size,
3841            chunk_hashes,
3842            chunk_sizes,
3843            is_chunked,
3844            uniform_chunk_size,
3845        }
3846    }
3847
3848    fn single_blob(total_size: u64) -> Self {
3849        Self {
3850            total_size,
3851            chunk_hashes: Vec::new(),
3852            chunk_sizes: Vec::new(),
3853            is_chunked: false,
3854            uniform_chunk_size: None,
3855        }
3856    }
3857
3858    fn chunk_start_for_range(&self, start: u64) -> (usize, u64) {
3859        if !self.is_chunked || self.chunk_sizes.is_empty() {
3860            return (0, 0);
3861        }
3862
3863        if let Some(chunk_size) = self.uniform_chunk_size {
3864            let index = start
3865                .checked_div(chunk_size)
3866                .unwrap_or(0)
3867                .min(self.chunk_sizes.len().saturating_sub(1) as u64)
3868                as usize;
3869            return (index, chunk_size.saturating_mul(index as u64));
3870        }
3871
3872        let mut offset = 0u64;
3873        for (index, chunk_size) in self.chunk_sizes.iter().copied().enumerate() {
3874            let next_offset = offset.saturating_add(chunk_size);
3875            if start < next_offset {
3876                return (index, offset);
3877            }
3878            offset = next_offset;
3879        }
3880
3881        (self.chunk_sizes.len(), offset)
3882    }
3883}
3884
3885fn uniform_chunk_size(chunk_sizes: &[u64]) -> Option<u64> {
3886    let (&first, rest) = chunk_sizes.split_first()?;
3887    if first == 0 {
3888        return None;
3889    }
3890    if rest.is_empty() {
3891        return Some(first);
3892    }
3893    let (last, prefix) = rest.split_last()?;
3894    if prefix.iter().any(|size| *size != first) || *last > first {
3895        return None;
3896    }
3897    Some(first)
3898}
3899
3900/// Owned iterator for async streaming
3901pub struct FileRangeChunksOwned {
3902    store: Arc<HashtreeStore>,
3903    metadata: Arc<FileChunkMetadata>,
3904    start: u64,
3905    end: u64,
3906    current_chunk_idx: usize,
3907    current_offset: u64,
3908}
3909
3910impl Iterator for FileRangeChunksOwned {
3911    type Item = Result<Vec<u8>>;
3912
3913    fn next(&mut self) -> Option<Self::Item> {
3914        if !self.metadata.is_chunked || self.current_chunk_idx >= self.metadata.chunk_hashes.len() {
3915            return None;
3916        }
3917
3918        if self.current_offset > self.end {
3919            return None;
3920        }
3921
3922        let chunk_hash = &self.metadata.chunk_hashes[self.current_chunk_idx];
3923        let chunk_size = self.metadata.chunk_sizes[self.current_chunk_idx];
3924        let chunk_end = self.current_offset + chunk_size - 1;
3925
3926        self.current_chunk_idx += 1;
3927
3928        if chunk_end < self.start || self.current_offset > self.end {
3929            self.current_offset += chunk_size;
3930            return self.next();
3931        }
3932
3933        let chunk_read_start = self.start.saturating_sub(self.current_offset);
3934
3935        let chunk_read_end = if chunk_end <= self.end {
3936            chunk_size - 1
3937        } else {
3938            self.end - self.current_offset
3939        };
3940
3941        let chunk_content =
3942            match self
3943                .store
3944                .get_blob_range(chunk_hash, chunk_read_start, chunk_read_end)
3945            {
3946                Ok(Some(content)) => content,
3947                Ok(None) => {
3948                    return Some(Err(anyhow::anyhow!(
3949                        "Chunk {} not found",
3950                        to_hex(chunk_hash)
3951                    )));
3952                }
3953                Err(e) => {
3954                    return Some(Err(e));
3955                }
3956            };
3957
3958        let expected_len = chunk_read_end.saturating_sub(chunk_read_start) + 1;
3959        if chunk_content.len() as u64 != expected_len {
3960            return Some(Err(anyhow::anyhow!(
3961                "Chunk {} range returned {} bytes, expected {}",
3962                to_hex(chunk_hash),
3963                chunk_content.len(),
3964                expected_len
3965            )));
3966        }
3967
3968        let result = chunk_content;
3969        self.current_offset += chunk_size;
3970
3971        Some(Ok(result))
3972    }
3973}
3974
3975#[derive(Debug)]
3976pub struct GcStats {
3977    pub deleted_dags: usize,
3978    pub freed_bytes: u64,
3979}
3980
3981#[derive(Debug, Clone)]
3982pub struct DirEntry {
3983    pub name: String,
3984    pub cid: String,
3985    pub is_directory: bool,
3986    pub size: u64,
3987}
3988
3989#[derive(Debug, Clone)]
3990pub struct DirectoryListing {
3991    pub dir_name: String,
3992    pub entries: Vec<DirEntry>,
3993}
3994
3995/// Blob metadata for Blossom protocol
3996#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
3997pub struct BlobMetadata {
3998    pub sha256: String,
3999    pub size: u64,
4000    pub mime_type: String,
4001    pub uploaded: u64,
4002}
4003
4004#[cfg(test)]
4005mod tests {
4006    use super::*;
4007    #[cfg(feature = "lmdb")]
4008    use hashtree_lmdb::{PoolMemberConfig, PoolStoreConfig};
4009    #[cfg(feature = "lmdb")]
4010    use tempfile::TempDir;
4011
4012    #[cfg(feature = "lmdb")]
4013    #[test]
4014    fn drop_closes_mutable_metadata_and_blob_environments() -> Result<()> {
4015        let temp = TempDir::new()?;
4016        let root = temp.path().join("store");
4017        let store = HashtreeStore::with_options(&root, None, LMDB_BLOB_MIN_MAP_SIZE_BYTES)?;
4018        let metadata_path = std::fs::canonicalize(&root)?;
4019        let pool_path = std::fs::canonicalize(root.join("blob-pool-v1"))?;
4020        let member_path = std::fs::canonicalize(root.join("blobs"))?;
4021
4022        drop(store);
4023
4024        assert!(
4025            heed::env_closing_event(metadata_path).is_none(),
4026            "mutable application metadata environment must close"
4027        );
4028        assert!(
4029            heed::env_closing_event(pool_path).is_none(),
4030            "blob pool catalog environment must close"
4031        );
4032        assert!(
4033            heed::env_closing_event(member_path).is_none(),
4034            "blob pool member environment must close"
4035        );
4036        Ok(())
4037    }
4038
4039    #[test]
4040    fn blob_access_update_gate_deduplicates_and_throttles() {
4041        let gate = BlobAccessUpdateGate::default();
4042        let first = sha256(b"first");
4043        let second = sha256(b"second");
4044
4045        assert_eq!(
4046            gate.due_hashes([first, first, second], 10),
4047            vec![first, second]
4048        );
4049        assert!(gate.due_hashes([first, second], 11).is_empty());
4050        assert_eq!(
4051            gate.due_hashes([second, first], 10 + ACCESS_UPDATE_INTERVAL_SECS),
4052            vec![second, first]
4053        );
4054    }
4055
4056    #[cfg(feature = "lmdb")]
4057    #[test]
4058    fn pool_migration_fallback_reads_old_tiers_and_writes_only_pool() -> Result<()> {
4059        let temp = TempDir::new()?;
4060        let pool = PoolStore::open(temp.path().join("pool"), PoolStoreConfig::default())?;
4061        let member_map_size = 64 * 1024 * 1024;
4062        pool.add_member(
4063            PoolMemberConfig::new(temp.path().join("member"), member_map_size)
4064                .with_map_size_bytes(member_map_size),
4065        )?;
4066        let hot_path = temp.path().join("hot");
4067        let legacy_path = temp.path().join("legacy");
4068
4069        let hot_data = b"existing hot-tier blob".to_vec();
4070        let hot_hash = sha256(&hot_data);
4071        let legacy_data = b"existing legacy-tier blob".to_vec();
4072        let legacy_hash = sha256(&legacy_data);
4073        {
4074            let hot = LmdbBlobStore::new(&hot_path)?;
4075            hot.put_sync(hot_hash, &hot_data)?;
4076            let legacy = LmdbBlobStore::new(&legacy_path)?;
4077            legacy.put_sync(legacy_hash, &legacy_data)?;
4078        }
4079        let hot_data_mdb = hot_path.join("data.mdb");
4080        let legacy_data_mdb = legacy_path.join("data.mdb");
4081        let hot_before = std::fs::metadata(&hot_data_mdb)?;
4082        let legacy_before = std::fs::metadata(&legacy_data_mdb)?;
4083        let hot = LmdbBlobReader::open(&hot_path, None)?;
4084        let legacy = LmdbBlobReader::open(&legacy_path, None)?;
4085        let store = PoolStoreWithFallbacks::new(pool.clone(), vec![hot, legacy]);
4086
4087        let new_data = b"new writes belong only in PoolStore".to_vec();
4088        let new_hash = sha256(&new_data);
4089        assert!(store.put_sync(new_hash, &new_data)?);
4090        assert_eq!(pool.get_sync(&new_hash)?, Some(new_data));
4091        assert!(
4092            store
4093                .fallbacks
4094                .iter()
4095                .all(|fallback| fallback.get_sync(&new_hash).unwrap().is_none()),
4096            "migration fallbacks must never receive new writes"
4097        );
4098
4099        let mut candidates = vec![hot_hash, new_hash, legacy_hash];
4100        candidates.sort_unstable();
4101        let canonical_before_reads = store.existing_hashes_in_sorted_candidates(&candidates)?;
4102        for (hash, exists) in candidates.iter().zip(canonical_before_reads) {
4103            assert_eq!(
4104                exists,
4105                *hash == new_hash,
4106                "legacy fallback entries must not suppress migration writes"
4107            );
4108        }
4109
4110        assert_eq!(store.get_sync(&hot_hash)?, Some(hot_data.clone()));
4111        assert_eq!(
4112            store.blob_size_sync(&legacy_hash)?,
4113            Some(legacy_data.len() as u64)
4114        );
4115        assert_eq!(
4116            pool.get_sync(&legacy_hash)?,
4117            None,
4118            "metadata-only size checks must not read or promote the body"
4119        );
4120        assert_eq!(
4121            store.get_range_sync(&legacy_hash, 9, 14)?,
4122            Some(legacy_data[9..=14].to_vec())
4123        );
4124        assert_eq!(
4125            pool.get_sync(&hot_hash)?,
4126            None,
4127            "verified fallback reads must not synchronously promote into PoolStore"
4128        );
4129        assert_eq!(
4130            pool.get_sync(&legacy_hash)?,
4131            None,
4132            "range fallback reads must not synchronously promote into PoolStore"
4133        );
4134        let canonical_after_reads = store.existing_hashes_in_sorted_candidates(&candidates)?;
4135        for (hash, exists) in candidates.iter().zip(canonical_after_reads) {
4136            assert_eq!(
4137                exists,
4138                *hash == new_hash,
4139                "fallback reads must leave exhaustive migration as the only PoolStore writer"
4140            );
4141        }
4142        let hot_after = std::fs::metadata(hot_data_mdb)?;
4143        let legacy_after = std::fs::metadata(legacy_data_mdb)?;
4144        assert_eq!(hot_after.len(), hot_before.len());
4145        assert_eq!(hot_after.modified()?, hot_before.modified()?);
4146        assert_eq!(legacy_after.len(), legacy_before.len());
4147        assert_eq!(legacy_after.modified()?, legacy_before.modified()?);
4148        Ok(())
4149    }
4150
4151    #[cfg(feature = "lmdb")]
4152    #[test]
4153    fn pinned_pool_read_fallback_serves_reads_but_primary_proof_stays_empty() -> Result<()> {
4154        let temp = TempDir::new()?;
4155        let member_map_size = 64 * 1024 * 1024;
4156
4157        let primary_path = temp.path().join("primary-pool");
4158        let primary = PoolStore::open(&primary_path, PoolStoreConfig::default())?;
4159        primary.add_member(
4160            PoolMemberConfig::new(temp.path().join("primary-member"), member_map_size)
4161                .with_map_size_bytes(member_map_size),
4162        )?;
4163
4164        let source_path = temp.path().join("source-pool");
4165        let source = PoolStore::open(&source_path, PoolStoreConfig::default())?;
4166        source.add_member(
4167            PoolMemberConfig::new(temp.path().join("source-member"), member_map_size)
4168                .with_map_size_bytes(member_map_size),
4169        )?;
4170        let source_data = b"temporary read-only Pool fallback bytes".to_vec();
4171        let source_hash = sha256(&source_data);
4172        source.put_sync(source_hash, &source_data)?;
4173        source.force_sync()?;
4174        drop(source);
4175
4176        let canonical_source = std::fs::canonicalize(&source_path)?;
4177        let source_reader = ReadOnlyPoolStore::open(&canonical_source)?;
4178        let manifest_sha256 = to_hex(&source_reader.manifest_snapshot()?.sha256);
4179        drop(source_reader);
4180        let fallback = open_pinned_pool_read_fallback(
4181            PoolReadFallbackConfig {
4182                path: canonical_source.clone(),
4183                manifest_sha256: manifest_sha256.clone(),
4184            },
4185            &primary_path,
4186        )?;
4187        let store = PoolStoreWithFallbacks::new(primary.clone(), Vec::new())
4188            .with_pool_read_fallback(Some(fallback));
4189
4190        assert_eq!(store.get_sync(&source_hash)?, Some(source_data.clone()));
4191        assert_eq!(
4192            store.blob_size_sync(&source_hash)?,
4193            Some(source_data.len() as u64)
4194        );
4195        assert!(!primary.blob_size_sync(&source_hash)?.is_some());
4196        assert_eq!(
4197            store.existing_hashes_in_sorted_candidates(&[source_hash])?,
4198            vec![false],
4199            "upload planning must remain scoped to the writable primary Pool"
4200        );
4201        assert!(store.full_deletes_blocked());
4202        assert!(store.delete_sync(&source_hash).is_err());
4203
4204        assert!(store.put_sync(source_hash, &source_data)?);
4205        assert_eq!(
4206            primary.blob_size_sync(&source_hash)?,
4207            Some(source_data.len() as u64)
4208        );
4209        assert_eq!(
4210            store.pool_read_fallback_status(),
4211            Some(PoolReadFallbackStatus {
4212                enabled: true,
4213                manifest_sha256,
4214            })
4215        );
4216        Ok(())
4217    }
4218
4219    #[cfg(feature = "lmdb")]
4220    #[test]
4221    fn pool_read_fallback_configuration_is_all_or_nothing() {
4222        let error = pool_read_fallback_config_from_values(
4223            Some(POOL_READ_FALLBACK_MODE_V1.to_string()),
4224            Some("/absolute/source".to_string()),
4225            None,
4226        )
4227        .unwrap_err();
4228        assert!(error.to_string().contains("fail-closed"));
4229
4230        assert!(pool_read_fallback_config_from_values(None, None, None)
4231            .unwrap()
4232            .is_none());
4233    }
4234
4235    #[cfg(feature = "lmdb")]
4236    #[test]
4237    fn pool_quota_uses_constant_time_physical_member_totals() -> Result<()> {
4238        let temp = TempDir::new()?;
4239        let member_path = temp.path().join("member");
4240        let member_map_size = 64 * 1024 * 1024;
4241        let pool = PoolStore::open(temp.path().join("pool"), PoolStoreConfig::default())?;
4242        pool.add_member(
4243            PoolMemberConfig::new(member_path.clone(), member_map_size)
4244                .with_map_size_bytes(member_map_size),
4245        )?;
4246
4247        let catalog_data = b"catalog-owned bytes";
4248        pool.put_sync(sha256(catalog_data), catalog_data)?;
4249        let direct_data = b"member bytes written by another process";
4250        let direct = LmdbBlobStore::with_exact_map_size_and_external_blob_options(
4251            &member_path,
4252            member_map_size as usize,
4253            None,
4254        )?;
4255        direct.put_sync(sha256(direct_data), direct_data)?;
4256
4257        assert_eq!(pool.stats()?.count, 1, "logical catalog remains separate");
4258        let expected_count = 2;
4259        let expected_bytes = (catalog_data.len() + direct_data.len()) as u64;
4260        let local = LocalStore::Pool(Box::new(PoolStoreWithFallbacks::new(pool, Vec::new())));
4261        let quota = local.writable_stats()?;
4262        assert_eq!(quota.count, expected_count);
4263        assert_eq!(quota.total_bytes, expected_bytes);
4264        Ok(())
4265    }
4266
4267    #[cfg(feature = "lmdb")]
4268    #[test]
4269    fn pool_migration_fallback_blocks_racy_full_deletes() -> Result<()> {
4270        let temp = TempDir::new()?;
4271        let pool = PoolStore::open(temp.path().join("pool"), PoolStoreConfig::default())?;
4272        let member_map_size = 64 * 1024 * 1024;
4273        pool.add_member(
4274            PoolMemberConfig::new(temp.path().join("member"), member_map_size)
4275                .with_map_size_bytes(member_map_size),
4276        )?;
4277        let fallback_path = temp.path().join("legacy");
4278        let data = b"source blob under active migration".to_vec();
4279        let hash = sha256(&data);
4280        {
4281            let fallback = LmdbBlobStore::new(&fallback_path)?;
4282            fallback.put_sync(hash, &data)?;
4283        }
4284        let fallback = LmdbBlobReader::open(fallback_path, None)?;
4285        let store = PoolStoreWithFallbacks::during_migration(pool, vec![fallback]);
4286
4287        let error = store
4288            .delete_sync(&hash)
4289            .expect_err("full delete must fail closed while source replay can race it");
4290        assert!(error.to_string().contains("temporarily disabled"));
4291        assert_eq!(store.get_sync(&hash)?, Some(data));
4292        Ok(())
4293    }
4294
4295    #[cfg(feature = "lmdb")]
4296    #[test]
4297    fn file_range_reads_reuse_metadata_and_seek_to_uniform_chunk() -> Result<()> {
4298        let temp = TempDir::new()?;
4299        let store = Arc::new(HashtreeStore::with_options_and_backend(
4300            temp.path(),
4301            None,
4302            LMDB_BLOB_MIN_MAP_SIZE_BYTES,
4303            true,
4304            &StorageBackend::Fs,
4305        )?);
4306        let tree = HashTree::new(
4307            HashTreeConfig::new(store.store_arc())
4308                .with_chunk_size(4)
4309                .public(),
4310        );
4311        let data = (0u8..20).collect::<Vec<_>>();
4312        let (cid, _) = sync_block_on(tree.put_file(&data))?;
4313
4314        let first = store.get_file_chunk_metadata(&cid.hash)?.unwrap();
4315        let second = store.get_file_chunk_metadata(&cid.hash)?.unwrap();
4316        assert!(
4317            Arc::ptr_eq(&first, &second),
4318            "hot file metadata should be returned from the in-process cache"
4319        );
4320        assert_eq!(first.uniform_chunk_size, Some(4));
4321        assert_eq!(first.chunk_start_for_range(14), (3, 12));
4322
4323        let mut chunks = Arc::clone(&store)
4324            .stream_file_range_chunks_owned(&cid.hash, 14, 17)?
4325            .unwrap();
4326        assert_eq!(chunks.current_chunk_idx, 3);
4327        assert_eq!(chunks.current_offset, 12);
4328        assert_eq!(chunks.next().unwrap()?, vec![14, 15]);
4329        assert_eq!(chunks.next().unwrap()?, vec![16, 17]);
4330        assert!(chunks.next().is_none());
4331
4332        let (range, total_size) = store.get_file_range(&cid.hash, 14, Some(17))?.unwrap();
4333        assert_eq!(total_size, data.len() as u64);
4334        assert_eq!(range, vec![14, 15, 16, 17]);
4335
4336        Ok(())
4337    }
4338
4339    #[cfg(feature = "lmdb")]
4340    #[test]
4341    fn hashtree_store_expands_blob_lmdb_map_size_to_storage_budget() -> Result<()> {
4342        let temp = TempDir::new()?;
4343        let requested = LMDB_BLOB_MIN_MAP_SIZE_BYTES + 64 * 1024 * 1024;
4344        let store = HashtreeStore::with_options_and_backend(
4345            temp.path(),
4346            None,
4347            requested,
4348            true,
4349            &StorageBackend::Lmdb,
4350        )?;
4351
4352        let map_size = match store.router.local.as_ref() {
4353            LocalStore::Lmdb(local) => local.map_size_bytes() as u64,
4354            LocalStore::Pool(pool) => {
4355                pool.largest_member_map_size_bytes()?
4356                    .expect("fresh pool should have a blob member") as u64
4357            }
4358            LocalStore::ReadOnlyPool(_) => panic!("expected writable LMDB local store"),
4359            LocalStore::Fs(_) => panic!("expected LMDB local store"),
4360        };
4361
4362        assert!(
4363            map_size >= requested,
4364            "expected blob LMDB map to grow to at least {requested} bytes, got {map_size}"
4365        );
4366
4367        drop(store);
4368        Ok(())
4369    }
4370
4371    #[cfg(feature = "lmdb")]
4372    #[test]
4373    fn hashtree_store_expands_metadata_lmdb_map_size_to_storage_budget() -> Result<()> {
4374        let temp = TempDir::new()?;
4375        let storage_budget = 256 * 1024 * 1024 * 1024u64;
4376        let expected = lmdb_metadata_map_size_for_storage_budget(storage_budget);
4377        let store = HashtreeStore::with_options_and_backend(
4378            temp.path(),
4379            None,
4380            storage_budget,
4381            true,
4382            &StorageBackend::Lmdb,
4383        )?;
4384
4385        let map_size = store.env.info().map_size as u64;
4386        assert!(
4387            map_size >= expected,
4388            "expected metadata LMDB map to grow to at least {expected} bytes, got {map_size}"
4389        );
4390
4391        drop(store);
4392        Ok(())
4393    }
4394
4395    #[cfg(feature = "lmdb")]
4396    #[test]
4397    fn embedded_store_uses_filesystem_blobs_and_no_lmdb_lock() -> Result<()> {
4398        let temp = TempDir::new()?;
4399        let store =
4400            HashtreeStore::with_embedded_options(temp.path(), None, LMDB_BLOB_MIN_MAP_SIZE_BYTES)?;
4401
4402        assert_eq!(store.router.local_store().backend(), StorageBackend::Fs);
4403        let flags = store.env.flags()?.unwrap_or(EnvFlags::empty());
4404        assert!(flags.contains(EnvFlags::NO_LOCK));
4405
4406        drop(store);
4407        Ok(())
4408    }
4409
4410    #[cfg(feature = "lmdb")]
4411    #[test]
4412    fn lmdb_map_size_for_existing_env_keeps_matching_requested_size() -> Result<()> {
4413        let temp = TempDir::new()?;
4414        let requested = LMDB_METADATA_MIN_MAP_SIZE_BYTES;
4415        std::fs::File::create(temp.path().join("data.mdb"))?.set_len(requested)?;
4416
4417        let map_size = lmdb_map_size_for_existing_env(temp.path(), requested)? as u64;
4418
4419        assert_eq!(map_size, align_lmdb_map_size(requested));
4420        Ok(())
4421    }
4422
4423    #[cfg(feature = "lmdb")]
4424    #[test]
4425    fn lmdb_map_size_for_existing_env_adds_headroom_when_existing_is_larger() -> Result<()> {
4426        let temp = TempDir::new()?;
4427        let requested = LMDB_METADATA_MIN_MAP_SIZE_BYTES;
4428        let existing = requested + 4096;
4429        std::fs::File::create(temp.path().join("data.mdb"))?.set_len(existing)?;
4430
4431        let map_size = lmdb_map_size_for_existing_env(temp.path(), requested)? as u64;
4432        let expected = align_lmdb_map_size(existing + LMDB_METADATA_REOPEN_HEADROOM_BYTES);
4433
4434        assert_eq!(map_size, expected);
4435        Ok(())
4436    }
4437
4438    #[cfg(feature = "lmdb")]
4439    #[test]
4440    fn local_store_can_override_lmdb_map_size() -> Result<()> {
4441        let temp = TempDir::new()?;
4442        let requested = 512 * 1024 * 1024u64;
4443        let store = LocalStore::new_with_lmdb_map_size(
4444            temp.path().join("lmdb-blobs"),
4445            &StorageBackend::Lmdb,
4446            Some(requested),
4447        )?;
4448
4449        let map_size = match store {
4450            LocalStore::Lmdb(local) => local.map_size_bytes() as u64,
4451            LocalStore::Pool(pool) => {
4452                pool.largest_member_map_size_bytes()?
4453                    .expect("fresh pool should have a blob member") as u64
4454            }
4455            LocalStore::ReadOnlyPool(_) => panic!("expected writable LMDB local store"),
4456            LocalStore::Fs(_) => panic!("expected LMDB local store"),
4457        };
4458
4459        assert!(
4460            map_size >= requested,
4461            "expected LMDB map to grow to at least {requested} bytes, got {map_size}"
4462        );
4463
4464        Ok(())
4465    }
4466
4467    #[cfg(feature = "lmdb")]
4468    #[test]
4469    fn local_add_reopen_options_leave_ordinary_writes_inline() -> Result<()> {
4470        let temp = TempDir::new()?;
4471        let store_path = temp.path().join("blobs");
4472        let external_path = temp.path().join(LOCAL_ADD_EXTERNAL_BLOB_DIR_NAME);
4473        let store = LmdbBlobStore::with_external_blob_options(
4474            &store_path,
4475            Some(local_add_external_blob_reopen_options(&store_path)),
4476        )?;
4477        let data = vec![7; 192 * 1024];
4478        let hash = sha256(&data);
4479
4480        assert!(store.put_sync(hash, &data)?);
4481        assert_eq!(store.get_sync(&hash)?, Some(data));
4482        assert!(!external_path.exists());
4483        Ok(())
4484    }
4485
4486    #[cfg(feature = "lmdb")]
4487    #[test]
4488    fn lmdb_local_store_removes_stale_fs_blob_shard_dirs() -> Result<()> {
4489        let temp = TempDir::new()?;
4490        let path = temp.path().join("lmdb-blobs");
4491        std::fs::create_dir_all(path.join("aa"))?;
4492        std::fs::create_dir_all(path.join("b2"))?;
4493        std::fs::create_dir_all(path.join("keep-me"))?;
4494        std::fs::write(path.join("aa").join("blob.bin"), b"old fs shard")?;
4495        std::fs::write(path.join("b2").join("blob.bin"), b"old fs shard")?;
4496        std::fs::write(path.join("keep-me").join("note.txt"), b"keep")?;
4497
4498        let _store = LocalStore::new_with_lmdb_map_size(
4499            &path,
4500            &StorageBackend::Lmdb,
4501            Some(128 * 1024 * 1024),
4502        )?;
4503
4504        assert!(!path.join("aa").exists());
4505        assert!(!path.join("b2").exists());
4506        assert!(path.join("keep-me").exists());
4507        assert!(path.join("data.mdb").exists());
4508        assert!(path.join("lock.mdb").exists());
4509
4510        Ok(())
4511    }
4512
4513    #[cfg(feature = "lmdb")]
4514    #[test]
4515    fn duplicate_blossom_writes_do_not_refresh_blob_last_accessed() -> Result<()> {
4516        let temp = TempDir::new()?;
4517        let store = HashtreeStore::with_options_and_backend(
4518            temp.path(),
4519            None,
4520            LMDB_BLOB_MIN_MAP_SIZE_BYTES,
4521            true,
4522            &StorageBackend::Lmdb,
4523        )?;
4524
4525        let raw = b"raw duplicate";
4526        let raw_hash = sha256(raw);
4527        store.put_blob(raw)?;
4528        let raw_accessed = store.blob_last_accessed_at(&raw_hash)?;
4529        store.put_blob(raw)?;
4530        assert_eq!(store.blob_last_accessed_at(&raw_hash)?, raw_accessed);
4531
4532        let data = b"cached blossom duplicate";
4533        let hash = sha256(data);
4534        store.put_cached_blob(data)?;
4535        let cached_accessed = store.blob_last_accessed_at(&hash)?;
4536        store.put_cached_blob(data)?;
4537        assert_eq!(store.blob_last_accessed_at(&hash)?, cached_accessed);
4538
4539        let cached_batch = [
4540            (
4541                sha256(b"cached blossom batch 1"),
4542                b"cached blossom batch 1".to_vec(),
4543            ),
4544            (
4545                sha256(b"cached blossom batch 2"),
4546                b"cached blossom batch 2".to_vec(),
4547            ),
4548        ];
4549        assert_eq!(store.put_cached_blobs(&cached_batch)?, 2);
4550        assert_eq!(store.put_cached_blobs(&cached_batch)?, 0);
4551        assert_eq!(
4552            store.get_blob(&cached_batch[0].0)?.as_deref(),
4553            Some(cached_batch[0].1.as_slice())
4554        );
4555
4556        let owned = b"owned blossom duplicate";
4557        let owned_hash = sha256(owned);
4558        let owner = [7u8; 32];
4559        store.put_owned_blob(owned, &owner)?;
4560        let owned_accessed = store.blob_last_accessed_at(&owned_hash)?;
4561        store.put_owned_blob(owned, &owner)?;
4562        assert_eq!(store.blob_last_accessed_at(&owned_hash)?, owned_accessed);
4563        let owned_blobs = store.list_blobs_by_pubkey(&owner)?;
4564        assert_eq!(owned_blobs.len(), 1);
4565        assert_eq!(owned_blobs[0].sha256, to_hex(&owned_hash));
4566
4567        let other_owner = [8u8; 32];
4568        store.put_owned_blob(owned, &other_owner)?;
4569        assert_eq!(store.blob_last_accessed_at(&owned_hash)?, owned_accessed);
4570        let other_owned_blobs = store.list_blobs_by_pubkey(&other_owner)?;
4571        assert_eq!(other_owned_blobs.len(), 1);
4572        assert_eq!(other_owned_blobs[0].sha256, to_hex(&owned_hash));
4573
4574        let batch = [
4575            (
4576                sha256(b"owned blossom batch 1"),
4577                b"owned blossom batch 1".to_vec(),
4578            ),
4579            (
4580                sha256(b"owned blossom batch 2"),
4581                b"owned blossom batch 2".to_vec(),
4582            ),
4583        ];
4584        store.put_owned_blobs(&batch, &owner)?;
4585        assert_eq!(store.put_owned_blobs(&batch, &owner)?, 0);
4586        let owned_blobs = store.list_blobs_by_pubkey(&owner)?;
4587        assert_eq!(owned_blobs.len(), 3);
4588
4589        Ok(())
4590    }
4591
4592    #[cfg(feature = "lmdb")]
4593    #[test]
4594    fn owned_blob_body_survives_concurrent_orphan_cleanup_until_owner_commit() -> Result<()> {
4595        let temp = TempDir::new()?;
4596        drop(LocalStore::new_unbounded_with_lmdb_map_size(
4597            temp.path().join("blobs"),
4598            &StorageBackend::Lmdb,
4599            Some(LMDB_BLOB_MIN_MAP_SIZE_BYTES),
4600        )?);
4601        let store = Arc::new(HashtreeStore::with_options_and_backend(
4602            temp.path(),
4603            None,
4604            LMDB_BLOB_MIN_MAP_SIZE_BYTES,
4605            true,
4606            &StorageBackend::Lmdb,
4607        )?);
4608        let data = vec![0x5a; 64 * 1024];
4609        let hash = sha256(&data);
4610        let owner = [0x42; 32];
4611        let (body_ready_tx, body_ready_rx) = std::sync::mpsc::channel();
4612        let (allow_owner_tx, allow_owner_rx) = std::sync::mpsc::channel();
4613
4614        let writer_store = Arc::clone(&store);
4615        let writer = std::thread::spawn(move || {
4616            writer_store.put_owned_blob_with_inserted_after_body(&data, &owner, || {
4617                body_ready_tx.send(()).expect("signal body write");
4618                allow_owner_rx
4619                    .recv_timeout(std::time::Duration::from_secs(10))
4620                    .expect("owner commit release");
4621            })
4622        });
4623
4624        body_ready_rx.recv_timeout(std::time::Duration::from_secs(10))?;
4625        assert!(store.blob_exists(&hash)?, "body write must be visible");
4626        assert!(
4627            !store.blob_has_owners(&hash)?,
4628            "test must pause before owner metadata commits"
4629        );
4630
4631        assert_eq!(
4632            store.relieve_cached_blob_write_pressure(64 * 1024)?,
4633            0,
4634            "orphan cleanup must skip a body awaiting durable metadata"
4635        );
4636        assert!(
4637            store.blob_exists(&hash)?,
4638            "concurrent cleanup deleted an in-flight owned body"
4639        );
4640
4641        allow_owner_tx.send(())?;
4642        let (_, inserted) = writer.join().expect("owned writer panicked")?;
4643        assert!(inserted);
4644        assert!(store.blob_exists(&hash)?);
4645        assert!(store.is_blob_owner(&hash, &owner)?);
4646        Ok(())
4647    }
4648
4649    #[cfg(feature = "lmdb")]
4650    #[test]
4651    fn owned_blob_batch_survives_concurrent_orphan_cleanup_until_owner_commit() -> Result<()> {
4652        let temp = TempDir::new()?;
4653        drop(LocalStore::new_unbounded_with_lmdb_map_size(
4654            temp.path().join("blobs"),
4655            &StorageBackend::Lmdb,
4656            Some(LMDB_BLOB_MIN_MAP_SIZE_BYTES),
4657        )?);
4658        let store = Arc::new(HashtreeStore::with_options_and_backend(
4659            temp.path(),
4660            None,
4661            LMDB_BLOB_MIN_MAP_SIZE_BYTES,
4662            true,
4663            &StorageBackend::Lmdb,
4664        )?);
4665        let first = vec![0x61; 32 * 1024];
4666        let second = vec![0x62; 48 * 1024];
4667        let first_hash = sha256(&first);
4668        let second_hash = sha256(&second);
4669        let owner = [0x24; 32];
4670        let items = vec![(first_hash, first), (second_hash, second)];
4671        let (bodies_ready_tx, bodies_ready_rx) = std::sync::mpsc::channel();
4672        let (allow_owner_tx, allow_owner_rx) = std::sync::mpsc::channel();
4673
4674        let writer_store = Arc::clone(&store);
4675        let writer = std::thread::spawn(move || {
4676            writer_store.put_owned_blobs_report_after_bodies(&items, &owner, || {
4677                bodies_ready_tx.send(()).expect("signal batch body write");
4678                allow_owner_rx
4679                    .recv_timeout(std::time::Duration::from_secs(10))
4680                    .expect("batch owner commit release");
4681            })
4682        });
4683
4684        bodies_ready_rx.recv_timeout(std::time::Duration::from_secs(10))?;
4685        for hash in [first_hash, second_hash] {
4686            assert!(store.blob_exists(&hash)?, "batch body must be visible");
4687            assert!(
4688                !store.blob_has_owners(&hash)?,
4689                "test must pause before batch owner metadata commits"
4690            );
4691        }
4692
4693        assert_eq!(
4694            store.relieve_cached_blob_write_pressure(80 * 1024)?,
4695            0,
4696            "orphan cleanup must skip all bodies awaiting batch metadata"
4697        );
4698        assert!(store.blob_exists(&first_hash)?);
4699        assert!(store.blob_exists(&second_hash)?);
4700
4701        allow_owner_tx.send(())?;
4702        let report = writer.join().expect("owned batch writer panicked")?;
4703        assert_eq!(report.inserted, 2);
4704        for hash in [first_hash, second_hash] {
4705            assert!(store.blob_exists(&hash)?);
4706            assert!(store.is_blob_owner(&hash, &owner)?);
4707        }
4708        Ok(())
4709    }
4710
4711    #[cfg(feature = "lmdb")]
4712    #[test]
4713    fn duplicate_heavy_cached_batch_uses_actual_inserted_bytes_for_quota() -> Result<()> {
4714        let temp = TempDir::new()?;
4715        let store = HashtreeStore::with_options_and_backend(
4716            temp.path(),
4717            None,
4718            35,
4719            true,
4720            &StorageBackend::Lmdb,
4721        )?;
4722
4723        let first = [1u8; 10];
4724        let second = [2u8; 10];
4725        let third = [3u8; 10];
4726        let new = [4u8; 5];
4727        let first_hash = sha256(&first);
4728        let second_hash = sha256(&second);
4729        let third_hash = sha256(&third);
4730        let new_hash = sha256(&new);
4731
4732        store.put_cached_blob(&first)?;
4733        store.put_cached_blob(&second)?;
4734        store.put_cached_blob(&third)?;
4735        assert_eq!(store.router.writable_stats()?.total_bytes, 30);
4736
4737        let inserted = store.put_cached_blobs(&[
4738            (first_hash, first.to_vec()),
4739            (second_hash, second.to_vec()),
4740            (new_hash, new.to_vec()),
4741        ])?;
4742
4743        assert_eq!(inserted, 1);
4744        assert_eq!(store.router.writable_stats()?.total_bytes, 35);
4745        assert!(store.blob_exists(&first_hash)?);
4746        assert!(store.blob_exists(&second_hash)?);
4747        assert!(store.blob_exists(&third_hash)?);
4748        assert!(store.blob_exists(&new_hash)?);
4749
4750        Ok(())
4751    }
4752
4753    #[cfg(feature = "lmdb")]
4754    #[test]
4755    fn replacing_tree_ref_unpins_and_unindexes_superseded_root() -> Result<()> {
4756        let temp = TempDir::new()?;
4757        let store = HashtreeStore::with_options_and_backend(
4758            temp.path(),
4759            None,
4760            LMDB_BLOB_MIN_MAP_SIZE_BYTES,
4761            true,
4762            &StorageBackend::Lmdb,
4763        )?;
4764
4765        let old_bytes = b"old published root";
4766        let new_bytes = b"new published root";
4767        let old_root = sha256(old_bytes);
4768        let new_root = sha256(new_bytes);
4769
4770        store.put_blob(old_bytes)?;
4771        store.pin(&old_root)?;
4772        store.index_tree(
4773            &old_root,
4774            "owner",
4775            Some("playlist"),
4776            PRIORITY_OWN,
4777            Some("npub1owner/playlist"),
4778        )?;
4779
4780        assert!(store.is_pinned(&old_root)?);
4781        assert!(store.get_tree_meta(&old_root)?.is_some());
4782
4783        store.put_blob(new_bytes)?;
4784        store.pin(&new_root)?;
4785        store.index_tree(
4786            &new_root,
4787            "owner",
4788            Some("playlist"),
4789            PRIORITY_OWN,
4790            Some("npub1owner/playlist"),
4791        )?;
4792
4793        assert!(
4794            !store.is_pinned(&old_root)?,
4795            "superseded root should be unpinned when ref is replaced"
4796        );
4797        assert!(
4798            store.get_tree_meta(&old_root)?.is_none(),
4799            "superseded root metadata should be removed when ref is replaced"
4800        );
4801        assert!(store.is_pinned(&new_root)?);
4802        assert!(store.get_tree_meta(&new_root)?.is_some());
4803
4804        Ok(())
4805    }
4806
4807    #[test]
4808    fn tracked_authors_round_trip_sorted_and_deduplicated() -> Result<()> {
4809        let temp = TempDir::new()?;
4810        let store = HashtreeStore::with_options(temp.path(), None, 1024 * 1024)?;
4811
4812        store
4813            .add_tracked_author("npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk")?;
4814        store
4815            .add_tracked_author("npub1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaqf5slm")?;
4816        store
4817            .add_tracked_author("npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk")?;
4818
4819        assert_eq!(
4820            store.list_tracked_authors()?,
4821            vec![
4822                "npub1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaqf5slm".to_string(),
4823                "npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk".to_string(),
4824            ]
4825        );
4826        assert!(store.remove_tracked_author(
4827            "npub1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaqf5slm"
4828        )?);
4829        assert!(!store.remove_tracked_author(
4830            "npub1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbpqqqqq"
4831        )?);
4832        assert_eq!(
4833            store.list_tracked_authors()?,
4834            vec!["npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk".to_string()]
4835        );
4836
4837        Ok(())
4838    }
4839
4840    #[cfg(feature = "s3")]
4841    #[test]
4842    fn async_store_s3_fallback_does_not_reenter_futures_executor() -> Result<()> {
4843        let temp = tempfile::TempDir::new()?;
4844        let local = Arc::new(LocalStore::new(
4845            temp.path().join("blobs"),
4846            &StorageBackend::Fs,
4847        )?);
4848
4849        let outcome = std::panic::catch_unwind(|| {
4850            sync_block_on(async {
4851                let aws_config = aws_config::from_env()
4852                    .region(aws_sdk_s3::config::Region::new("auto"))
4853                    .load()
4854                    .await;
4855                let s3_client = aws_sdk_s3::Client::from_conf(
4856                    aws_sdk_s3::config::Builder::from(&aws_config)
4857                        .endpoint_url("http://127.0.0.1:9")
4858                        .force_path_style(true)
4859                        .build(),
4860                );
4861
4862                let router = StorageRouter {
4863                    local,
4864                    s3_client: Some(s3_client),
4865                    s3_bucket: Some("test-bucket".to_string()),
4866                    s3_prefix: String::new(),
4867                    sync_tx: None,
4868                };
4869                let hash = [0u8; 32];
4870
4871                let _ = Store::has(&router, &hash).await;
4872                let _ = Store::get(&router, &hash).await;
4873            });
4874        });
4875
4876        assert!(
4877            outcome.is_ok(),
4878            "S3-backed async store methods should not panic inside futures::block_on"
4879        );
4880
4881        Ok(())
4882    }
4883}