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