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