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