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