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