1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use futures::executor::block_on as sync_block_on;
4use futures::StreamExt;
5use hashtree_config::StorageBackend;
6use hashtree_core::store::{slice_blob_range, PutManyReport, Store, StoreError};
7use hashtree_core::{
8 from_hex, sha256, to_hex, types::Hash, Cid, HashTree, HashTreeConfig, TreeNode,
9};
10use hashtree_fs::FsBlobStore;
11#[cfg(feature = "lmdb")]
12use hashtree_lmdb::{ExternalBlobOptions, LmdbBlobStore};
13use heed::types::*;
14use heed::{Database, EnvFlags, EnvOpenOptions, Error as HeedError, MdbError, PutFlags};
15use lru::LruCache;
16use serde::{Deserialize, Serialize};
17use std::collections::{HashMap, HashSet};
18#[cfg(feature = "s3")]
19use std::future::Future;
20use std::io::Write;
21use std::num::NonZeroUsize;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::{Arc, Mutex};
25use std::time::{Instant, SystemTime, UNIX_EPOCH};
26
27mod upload;
28pub use upload::{AddProgress, AddProgressSnapshot};
29
30mod maintenance;
31mod retention;
32
33#[cfg(feature = "s3")]
34const DEFAULT_S3_SYNC_TIMEOUT_MS: u64 = 5_000;
35#[cfg(feature = "s3")]
36const S3_SYNC_TIMEOUT_MS_ENV: &str = "HTREE_S3_SYNC_TIMEOUT_MS";
37
38pub use maintenance::{
39 compact_lmdb_environments_under, CompactResult, R2ImportOptions, R2ImportResult, VerifyResult,
40};
41pub use retention::{OwnedBlobStats, PinnedItem, StorageByPriority, StorageStats, TreeMeta};
42
43pub const PRIORITY_OTHER: u8 = 64;
45pub const PRIORITY_FOLLOWED: u8 = 128;
46pub const PRIORITY_OWN: u8 = 255;
47const LMDB_MAX_READERS: u32 = 1024;
48const LMDB_METADATA_MIN_MAP_SIZE_BYTES: u64 = 64 * 1024 * 1024;
49const LMDB_METADATA_MAX_MAP_SIZE_BYTES: u64 = 64 * 1024 * 1024 * 1024;
50const LMDB_METADATA_STORAGE_RATIO_DIVISOR: u64 = 1024;
51const LMDB_METADATA_REOPEN_HEADROOM_BYTES: u64 = 64 * 1024 * 1024;
52#[cfg(feature = "lmdb")]
53const LMDB_BLOB_MIN_MAP_SIZE_BYTES: u64 = 16 * 1024 * 1024;
54const ACCESS_UPDATE_INTERVAL_SECS: u64 = 300;
55const ACCESS_UPDATE_GATE_MAX_ENTRIES: usize = 4096;
56const DEFAULT_ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT: usize = 64;
57const ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT_ENV: &str = "HTREE_ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT";
58const DEFAULT_FILE_METADATA_CACHE_ENTRIES: usize = 128;
59const FILE_METADATA_CACHE_ENTRIES_ENV: &str = "HTREE_FILE_METADATA_CACHE_ENTRIES";
60const SLOW_OWNED_BLOB_BATCH_LOG_MS_ENV: &str = "HTREE_SLOW_OWNED_BLOB_BATCH_LOG_MS";
61const SLOW_CACHED_BLOB_BATCH_LOG_MS_ENV: &str = "HTREE_SLOW_CACHED_BLOB_BATCH_LOG_MS";
62#[cfg(feature = "lmdb")]
63const LMDB_HOT_BLOB_DIR_ENV: &str = "HTREE_LMDB_HOT_BLOB_DIR";
64#[cfg(feature = "lmdb")]
65const LMDB_HOT_BLOB_LEGACY_DIR_ENV: &str = "HTREE_LMDB_HOT_BLOB_LEGACY_DIR";
66#[cfg(feature = "lmdb")]
67const LMDB_HOT_EXTERNAL_BLOB_DIR_ENV: &str = "HTREE_LMDB_HOT_EXTERNAL_BLOB_DIR";
68#[cfg(feature = "lmdb")]
69const LMDB_LEGACY_EXTERNAL_BLOB_DIR_ENV: &str = "HTREE_LMDB_LEGACY_EXTERNAL_BLOB_DIR";
70pub const LOCAL_ADD_EXTERNAL_BLOB_DIR_NAME: &str = "blob-files-v1";
71const LMDB_NO_READ_AHEAD_ENV: &str = "HTREE_LMDB_NO_READ_AHEAD";
72const LMDB_NO_SYNC_ENV: &str = "HTREE_LMDB_NO_SYNC";
73const LMDB_NO_META_SYNC_ENV: &str = "HTREE_LMDB_NO_META_SYNC";
74#[cfg(all(test, feature = "lmdb"))]
75const LMDB_EXTERNAL_BLOB_MIN_BYTES_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_MIN_BYTES";
76#[cfg(all(test, feature = "lmdb"))]
77const LMDB_EXTERNAL_BLOB_DIR_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_DIR";
78#[cfg(all(test, feature = "lmdb"))]
79const LMDB_EXTERNAL_BLOB_SYNC_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_SYNC";
80#[cfg(all(test, feature = "lmdb"))]
81const LMDB_EXTERNAL_BLOB_PACK_TARGET_BYTES_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_PACK_TARGET_BYTES";
82
83fn slow_owned_blob_batch_log_ms() -> Option<u128> {
84 std::env::var(SLOW_OWNED_BLOB_BATCH_LOG_MS_ENV)
85 .ok()
86 .and_then(|value| value.parse::<u128>().ok())
87 .filter(|value| *value > 0)
88}
89
90fn slow_cached_blob_batch_log_ms() -> Option<u128> {
91 std::env::var(SLOW_CACHED_BLOB_BATCH_LOG_MS_ENV)
92 .ok()
93 .and_then(|value| value.parse::<u128>().ok())
94 .filter(|value| *value > 0)
95}
96
97fn access_update_background_batch_limit() -> usize {
98 std::env::var(ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT_ENV)
99 .ok()
100 .and_then(|value| value.parse::<usize>().ok())
101 .unwrap_or(DEFAULT_ACCESS_UPDATE_BACKGROUND_BATCH_LIMIT)
102}
103
104fn file_metadata_cache_entries() -> NonZeroUsize {
105 let entries = std::env::var(FILE_METADATA_CACHE_ENTRIES_ENV)
106 .ok()
107 .and_then(|value| value.parse::<usize>().ok())
108 .filter(|value| *value > 0)
109 .unwrap_or(DEFAULT_FILE_METADATA_CACHE_ENTRIES);
110 NonZeroUsize::new(entries).unwrap_or(NonZeroUsize::new(1).expect("nonzero cache size"))
111}
112
113fn env_bool(name: &str) -> Option<bool> {
114 std::env::var(name).ok().and_then(|value| {
115 let value = value.trim();
116 if value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") {
117 Some(true)
118 } else if value == "0"
119 || value.eq_ignore_ascii_case("false")
120 || value.eq_ignore_ascii_case("no")
121 {
122 Some(false)
123 } else {
124 None
125 }
126 })
127}
128
129fn lmdb_env_flags_from_env() -> EnvFlags {
130 let mut flags = EnvFlags::empty();
131 if env_bool(LMDB_NO_READ_AHEAD_ENV).unwrap_or(false) {
132 flags |= EnvFlags::NO_READ_AHEAD;
133 }
134 if env_bool(LMDB_NO_SYNC_ENV).unwrap_or(false) {
135 flags |= EnvFlags::NO_SYNC;
136 }
137 if env_bool(LMDB_NO_META_SYNC_ENV).unwrap_or(false) {
138 flags |= EnvFlags::NO_META_SYNC;
139 }
140 flags
141}
142
143fn unix_timestamp_now() -> u64 {
144 SystemTime::now()
145 .duration_since(UNIX_EPOCH)
146 .unwrap_or_default()
147 .as_secs()
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct CachedRoot {
153 pub hash: String,
155 pub key: Option<String>,
157 pub updated_at: u64,
159 pub visibility: String,
161}
162
163#[derive(Debug, Clone)]
165pub struct LocalStoreStats {
166 pub count: usize,
167 pub total_bytes: u64,
168}
169
170#[derive(Default)]
171struct BlobAccessUpdateGate {
172 next_update_by_hash: Mutex<HashMap<Hash, u64>>,
173}
174
175impl BlobAccessUpdateGate {
176 fn due_hashes<I>(&self, hashes: I, now: u64) -> Vec<Hash>
177 where
178 I: IntoIterator<Item = Hash>,
179 {
180 let Ok(mut next_update_by_hash) = self.next_update_by_hash.try_lock() else {
181 return Vec::new();
182 };
183
184 if next_update_by_hash.len() >= ACCESS_UPDATE_GATE_MAX_ENTRIES {
185 next_update_by_hash.retain(|_, next_update| *next_update > now);
186 if next_update_by_hash.len() >= ACCESS_UPDATE_GATE_MAX_ENTRIES {
187 next_update_by_hash.clear();
188 }
189 }
190
191 let mut due = Vec::new();
192 let mut seen = HashSet::new();
193 for hash in hashes {
194 if !seen.insert(hash) {
195 continue;
196 }
197 if next_update_by_hash
198 .get(&hash)
199 .is_some_and(|next_update| now < *next_update)
200 {
201 continue;
202 }
203 next_update_by_hash.insert(hash, now.saturating_add(ACCESS_UPDATE_INTERVAL_SECS));
204 due.push(hash);
205 }
206 due
207 }
208}
209
210pub enum LocalStore {
212 Fs(FsBlobStore),
213 #[cfg(feature = "lmdb")]
214 Lmdb(LmdbBlobStore),
215 #[cfg(feature = "lmdb")]
216 TieredLmdb {
217 primary: Box<LmdbBlobStore>,
218 legacy: Box<LmdbBlobStore>,
219 },
220}
221
222#[cfg(feature = "lmdb")]
223fn is_fs_blob_shard_dir(path: &Path) -> bool {
224 path.file_name()
225 .and_then(|name| name.to_str())
226 .map(|name| name.len() == 2 && name.as_bytes().iter().all(u8::is_ascii_hexdigit))
227 .unwrap_or(false)
228}
229
230fn lmdb_metadata_map_size_for_storage_budget(max_size_bytes: u64) -> u64 {
231 if max_size_bytes == 0 {
232 return LMDB_METADATA_MAX_MAP_SIZE_BYTES;
233 }
234
235 max_size_bytes
236 .saturating_div(LMDB_METADATA_STORAGE_RATIO_DIVISOR)
237 .clamp(
238 LMDB_METADATA_MIN_MAP_SIZE_BYTES,
239 LMDB_METADATA_MAX_MAP_SIZE_BYTES,
240 )
241}
242
243fn lmdb_map_size_for_existing_env(path: &Path, requested_bytes: u64) -> Result<usize> {
244 let existing_bytes = std::fs::metadata(path.join("data.mdb"))
245 .map(|metadata| metadata.len())
246 .unwrap_or(0);
247 let requested = if existing_bytes > requested_bytes {
248 let existing_headroom = existing_bytes
249 .saturating_div(10)
250 .max(LMDB_METADATA_REOPEN_HEADROOM_BYTES);
251 existing_bytes.saturating_add(existing_headroom)
252 } else {
253 requested_bytes
254 };
255 let requested = align_lmdb_map_size(requested);
256 usize::try_from(requested).context("LMDB map size exceeds usize")
257}
258
259fn align_lmdb_map_size(bytes: u64) -> u64 {
260 let page_size = (page_size::get() as u64).max(4096);
261 let remainder = bytes % page_size;
262 if remainder == 0 {
263 bytes
264 } else {
265 bytes.saturating_add(page_size - remainder)
266 }
267}
268
269#[cfg(feature = "lmdb")]
270fn remove_stale_fs_blob_shards(path: &Path) -> Result<(), StoreError> {
271 let entries = std::fs::read_dir(path).map_err(StoreError::Io)?;
272 for entry in entries {
273 let entry = entry.map_err(StoreError::Io)?;
274 let entry_path = entry.path();
275 if entry_path.is_dir() && is_fs_blob_shard_dir(&entry_path) {
276 std::fs::remove_dir_all(&entry_path).map_err(StoreError::Io)?;
277 tracing::info!(
278 "Removed stale filesystem blob shard directory after LMDB cutover: {}",
279 entry_path.display()
280 );
281 }
282 }
283 Ok(())
284}
285
286#[cfg(feature = "lmdb")]
287fn lmdb_hot_blob_dir_for(legacy_path: &Path) -> Option<PathBuf> {
288 if let Ok(expected_legacy) = std::env::var(LMDB_HOT_BLOB_LEGACY_DIR_ENV) {
289 let expected_legacy = expected_legacy.trim();
290 if !expected_legacy.is_empty()
291 && !paths_refer_to_same_location(legacy_path, Path::new(expected_legacy))
292 {
293 return None;
294 }
295 }
296
297 std::env::var(LMDB_HOT_BLOB_DIR_ENV)
298 .ok()
299 .map(|value| value.trim().to_string())
300 .filter(|value| !value.is_empty())
301 .map(PathBuf::from)
302}
303
304#[cfg(feature = "lmdb")]
305fn paths_refer_to_same_location(left: &Path, right: &Path) -> bool {
306 if left == right {
307 return true;
308 }
309
310 match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
311 (Ok(left), Ok(right)) => left == right,
312 _ => false,
313 }
314}
315
316#[cfg(feature = "lmdb")]
317fn local_add_external_blob_reopen_options(store_path: &Path) -> ExternalBlobOptions {
318 ExternalBlobOptions {
319 base_path: store_path.with_file_name(LOCAL_ADD_EXTERNAL_BLOB_DIR_NAME),
320 min_bytes: usize::MAX,
321 sync: true,
322 pack_target_bytes: None,
323 }
324}
325
326#[cfg(feature = "lmdb")]
327fn external_blob_options_for(
328 store_path: &Path,
329 override_dir_env: Option<&str>,
330) -> ExternalBlobOptions {
331 let options = ExternalBlobOptions::from_env(store_path).unwrap_or_else(|| {
332 local_add_external_blob_reopen_options(store_path)
337 });
338 let override_path = override_dir_env
339 .and_then(|name| std::env::var(name).ok())
340 .map(|value| value.trim().to_string())
341 .filter(|value| !value.is_empty())
342 .map(PathBuf::from);
343 match override_path {
344 Some(path) => options.with_base_path(path),
345 None => options,
346 }
347}
348
349#[cfg(feature = "lmdb")]
350fn open_lmdb_blob_store<P: AsRef<Path>>(
351 path: P,
352 map_size_bytes: Option<u64>,
353) -> Result<LmdbBlobStore, StoreError> {
354 open_lmdb_blob_store_with_external_dir_env(path, map_size_bytes, None)
355}
356
357#[cfg(feature = "lmdb")]
358fn open_lmdb_blob_store_with_external_dir_env<P: AsRef<Path>>(
359 path: P,
360 map_size_bytes: Option<u64>,
361 external_dir_env: Option<&str>,
362) -> Result<LmdbBlobStore, StoreError> {
363 std::fs::create_dir_all(path.as_ref()).map_err(StoreError::Io)?;
364 remove_stale_fs_blob_shards(path.as_ref())?;
365 let external_blobs = Some(external_blob_options_for(path.as_ref(), external_dir_env));
366 match map_size_bytes {
367 Some(map_size_bytes) => {
368 LmdbBlobStore::with_max_bytes_and_external_blob_options(path, map_size_bytes, |_| {
369 external_blobs
370 })
371 }
372 None => LmdbBlobStore::with_external_blob_options(path, external_blobs),
373 }
374}
375
376#[cfg(feature = "lmdb")]
377fn open_unbounded_lmdb_blob_store<P: AsRef<Path>>(
378 path: P,
379 map_size_bytes: Option<u64>,
380) -> Result<LmdbBlobStore, StoreError> {
381 open_unbounded_lmdb_blob_store_with_external_dir_env(path, map_size_bytes, None)
382}
383
384#[cfg(feature = "lmdb")]
385fn open_unbounded_lmdb_blob_store_with_external_dir_env<P: AsRef<Path>>(
386 path: P,
387 map_size_bytes: Option<u64>,
388 external_dir_env: Option<&str>,
389) -> Result<LmdbBlobStore, StoreError> {
390 std::fs::create_dir_all(path.as_ref()).map_err(StoreError::Io)?;
391 remove_stale_fs_blob_shards(path.as_ref())?;
392 let external_blobs = Some(external_blob_options_for(path.as_ref(), external_dir_env));
393 match map_size_bytes {
394 Some(map_size_bytes) => {
395 let map_size = usize::try_from(align_lmdb_map_size(map_size_bytes))
396 .map_err(|_| StoreError::Other("LMDB map size exceeds usize".to_string()))?;
397 LmdbBlobStore::with_map_size_and_external_blob_options(path, map_size, external_blobs)
398 }
399 None => LmdbBlobStore::with_external_blob_options(path, external_blobs),
400 }
401}
402
403impl LocalStore {
404 pub fn new<P: AsRef<Path>>(path: P, backend: &StorageBackend) -> Result<Self, StoreError> {
410 Self::new_unbounded(path, backend)
411 }
412
413 pub fn new_with_lmdb_map_size<P: AsRef<Path>>(
418 path: P,
419 backend: &StorageBackend,
420 _map_size_bytes: Option<u64>,
421 ) -> Result<Self, StoreError> {
422 match backend {
423 StorageBackend::Fs => Ok(LocalStore::Fs(FsBlobStore::new(path)?)),
424 #[cfg(feature = "lmdb")]
425 StorageBackend::Lmdb => {
426 if let Some(hot_path) = lmdb_hot_blob_dir_for(path.as_ref()) {
427 let legacy_path = path.as_ref().to_path_buf();
428 if hot_path != legacy_path {
429 let primary = open_lmdb_blob_store_with_external_dir_env(
430 &hot_path,
431 _map_size_bytes,
432 Some(LMDB_HOT_EXTERNAL_BLOB_DIR_ENV),
433 )?;
434 let legacy = open_lmdb_blob_store_with_external_dir_env(
435 &legacy_path,
436 _map_size_bytes,
437 Some(LMDB_LEGACY_EXTERNAL_BLOB_DIR_ENV),
438 )?;
439 tracing::info!(
440 "Using tiered LMDB blob storage: primary={}, legacy={}",
441 hot_path.display(),
442 legacy_path.display()
443 );
444 return Ok(LocalStore::TieredLmdb {
445 primary: Box::new(primary),
446 legacy: Box::new(legacy),
447 });
448 }
449 }
450 Ok(LocalStore::Lmdb(open_lmdb_blob_store(
451 path,
452 _map_size_bytes,
453 )?))
454 }
455 #[cfg(not(feature = "lmdb"))]
456 StorageBackend::Lmdb => {
457 tracing::warn!(
458 "LMDB backend requested but lmdb feature not enabled, using filesystem storage"
459 );
460 Ok(LocalStore::Fs(FsBlobStore::new(path)?))
461 }
462 }
463 }
464
465 pub fn new_unbounded<P: AsRef<Path>>(
467 path: P,
468 backend: &StorageBackend,
469 ) -> Result<Self, StoreError> {
470 Self::new_with_lmdb_map_size(path, backend, None)
471 }
472
473 pub fn new_unbounded_with_lmdb_map_size<P: AsRef<Path>>(
480 path: P,
481 backend: &StorageBackend,
482 _map_size_bytes: Option<u64>,
483 ) -> Result<Self, StoreError> {
484 match backend {
485 StorageBackend::Fs => Ok(LocalStore::Fs(FsBlobStore::new(path)?)),
486 #[cfg(feature = "lmdb")]
487 StorageBackend::Lmdb => {
488 if let Some(hot_path) = lmdb_hot_blob_dir_for(path.as_ref()) {
489 let legacy_path = path.as_ref().to_path_buf();
490 if hot_path != legacy_path {
491 let primary = open_unbounded_lmdb_blob_store_with_external_dir_env(
492 &hot_path,
493 _map_size_bytes,
494 Some(LMDB_HOT_EXTERNAL_BLOB_DIR_ENV),
495 )?;
496 let legacy = open_unbounded_lmdb_blob_store_with_external_dir_env(
497 &legacy_path,
498 _map_size_bytes,
499 Some(LMDB_LEGACY_EXTERNAL_BLOB_DIR_ENV),
500 )?;
501 tracing::info!(
502 "Using tiered LMDB blob storage: primary={}, legacy={}",
503 hot_path.display(),
504 legacy_path.display()
505 );
506 return Ok(LocalStore::TieredLmdb {
507 primary: Box::new(primary),
508 legacy: Box::new(legacy),
509 });
510 }
511 }
512 Ok(LocalStore::Lmdb(open_unbounded_lmdb_blob_store(
513 path,
514 _map_size_bytes,
515 )?))
516 }
517 #[cfg(not(feature = "lmdb"))]
518 StorageBackend::Lmdb => {
519 tracing::warn!(
520 "LMDB backend requested but lmdb feature not enabled, using filesystem storage"
521 );
522 Ok(LocalStore::Fs(FsBlobStore::new(path)?))
523 }
524 }
525 }
526
527 pub fn backend(&self) -> StorageBackend {
528 match self {
529 LocalStore::Fs(_) => StorageBackend::Fs,
530 #[cfg(feature = "lmdb")]
531 LocalStore::Lmdb(_) | LocalStore::TieredLmdb { .. } => StorageBackend::Lmdb,
532 }
533 }
534
535 pub fn force_sync(&self) -> Result<(), StoreError> {
536 match self {
537 LocalStore::Fs(_) => Ok(()),
538 #[cfg(feature = "lmdb")]
539 LocalStore::Lmdb(store) => store.force_sync(),
540 #[cfg(feature = "lmdb")]
541 LocalStore::TieredLmdb { primary, legacy } => {
542 primary.force_sync()?;
543 legacy.force_sync()
544 }
545 }
546 }
547
548 pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
550 match self {
551 LocalStore::Fs(store) => store.put_sync(hash, data),
552 #[cfg(feature = "lmdb")]
553 LocalStore::Lmdb(store) => store.put_sync(hash, data),
554 #[cfg(feature = "lmdb")]
555 LocalStore::TieredLmdb { primary, .. } => primary.put_sync(hash, data),
556 }
557 }
558
559 pub fn put_many_report_sync(
561 &self,
562 items: &[(Hash, Vec<u8>)],
563 ) -> Result<PutManyReport, StoreError> {
564 match self {
565 LocalStore::Fs(store) => {
566 let mut report = PutManyReport {
567 total: items.len(),
568 ..PutManyReport::default()
569 };
570 for (hash, data) in items {
571 if store.put_sync(*hash, data.as_slice())? {
572 report.inserted = report.inserted.saturating_add(1);
573 report.inserted_bytes =
574 report.inserted_bytes.saturating_add(data.len() as u64);
575 report.inserted_hashes.push(*hash);
576 }
577 }
578 Ok(report)
579 }
580 #[cfg(feature = "lmdb")]
581 LocalStore::Lmdb(store) => store.put_many_report_sync(items),
582 #[cfg(feature = "lmdb")]
583 LocalStore::TieredLmdb { primary, .. } => primary.put_many_report_sync(items),
584 }
585 }
586
587 pub fn put_many_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
589 self.put_many_report_sync(items)
590 .map(|report| report.inserted)
591 }
592
593 pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
595 match self {
596 LocalStore::Fs(store) => store.get_sync(hash),
597 #[cfg(feature = "lmdb")]
598 LocalStore::Lmdb(store) => store.get_sync(hash),
599 #[cfg(feature = "lmdb")]
600 LocalStore::TieredLmdb { primary, legacy } => {
601 if let Some(data) = primary.get_sync(hash)? {
602 return Ok(Some(data));
603 }
604 legacy.get_sync(hash)
605 }
606 }
607 }
608
609 pub fn get_range_sync(
610 &self,
611 hash: &Hash,
612 start: u64,
613 end_inclusive: u64,
614 ) -> Result<Option<Vec<u8>>, StoreError> {
615 match self {
616 LocalStore::Fs(store) => store.get_range_sync(hash, start, end_inclusive),
617 #[cfg(feature = "lmdb")]
618 LocalStore::Lmdb(store) => store.get_range_sync(hash, start, end_inclusive),
619 #[cfg(feature = "lmdb")]
620 LocalStore::TieredLmdb { primary, legacy } => {
621 if primary.exists(hash)? {
622 return primary.get_range_sync(hash, start, end_inclusive);
623 }
624 legacy.get_range_sync(hash, start, end_inclusive)
625 }
626 }
627 }
628
629 pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
630 match self {
631 LocalStore::Fs(store) => store.blob_size_sync(hash),
632 #[cfg(feature = "lmdb")]
633 LocalStore::Lmdb(store) => store.blob_size_sync(hash),
634 #[cfg(feature = "lmdb")]
635 LocalStore::TieredLmdb { primary, legacy } => {
636 if let Some(size) = primary.blob_size_sync(hash)? {
637 return Ok(Some(size));
638 }
639 legacy.blob_size_sync(hash)
640 }
641 }
642 }
643
644 pub fn touch_accessed_sync(&self, hash: &Hash, now: u64) -> Result<bool, StoreError> {
645 match self {
646 LocalStore::Fs(store) => store.touch_accessed_sync(hash, now),
647 #[cfg(feature = "lmdb")]
648 LocalStore::Lmdb(store) => store.touch_accessed_sync(hash, now),
649 #[cfg(feature = "lmdb")]
650 LocalStore::TieredLmdb { primary, .. } => {
651 if primary.exists(hash)? {
652 return primary.touch_accessed_sync(hash, now);
653 }
654 Ok(false)
655 }
656 }
657 }
658
659 pub fn touch_many_accessed_sync(&self, hashes: &[Hash], now: u64) -> Result<usize, StoreError> {
660 match self {
661 LocalStore::Fs(store) => store.touch_many_accessed_sync(hashes, now),
662 #[cfg(feature = "lmdb")]
663 LocalStore::Lmdb(store) => store.touch_many_accessed_sync(hashes, now),
664 #[cfg(feature = "lmdb")]
665 LocalStore::TieredLmdb { primary, .. } => {
666 let mut primary_hashes = Vec::new();
667 for hash in hashes {
668 if primary.exists(hash)? {
669 primary_hashes.push(*hash);
670 }
671 }
672 primary.touch_many_accessed_sync(&primary_hashes, now)
673 }
674 }
675 }
676
677 pub fn last_accessed_at_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
678 match self {
679 LocalStore::Fs(store) => store.last_accessed_at_sync(hash),
680 #[cfg(feature = "lmdb")]
681 LocalStore::Lmdb(store) => store.last_accessed_at_sync(hash),
682 #[cfg(feature = "lmdb")]
683 LocalStore::TieredLmdb { primary, legacy } => {
684 if let Some(accessed_at) = primary.last_accessed_at_sync(hash)? {
685 return Ok(Some(accessed_at));
686 }
687 legacy.last_accessed_at_sync(hash)
688 }
689 }
690 }
691
692 pub fn many_last_accessed_at_sync(
693 &self,
694 hashes: &[Hash],
695 ) -> Result<Vec<(Hash, u64)>, StoreError> {
696 match self {
697 LocalStore::Fs(store) => store.many_last_accessed_at_sync(hashes),
698 #[cfg(feature = "lmdb")]
699 LocalStore::Lmdb(store) => store.many_last_accessed_at_sync(hashes),
700 #[cfg(feature = "lmdb")]
701 LocalStore::TieredLmdb { primary, legacy } => {
702 let mut results = primary.many_last_accessed_at_sync(hashes)?;
703 let found: HashSet<Hash> = results.iter().map(|(hash, _)| *hash).collect();
704 let missing = hashes
705 .iter()
706 .copied()
707 .filter(|hash| !found.contains(hash))
708 .collect::<Vec<_>>();
709 results.extend(legacy.many_last_accessed_at_sync(&missing)?);
710 Ok(results)
711 }
712 }
713 }
714
715 pub fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
717 match self {
718 LocalStore::Fs(store) => Ok(store.exists(hash)),
719 #[cfg(feature = "lmdb")]
720 LocalStore::Lmdb(store) => store.exists(hash),
721 #[cfg(feature = "lmdb")]
722 LocalStore::TieredLmdb { primary, legacy } => {
723 Ok(primary.exists(hash)? || legacy.exists(hash)?)
724 }
725 }
726 }
727
728 pub fn existing_hashes_in_sorted_candidates(
730 &self,
731 sorted_hashes: &[Hash],
732 ) -> Result<Vec<bool>, StoreError> {
733 match self {
734 LocalStore::Fs(store) => Ok(sorted_hashes
735 .iter()
736 .map(|hash| store.exists(hash))
737 .collect()),
738 #[cfg(feature = "lmdb")]
739 LocalStore::Lmdb(store) => store.existing_hashes_in_sorted_candidates(sorted_hashes),
740 #[cfg(feature = "lmdb")]
741 LocalStore::TieredLmdb { primary, legacy } => {
742 let mut existing = primary.existing_hashes_in_sorted_candidates(sorted_hashes)?;
743 let missing = sorted_hashes
744 .iter()
745 .copied()
746 .zip(existing.iter().copied())
747 .filter_map(|(hash, exists)| (!exists).then_some(hash))
748 .collect::<Vec<_>>();
749 if missing.is_empty() {
750 return Ok(existing);
751 }
752 let legacy_existing = legacy.existing_hashes_in_sorted_candidates(&missing)?;
753 let legacy_existing_by_hash: HashSet<Hash> = missing
754 .into_iter()
755 .zip(legacy_existing)
756 .filter_map(|(hash, exists)| exists.then_some(hash))
757 .collect();
758 for (hash, exists) in sorted_hashes.iter().zip(existing.iter_mut()) {
759 *exists |= legacy_existing_by_hash.contains(hash);
760 }
761 Ok(existing)
762 }
763 }
764 }
765
766 pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
768 match self {
769 LocalStore::Fs(store) => store.delete_sync(hash),
770 #[cfg(feature = "lmdb")]
771 LocalStore::Lmdb(store) => store.delete_sync(hash),
772 #[cfg(feature = "lmdb")]
773 LocalStore::TieredLmdb { primary, legacy } => {
774 let deleted_primary = if primary.exists(hash)? {
775 primary.delete_sync(hash)?
776 } else {
777 false
778 };
779 let deleted_legacy = if legacy.exists(hash)? {
780 legacy.delete_sync(hash)?
781 } else {
782 false
783 };
784 Ok(deleted_primary || deleted_legacy)
785 }
786 }
787 }
788
789 pub fn delete_writable_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
790 match self {
791 LocalStore::Fs(store) => store.delete_sync(hash),
792 #[cfg(feature = "lmdb")]
793 LocalStore::Lmdb(store) => store.delete_sync(hash),
794 #[cfg(feature = "lmdb")]
795 LocalStore::TieredLmdb { primary, .. } => {
796 if primary.exists(hash)? {
797 primary.delete_sync(hash)
798 } else {
799 Ok(false)
800 }
801 }
802 }
803 }
804
805 pub fn stats(&self) -> Result<LocalStoreStats, StoreError> {
807 match self {
808 LocalStore::Fs(store) => {
809 let stats = store.stats()?;
810 Ok(LocalStoreStats {
811 count: stats.count,
812 total_bytes: stats.total_bytes,
813 })
814 }
815 #[cfg(feature = "lmdb")]
816 LocalStore::Lmdb(store) => {
817 let stats = store.stats()?;
818 Ok(LocalStoreStats {
819 count: stats.count,
820 total_bytes: stats.total_bytes,
821 })
822 }
823 #[cfg(feature = "lmdb")]
824 LocalStore::TieredLmdb { primary, legacy } => {
825 let primary_stats = primary.stats()?;
826 let legacy_stats = legacy.stats()?;
827 Ok(LocalStoreStats {
828 count: primary_stats.count.saturating_add(legacy_stats.count),
829 total_bytes: primary_stats
830 .total_bytes
831 .saturating_add(legacy_stats.total_bytes),
832 })
833 }
834 }
835 }
836
837 pub fn writable_stats(&self) -> Result<LocalStoreStats, StoreError> {
839 match self {
840 LocalStore::Fs(store) => {
841 let stats = store.stats()?;
842 Ok(LocalStoreStats {
843 count: stats.count,
844 total_bytes: stats.total_bytes,
845 })
846 }
847 #[cfg(feature = "lmdb")]
848 LocalStore::Lmdb(store) => {
849 let stats = store.stats()?;
850 Ok(LocalStoreStats {
851 count: stats.count,
852 total_bytes: stats.total_bytes,
853 })
854 }
855 #[cfg(feature = "lmdb")]
856 LocalStore::TieredLmdb { primary, .. } => {
857 let stats = primary.stats()?;
858 Ok(LocalStoreStats {
859 count: stats.count,
860 total_bytes: stats.total_bytes,
861 })
862 }
863 }
864 }
865
866 pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
868 match self {
869 LocalStore::Fs(store) => store.list(),
870 #[cfg(feature = "lmdb")]
871 LocalStore::Lmdb(store) => store.list(),
872 #[cfg(feature = "lmdb")]
873 LocalStore::TieredLmdb { primary, legacy } => {
874 let mut hashes = primary.list()?;
875 let mut seen: HashSet<Hash> = hashes.iter().copied().collect();
876 for hash in legacy.list()? {
877 if seen.insert(hash) {
878 hashes.push(hash);
879 }
880 }
881 Ok(hashes)
882 }
883 }
884 }
885
886 pub fn list_writable(&self) -> Result<Vec<Hash>, StoreError> {
888 match self {
889 LocalStore::Fs(store) => store.list(),
890 #[cfg(feature = "lmdb")]
891 LocalStore::Lmdb(store) => store.list(),
892 #[cfg(feature = "lmdb")]
893 LocalStore::TieredLmdb { primary, .. } => primary.list(),
894 }
895 }
896}
897
898#[async_trait]
899impl Store for LocalStore {
900 async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
901 self.put_sync(hash, &data)
902 }
903
904 async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
905 self.put_many_sync(&items)
906 }
907
908 async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
909 self.get_sync(hash)
910 }
911
912 async fn get_range(
913 &self,
914 hash: &Hash,
915 start: u64,
916 end_inclusive: u64,
917 ) -> Result<Option<Vec<u8>>, StoreError> {
918 self.get_range_sync(hash, start, end_inclusive)
919 }
920
921 async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
922 self.blob_size_sync(hash)
923 }
924
925 async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
926 self.exists(hash)
927 }
928
929 async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
930 self.delete_sync(hash)
931 }
932}
933
934#[cfg(feature = "s3")]
935use tokio::sync::mpsc;
936
937use crate::config::S3Config;
938
939#[cfg(feature = "s3")]
941enum S3SyncMessage {
942 Upload { hash: Hash, data: Vec<u8> },
943 Delete { hash: Hash },
944}
945
946pub struct StorageRouter {
951 local: Arc<LocalStore>,
953 #[cfg(feature = "s3")]
955 s3_client: Option<aws_sdk_s3::Client>,
956 #[cfg(feature = "s3")]
957 s3_bucket: Option<String>,
958 #[cfg(feature = "s3")]
959 s3_prefix: String,
960 #[cfg(feature = "s3")]
962 sync_tx: Option<mpsc::UnboundedSender<S3SyncMessage>>,
963}
964
965impl StorageRouter {
966 #[cfg(feature = "s3")]
967 fn s3_sync_timeout() -> std::time::Duration {
968 let millis = std::env::var(S3_SYNC_TIMEOUT_MS_ENV)
969 .ok()
970 .and_then(|value| value.parse::<u64>().ok())
971 .filter(|value| *value > 0)
972 .unwrap_or(DEFAULT_S3_SYNC_TIMEOUT_MS);
973 std::time::Duration::from_millis(millis)
974 }
975
976 #[cfg(feature = "s3")]
977 fn s3_sync_timeout_error(timeout: std::time::Duration) -> StoreError {
978 StoreError::Other(format!(
979 "S3 sync operation timed out after {}ms",
980 timeout.as_millis()
981 ))
982 }
983
984 #[cfg(feature = "s3")]
985 fn run_s3_future_sync<F, T>(future: F) -> Result<T, StoreError>
986 where
987 F: Future<Output = T> + Send + 'static,
988 T: Send + 'static,
989 {
990 let timeout = Self::s3_sync_timeout();
991 if tokio::runtime::Handle::try_current().is_ok() {
992 return std::thread::Builder::new()
993 .name("storage-s3-sync".to_string())
994 .spawn(move || {
995 let runtime = tokio::runtime::Builder::new_current_thread()
996 .enable_all()
997 .build()
998 .map_err(|err| {
999 StoreError::Other(format!("build storage s3 sync runtime: {err}"))
1000 })?;
1001 runtime.block_on(async move {
1002 tokio::time::timeout(timeout, future)
1003 .await
1004 .map_err(|_| Self::s3_sync_timeout_error(timeout))
1005 })
1006 })
1007 .map_err(|err| StoreError::Other(format!("spawn S3 sync helper thread: {err}")))?
1008 .join()
1009 .map_err(|_| StoreError::Other("S3 sync helper thread panicked".to_string()))?;
1010 }
1011
1012 let runtime = tokio::runtime::Builder::new_current_thread()
1013 .enable_all()
1014 .build()
1015 .map_err(|err| StoreError::Other(format!("build storage s3 sync runtime: {err}")))?;
1016 runtime.block_on(async move {
1017 tokio::time::timeout(timeout, future)
1018 .await
1019 .map_err(|_| Self::s3_sync_timeout_error(timeout))
1020 })
1021 }
1022
1023 pub fn new(local: Arc<LocalStore>) -> Self {
1025 Self {
1026 local,
1027 #[cfg(feature = "s3")]
1028 s3_client: None,
1029 #[cfg(feature = "s3")]
1030 s3_bucket: None,
1031 #[cfg(feature = "s3")]
1032 s3_prefix: String::new(),
1033 #[cfg(feature = "s3")]
1034 sync_tx: None,
1035 }
1036 }
1037
1038 pub fn force_sync(&self) -> Result<(), StoreError> {
1039 self.local.force_sync()
1040 }
1041
1042 #[cfg(feature = "s3")]
1044 pub async fn with_s3(local: Arc<LocalStore>, config: &S3Config) -> Result<Self, anyhow::Error> {
1045 use aws_sdk_s3::Client as S3Client;
1046
1047 let mut aws_config_loader = aws_config::from_env();
1049 aws_config_loader =
1050 aws_config_loader.region(aws_sdk_s3::config::Region::new(config.region.clone()));
1051 let aws_config = aws_config_loader.load().await;
1052
1053 let mut s3_config_builder = aws_sdk_s3::config::Builder::from(&aws_config);
1055 s3_config_builder = s3_config_builder
1056 .endpoint_url(&config.endpoint)
1057 .force_path_style(true);
1058
1059 let s3_client = S3Client::from_conf(s3_config_builder.build());
1060 let bucket = config.bucket.clone();
1061 let prefix = config.prefix.clone().unwrap_or_default();
1062
1063 let (sync_tx, mut sync_rx) = mpsc::unbounded_channel::<S3SyncMessage>();
1065
1066 let sync_client = s3_client.clone();
1068 let sync_bucket = bucket.clone();
1069 let sync_prefix = prefix.clone();
1070
1071 tokio::spawn(async move {
1072 use aws_sdk_s3::primitives::ByteStream;
1073
1074 tracing::info!("S3 background sync task started");
1075
1076 let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(8));
1078 let client = std::sync::Arc::new(sync_client);
1079 let bucket = std::sync::Arc::new(sync_bucket);
1080 let prefix = std::sync::Arc::new(sync_prefix);
1081
1082 while let Some(msg) = sync_rx.recv().await {
1083 let client = client.clone();
1084 let bucket = bucket.clone();
1085 let prefix = prefix.clone();
1086 let semaphore = semaphore.clone();
1087
1088 tokio::spawn(async move {
1090 let _permit = semaphore.acquire().await;
1092
1093 match msg {
1094 S3SyncMessage::Upload { hash, data } => {
1095 let key = format!("{}{}.bin", prefix, to_hex(&hash));
1096 tracing::debug!("S3 uploading {} ({} bytes)", &key, data.len());
1097
1098 let mut attempt = 1u8;
1099 loop {
1100 match client
1101 .put_object()
1102 .bucket(bucket.as_str())
1103 .key(&key)
1104 .body(ByteStream::from(data.clone()))
1105 .send()
1106 .await
1107 {
1108 Ok(_) => {
1109 tracing::debug!("S3 upload succeeded: {}", &key);
1110 break;
1111 }
1112 Err(e) if attempt < 3 => {
1113 tracing::warn!(
1114 "S3 upload retrying {}: attempt={} error={}",
1115 &key,
1116 attempt,
1117 e
1118 );
1119 tokio::time::sleep(std::time::Duration::from_millis(
1120 250 * u64::from(attempt),
1121 ))
1122 .await;
1123 attempt += 1;
1124 }
1125 Err(e) => {
1126 tracing::error!(
1127 "S3 upload failed {} after {} attempts: {}",
1128 &key,
1129 attempt,
1130 e
1131 );
1132 break;
1133 }
1134 }
1135 }
1136 }
1137 S3SyncMessage::Delete { hash } => {
1138 let key = format!("{}{}.bin", prefix, to_hex(&hash));
1139 tracing::debug!("S3 deleting {}", &key);
1140
1141 let mut attempt = 1u8;
1142 loop {
1143 match client
1144 .delete_object()
1145 .bucket(bucket.as_str())
1146 .key(&key)
1147 .send()
1148 .await
1149 {
1150 Ok(_) => break,
1151 Err(e) if attempt < 3 => {
1152 tracing::warn!(
1153 "S3 delete retrying {}: attempt={} error={}",
1154 &key,
1155 attempt,
1156 e
1157 );
1158 tokio::time::sleep(std::time::Duration::from_millis(
1159 250 * u64::from(attempt),
1160 ))
1161 .await;
1162 attempt += 1;
1163 }
1164 Err(e) => {
1165 tracing::error!(
1166 "S3 delete failed {} after {} attempts: {}",
1167 &key,
1168 attempt,
1169 e
1170 );
1171 break;
1172 }
1173 }
1174 }
1175 }
1176 }
1177 });
1178 }
1179 });
1180
1181 tracing::info!(
1182 "S3 storage initialized: bucket={}, prefix={}",
1183 bucket,
1184 prefix
1185 );
1186
1187 Ok(Self {
1188 local,
1189 s3_client: Some(s3_client),
1190 s3_bucket: Some(bucket),
1191 s3_prefix: prefix,
1192 sync_tx: Some(sync_tx),
1193 })
1194 }
1195
1196 pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
1198 let is_new = self.local.put_sync(hash, data)?;
1200
1201 #[cfg(feature = "s3")]
1204 if is_new {
1205 if let Some(ref tx) = self.sync_tx {
1206 tracing::debug!(
1207 "Queueing S3 upload for {} ({} bytes)",
1208 crate::storage::to_hex(&hash)[..16].to_string(),
1209 data.len(),
1210 );
1211 if let Err(e) = tx.send(S3SyncMessage::Upload {
1212 hash,
1213 data: data.to_vec(),
1214 }) {
1215 tracing::error!("Failed to queue S3 upload: {}", e);
1216 }
1217 }
1218 }
1219
1220 Ok(is_new)
1221 }
1222
1223 pub fn put_many_report_sync(
1225 &self,
1226 items: &[(Hash, Vec<u8>)],
1227 ) -> Result<PutManyReport, StoreError> {
1228 let report = self.local.put_many_report_sync(items)?;
1229
1230 #[cfg(feature = "s3")]
1231 if let Some(ref tx) = self.sync_tx {
1232 if !report.inserted_hashes.is_empty() {
1233 let inserted: HashSet<Hash> = report.inserted_hashes.iter().copied().collect();
1234 let mut queued = HashSet::new();
1235 for (hash, data) in items {
1236 if inserted.contains(hash) && queued.insert(*hash) {
1237 if let Err(e) = tx.send(S3SyncMessage::Upload {
1238 hash: *hash,
1239 data: data.clone(),
1240 }) {
1241 tracing::error!("Failed to queue S3 upload: {}", e);
1242 }
1243 }
1244 }
1245 }
1246 }
1247
1248 Ok(report)
1249 }
1250
1251 pub fn put_many_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
1253 self.put_many_report_sync(items)
1254 .map(|report| report.inserted)
1255 }
1256
1257 pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
1259 if let Some(data) = self.local.get_sync(hash)? {
1261 return Ok(Some(data));
1262 }
1263
1264 #[cfg(feature = "s3")]
1266 if let (Some(ref client), Some(ref bucket)) = (&self.s3_client, &self.s3_bucket) {
1267 let key = format!("{}{}.bin", self.s3_prefix, to_hex(hash));
1268 let client = client.clone();
1269 let bucket = bucket.clone();
1270
1271 match Self::run_s3_future_sync(async move {
1272 client.get_object().bucket(bucket).key(key).send().await
1273 }) {
1274 Ok(Ok(output)) => {
1275 match Self::run_s3_future_sync(async move { output.body.collect().await }) {
1276 Ok(Ok(body)) => {
1277 let data = body.into_bytes().to_vec();
1278 let _ = self.local.put_sync(*hash, &data);
1280 return Ok(Some(data));
1281 }
1282 Ok(Err(err)) => {
1283 tracing::warn!("S3 body collect failed: {}", err);
1284 }
1285 Err(err) => {
1286 tracing::warn!("S3 body collect runtime failed: {}", err);
1287 }
1288 }
1289 }
1290 Ok(Err(err)) => {
1291 let service_err = err.into_service_error();
1292 if !service_err.is_no_such_key() {
1293 tracing::warn!("S3 get failed: {}", service_err);
1294 }
1295 }
1296 Err(err) => {
1297 tracing::warn!("S3 get runtime failed: {}", err);
1298 }
1299 }
1300 }
1301
1302 Ok(None)
1303 }
1304
1305 pub fn get_range_sync(
1306 &self,
1307 hash: &Hash,
1308 start: u64,
1309 end_inclusive: u64,
1310 ) -> Result<Option<Vec<u8>>, StoreError> {
1311 self.local.get_range_sync(hash, start, end_inclusive)
1312 }
1313
1314 pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1315 self.local.blob_size_sync(hash)
1316 }
1317
1318 pub fn touch_accessed_sync(&self, hash: &Hash, now: u64) -> Result<bool, StoreError> {
1319 self.local.touch_accessed_sync(hash, now)
1320 }
1321
1322 pub fn touch_many_accessed_sync(&self, hashes: &[Hash], now: u64) -> Result<usize, StoreError> {
1323 self.local.touch_many_accessed_sync(hashes, now)
1324 }
1325
1326 pub fn last_accessed_at_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1327 self.local.last_accessed_at_sync(hash)
1328 }
1329
1330 pub fn many_last_accessed_at_sync(
1331 &self,
1332 hashes: &[Hash],
1333 ) -> Result<Vec<(Hash, u64)>, StoreError> {
1334 self.local.many_last_accessed_at_sync(hashes)
1335 }
1336
1337 pub fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
1339 if self.local.exists(hash)? {
1341 return Ok(true);
1342 }
1343
1344 #[cfg(feature = "s3")]
1346 if let (Some(ref client), Some(ref bucket)) = (&self.s3_client, &self.s3_bucket) {
1347 let key = format!("{}{}.bin", self.s3_prefix, to_hex(hash));
1348 let client = client.clone();
1349 let bucket = bucket.clone();
1350
1351 match Self::run_s3_future_sync(async move {
1352 client.head_object().bucket(bucket).key(&key).send().await
1353 }) {
1354 Ok(Ok(_)) => return Ok(true),
1355 Ok(Err(err)) => {
1356 let service_err = err.into_service_error();
1357 if !service_err.is_not_found() {
1358 tracing::warn!("S3 head failed: {}", service_err);
1359 }
1360 }
1361 Err(err) => {
1362 tracing::warn!("S3 head runtime failed: {}", err);
1363 }
1364 }
1365 }
1366
1367 Ok(false)
1368 }
1369
1370 pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
1372 let deleted = self.local.delete_sync(hash)?;
1373
1374 #[cfg(feature = "s3")]
1376 if let Some(ref tx) = self.sync_tx {
1377 let _ = tx.send(S3SyncMessage::Delete { hash: *hash });
1378 }
1379
1380 Ok(deleted)
1381 }
1382
1383 pub fn delete_local_only(&self, hash: &Hash) -> Result<bool, StoreError> {
1386 self.local.delete_writable_sync(hash)
1387 }
1388
1389 pub fn stats(&self) -> Result<LocalStoreStats, StoreError> {
1391 self.local.stats()
1392 }
1393
1394 pub fn writable_stats(&self) -> Result<LocalStoreStats, StoreError> {
1396 self.local.writable_stats()
1397 }
1398
1399 pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
1401 self.local.list()
1402 }
1403
1404 pub fn list_writable(&self) -> Result<Vec<Hash>, StoreError> {
1406 self.local.list_writable()
1407 }
1408
1409 pub fn existing_local_hashes_in_sorted_candidates(
1411 &self,
1412 sorted_hashes: &[Hash],
1413 ) -> Result<Vec<bool>, StoreError> {
1414 self.local
1415 .existing_hashes_in_sorted_candidates(sorted_hashes)
1416 }
1417
1418 pub fn local_store(&self) -> Arc<LocalStore> {
1420 Arc::clone(&self.local)
1421 }
1422}
1423
1424#[derive(Clone)]
1425struct AccessRecordingStore {
1426 inner: Arc<StorageRouter>,
1427 accessed: Arc<Mutex<HashSet<Hash>>>,
1428}
1429
1430impl AccessRecordingStore {
1431 fn new(inner: Arc<StorageRouter>) -> Self {
1432 Self {
1433 inner,
1434 accessed: Arc::new(Mutex::new(HashSet::new())),
1435 }
1436 }
1437
1438 fn take_accessed_hashes(&self) -> Vec<Hash> {
1439 let Ok(mut accessed) = self.accessed.lock() else {
1440 return Vec::new();
1441 };
1442 accessed.drain().collect()
1443 }
1444
1445 fn record_access(&self, hash: &Hash) {
1446 let Ok(mut accessed) = self.accessed.lock() else {
1447 return;
1448 };
1449 accessed.insert(*hash);
1450 }
1451}
1452
1453#[async_trait]
1454impl Store for AccessRecordingStore {
1455 async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
1456 self.inner.put(hash, data).await
1457 }
1458
1459 async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
1460 self.inner.put_many(items).await
1461 }
1462
1463 async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
1464 let data = self.inner.get(hash).await?;
1465 if data.is_some() {
1466 self.record_access(hash);
1467 }
1468 Ok(data)
1469 }
1470
1471 async fn get_range(
1472 &self,
1473 hash: &Hash,
1474 start: u64,
1475 end_inclusive: u64,
1476 ) -> Result<Option<Vec<u8>>, StoreError> {
1477 let data = self.inner.get_range(hash, start, end_inclusive).await?;
1478 if data.is_some() {
1479 self.record_access(hash);
1480 }
1481 Ok(data)
1482 }
1483
1484 async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1485 self.inner.blob_size(hash).await
1486 }
1487
1488 async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
1489 self.inner.has(hash).await
1490 }
1491
1492 async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
1493 self.inner.delete(hash).await
1494 }
1495}
1496
1497#[async_trait]
1500impl Store for StorageRouter {
1501 async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
1502 self.put_sync(hash, &data)
1503 }
1504
1505 async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
1506 self.put_many_sync(&items)
1507 }
1508
1509 async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
1510 self.get_sync(hash)
1511 }
1512
1513 async fn get_range(
1514 &self,
1515 hash: &Hash,
1516 start: u64,
1517 end_inclusive: u64,
1518 ) -> Result<Option<Vec<u8>>, StoreError> {
1519 if let Some(data) = self.get_range_sync(hash, start, end_inclusive)? {
1520 return Ok(Some(data));
1521 }
1522 let Some(data) = self.get_sync(hash)? else {
1523 return Ok(None);
1524 };
1525 Ok(Some(slice_blob_range(&data, start, end_inclusive)?))
1526 }
1527
1528 async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1529 if let Some(size) = self.blob_size_sync(hash)? {
1530 return Ok(Some(size));
1531 }
1532 Ok(self.get_sync(hash)?.map(|data| data.len() as u64))
1533 }
1534
1535 async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
1536 self.exists(hash)
1537 }
1538
1539 async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
1540 self.delete_sync(hash)
1541 }
1542}
1543
1544pub struct HashtreeStore {
1545 base_path: PathBuf,
1546 env: heed::Env,
1547 pins: Database<Bytes, Unit>,
1549 pinned_refs: Database<Str, Unit>,
1551 tracked_authors: Database<Str, Unit>,
1553 blob_owners: Database<Bytes, Unit>,
1555 pubkey_blobs: Database<Bytes, Bytes>,
1557 pubkey_blob_index: Database<Bytes, Bytes>,
1559 tree_meta: Database<Bytes, Bytes>,
1561 blob_trees: Database<Bytes, Unit>,
1563 tree_refs: Database<Str, Bytes>,
1565 cached_roots: Database<Str, Bytes>,
1567 router: Arc<StorageRouter>,
1569 max_size_bytes: u64,
1571 evict_orphans: bool,
1573 blob_access_update_gate: BlobAccessUpdateGate,
1575 blob_access_update_inflight: Arc<AtomicBool>,
1577 file_metadata_cache: Mutex<LruCache<Hash, Arc<FileChunkMetadata>>>,
1579}
1580
1581impl HashtreeStore {
1582 pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
1584 let config = hashtree_config::Config::load_or_default();
1585 let max_size_bytes = config
1586 .storage
1587 .max_size_gb
1588 .saturating_mul(1024 * 1024 * 1024);
1589 Self::with_options_and_backend(
1590 path,
1591 None,
1592 max_size_bytes,
1593 config.storage.evict_orphans,
1594 &config.storage.backend,
1595 )
1596 }
1597
1598 pub fn new_with_backend<P: AsRef<Path>>(
1600 path: P,
1601 backend: hashtree_config::StorageBackend,
1602 max_size_bytes: u64,
1603 ) -> Result<Self> {
1604 Self::with_options_and_backend(path, None, max_size_bytes, true, &backend)
1605 }
1606
1607 pub fn with_s3<P: AsRef<Path>>(path: P, s3_config: Option<&S3Config>) -> Result<Self> {
1609 let config = hashtree_config::Config::load_or_default();
1610 let max_size_bytes = config
1611 .storage
1612 .max_size_gb
1613 .saturating_mul(1024 * 1024 * 1024);
1614 Self::with_options_and_backend(
1615 path,
1616 s3_config,
1617 max_size_bytes,
1618 config.storage.evict_orphans,
1619 &config.storage.backend,
1620 )
1621 }
1622
1623 pub fn with_options<P: AsRef<Path>>(
1629 path: P,
1630 s3_config: Option<&S3Config>,
1631 max_size_bytes: u64,
1632 ) -> Result<Self> {
1633 let config = hashtree_config::Config::load_or_default();
1634 Self::with_options_and_backend(
1635 path,
1636 s3_config,
1637 max_size_bytes,
1638 config.storage.evict_orphans,
1639 &config.storage.backend,
1640 )
1641 }
1642
1643 pub fn with_options_and_backend<P: AsRef<Path>>(
1644 path: P,
1645 s3_config: Option<&S3Config>,
1646 max_size_bytes: u64,
1647 evict_orphans: bool,
1648 backend: &hashtree_config::StorageBackend,
1649 ) -> Result<Self> {
1650 Self::with_options_and_backend_and_env_flags(
1651 path,
1652 s3_config,
1653 max_size_bytes,
1654 evict_orphans,
1655 backend,
1656 EnvFlags::empty(),
1657 )
1658 }
1659
1660 pub fn with_embedded_options<P: AsRef<Path>>(
1667 path: P,
1668 s3_config: Option<&S3Config>,
1669 max_size_bytes: u64,
1670 ) -> Result<Self> {
1671 Self::with_options_and_backend_and_env_flags(
1672 path,
1673 s3_config,
1674 max_size_bytes,
1675 true,
1676 &hashtree_config::StorageBackend::Fs,
1677 EnvFlags::NO_LOCK,
1678 )
1679 }
1680
1681 fn with_options_and_backend_and_env_flags<P: AsRef<Path>>(
1682 path: P,
1683 s3_config: Option<&S3Config>,
1684 max_size_bytes: u64,
1685 evict_orphans: bool,
1686 backend: &hashtree_config::StorageBackend,
1687 env_flags: EnvFlags,
1688 ) -> Result<Self> {
1689 let env_flags = env_flags | lmdb_env_flags_from_env();
1690 let path = path.as_ref();
1691 std::fs::create_dir_all(path)?;
1692 let metadata_map_size = lmdb_map_size_for_existing_env(
1693 path,
1694 lmdb_metadata_map_size_for_storage_budget(max_size_bytes),
1695 )?;
1696
1697 let mut env_options = EnvOpenOptions::new();
1698 env_options
1699 .map_size(metadata_map_size)
1700 .max_dbs(11) .max_readers(LMDB_MAX_READERS);
1702 unsafe {
1703 env_options.flags(env_flags);
1704 }
1705 let env = unsafe { env_options.open(path)? };
1706 let _ = env.clear_stale_readers();
1707 if env.info().map_size < metadata_map_size {
1708 unsafe { env.resize(metadata_map_size) }?;
1709 }
1710
1711 let mut wtxn = env.write_txn()?;
1712 let pins = env.create_database(&mut wtxn, Some("pins"))?;
1713 let pinned_refs = env.create_database(&mut wtxn, Some("pinned_refs"))?;
1714 let tracked_authors = env.create_database(&mut wtxn, Some("tracked_authors"))?;
1715 let blob_owners = env.create_database(&mut wtxn, Some("blob_owners"))?;
1716 let pubkey_blobs = env.create_database(&mut wtxn, Some("pubkey_blobs"))?;
1717 let pubkey_blob_index = env.create_database(&mut wtxn, Some("pubkey_blob_index"))?;
1718 let tree_meta = env.create_database(&mut wtxn, Some("tree_meta"))?;
1719 let blob_trees = env.create_database(&mut wtxn, Some("blob_trees"))?;
1720 let tree_refs = env.create_database(&mut wtxn, Some("tree_refs"))?;
1721 let cached_roots = env.create_database(&mut wtxn, Some("cached_roots"))?;
1722 wtxn.commit()?;
1723
1724 #[cfg(feature = "lmdb")]
1728 let blob_map_size = Some(max_size_bytes.max(LMDB_BLOB_MIN_MAP_SIZE_BYTES));
1729 #[cfg(not(feature = "lmdb"))]
1730 let blob_map_size = None;
1731 let local_store = Arc::new(
1732 LocalStore::new_unbounded_with_lmdb_map_size(
1733 path.join("blobs"),
1734 backend,
1735 blob_map_size,
1736 )
1737 .map_err(|e| anyhow::anyhow!("Failed to create blob store: {}", e))?,
1738 );
1739
1740 #[cfg(feature = "s3")]
1742 let router = Arc::new(if let Some(s3_cfg) = s3_config {
1743 tracing::info!(
1744 "Initializing S3 storage backend: bucket={}, endpoint={}",
1745 s3_cfg.bucket,
1746 s3_cfg.endpoint
1747 );
1748
1749 sync_block_on(async { StorageRouter::with_s3(local_store, s3_cfg).await })?
1750 } else {
1751 StorageRouter::new(local_store)
1752 });
1753
1754 #[cfg(not(feature = "s3"))]
1755 let router = Arc::new({
1756 if s3_config.is_some() {
1757 tracing::warn!(
1758 "S3 config provided but S3 feature not enabled. Using local storage only."
1759 );
1760 }
1761 StorageRouter::new(local_store)
1762 });
1763
1764 Ok(Self {
1765 base_path: path.to_path_buf(),
1766 env,
1767 pins,
1768 pinned_refs,
1769 tracked_authors,
1770 blob_owners,
1771 pubkey_blobs,
1772 pubkey_blob_index,
1773 tree_meta,
1774 blob_trees,
1775 tree_refs,
1776 cached_roots,
1777 router,
1778 max_size_bytes,
1779 evict_orphans,
1780 blob_access_update_gate: BlobAccessUpdateGate::default(),
1781 blob_access_update_inflight: Arc::new(AtomicBool::new(false)),
1782 file_metadata_cache: Mutex::new(LruCache::new(file_metadata_cache_entries())),
1783 })
1784 }
1785
1786 pub fn base_path(&self) -> &Path {
1787 &self.base_path
1788 }
1789
1790 pub fn router(&self) -> &StorageRouter {
1792 &self.router
1793 }
1794
1795 pub fn store_arc(&self) -> Arc<StorageRouter> {
1798 Arc::clone(&self.router)
1799 }
1800
1801 pub fn force_sync(&self) -> Result<()> {
1802 self.env.force_sync()?;
1803 self.router
1804 .force_sync()
1805 .map_err(|err| anyhow::anyhow!("Failed to sync blob store: {}", err))
1806 }
1807
1808 fn access_tracking_tree(&self) -> (HashTree<AccessRecordingStore>, AccessRecordingStore) {
1809 let access_store = AccessRecordingStore::new(self.store_arc());
1810 let tree = HashTree::new(HashTreeConfig::new(Arc::new(access_store.clone())).public());
1811 (tree, access_store)
1812 }
1813
1814 pub fn record_blob_accesses<I>(&self, hashes: I)
1815 where
1816 I: IntoIterator<Item = Hash>,
1817 {
1818 let access_update_batch_limit = access_update_background_batch_limit();
1819 if access_update_batch_limit == 0 {
1820 return;
1821 }
1822
1823 let now = unix_timestamp_now();
1824 let mut due_hashes = self.blob_access_update_gate.due_hashes(hashes, now);
1825 if due_hashes.is_empty() {
1826 return;
1827 }
1828
1829 if self
1830 .blob_access_update_inflight
1831 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1832 .is_err()
1833 {
1834 return;
1835 }
1836
1837 if due_hashes.len() > access_update_batch_limit {
1838 due_hashes.truncate(access_update_batch_limit);
1839 }
1840
1841 let router = Arc::clone(&self.router);
1842 let inflight = Arc::clone(&self.blob_access_update_inflight);
1843 let spawn_result = std::thread::Builder::new()
1844 .name("blob-access-update".to_string())
1845 .spawn(move || {
1846 if let Err(err) = router.touch_many_accessed_sync(&due_hashes, now) {
1847 tracing::debug!("Failed to update blob access metadata: {}", err);
1848 }
1849 inflight.store(false, Ordering::Release);
1850 });
1851 if let Err(err) = spawn_result {
1852 self.blob_access_update_inflight
1853 .store(false, Ordering::Release);
1854 tracing::debug!("Failed to spawn blob access metadata updater: {}", err);
1855 }
1856 }
1857
1858 pub fn blob_last_accessed_at(&self, hash: &Hash) -> Result<Option<u64>> {
1859 self.router
1860 .last_accessed_at_sync(hash)
1861 .map_err(|e| anyhow::anyhow!("Failed to read blob access metadata: {}", e))
1862 }
1863
1864 pub fn blob_last_accessed_many(&self, hashes: &[Hash]) -> Result<Vec<(Hash, u64)>> {
1865 self.router
1866 .many_last_accessed_at_sync(hashes)
1867 .map_err(|e| anyhow::anyhow!("Failed to read blob access metadata: {}", e))
1868 }
1869
1870 pub fn get_tree_node(&self, hash: &[u8; 32]) -> Result<Option<TreeNode>> {
1872 let (tree, access_store) = self.access_tracking_tree();
1873
1874 let result = sync_block_on(async {
1875 tree.get_tree_node(hash)
1876 .await
1877 .map_err(|e| anyhow::anyhow!("Failed to get tree node: {}", e))
1878 })?;
1879 if result.is_some() {
1880 self.record_blob_accesses(access_store.take_accessed_hashes());
1881 }
1882 Ok(result)
1883 }
1884
1885 pub fn put_blob(&self, data: &[u8]) -> Result<String> {
1887 let hash = sha256(data);
1888 self.router
1889 .put_sync(hash, data)
1890 .map_err(|e| anyhow::anyhow!("Failed to store blob: {}", e))?;
1891 Ok(to_hex(&hash))
1892 }
1893
1894 pub fn put_owned_blob_with_inserted(
1896 &self,
1897 data: &[u8],
1898 pubkey: &[u8; 32],
1899 ) -> Result<(String, bool)> {
1900 let hash = sha256(data);
1901 let incoming_bytes = data.len() as u64;
1902 let mut retried_after_cleanup = false;
1903 let inserted = loop {
1904 match self.router.put_sync(hash, data) {
1905 Ok(inserted) => break inserted,
1906 Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
1907 let freed = self.make_room_for_durable_blob(incoming_bytes)?;
1908 if freed == 0 {
1909 return Err(anyhow::anyhow!("Failed to store blob: {}", err));
1910 }
1911 retried_after_cleanup = true;
1912 }
1913 Err(err) => return Err(anyhow::anyhow!("Failed to store blob: {}", err)),
1914 }
1915 };
1916
1917 self.set_blob_owner_with_size(&hash, pubkey, incoming_bytes)?;
1918 if inserted {
1919 if let Err(err) = self.enforce_durable_blob_budget_after_insert(incoming_bytes) {
1920 let _ = self.delete_blossom_blob(&hash, pubkey);
1921 return Err(err);
1922 }
1923 }
1924
1925 Ok((to_hex(&hash), inserted))
1926 }
1927
1928 pub fn put_owned_blob(&self, data: &[u8], pubkey: &[u8; 32]) -> Result<String> {
1929 self.put_owned_blob_with_inserted(data, pubkey)
1930 .map(|(hash, _)| hash)
1931 }
1932
1933 fn put_blob_owners_for_batch(
1934 &self,
1935 items: &[(Hash, Vec<u8>)],
1936 pubkey: &[u8; 32],
1937 ) -> Result<()> {
1938 let now = SystemTime::now()
1939 .duration_since(UNIX_EPOCH)
1940 .unwrap()
1941 .as_secs();
1942 let mut wtxn = self.env.write_txn()?;
1943 for (hash, data) in items {
1944 let owner_key = Self::blob_owner_key(hash, pubkey);
1945 match self.blob_owners.put_with_flags(
1946 &mut wtxn,
1947 PutFlags::NO_OVERWRITE,
1948 &owner_key[..],
1949 &(),
1950 ) {
1951 Ok(()) => {}
1952 Err(HeedError::Mdb(MdbError::KeyExist)) => continue,
1953 Err(error) => return Err(error.into()),
1954 }
1955
1956 let index_key = Self::pubkey_blob_key(pubkey, hash);
1957 let metadata = BlobMetadata {
1958 sha256: to_hex(hash),
1959 size: data.len() as u64,
1960 mime_type: "application/octet-stream".to_string(),
1961 uploaded: now,
1962 };
1963 self.pubkey_blob_index.put(
1964 &mut wtxn,
1965 &index_key[..],
1966 &serde_json::to_vec(&metadata)?,
1967 )?;
1968 }
1969 wtxn.commit()?;
1970 Ok(())
1971 }
1972
1973 fn put_many_durable_blob_bodies(
1974 &self,
1975 items: &[(Hash, Vec<u8>)],
1976 incoming_bytes: u64,
1977 ) -> Result<PutManyReport> {
1978 let mut retried_after_cleanup = false;
1979 loop {
1980 match self.router.put_many_report_sync(items) {
1981 Ok(report) => return Ok(report),
1982 Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
1983 let freed = self.make_room_for_durable_blob(incoming_bytes)?;
1984 if freed == 0 {
1985 return Err(anyhow::anyhow!("Failed to store blob batch: {}", err));
1986 }
1987 retried_after_cleanup = true;
1988 }
1989 Err(err) => return Err(anyhow::anyhow!("Failed to store blob batch: {}", err)),
1990 }
1991 }
1992 }
1993
1994 pub fn put_owned_blobs_report(
1996 &self,
1997 items: &[(Hash, Vec<u8>)],
1998 pubkey: &[u8; 32],
1999 ) -> Result<PutManyReport> {
2000 let started_at = Instant::now();
2001 let slow_log_ms = slow_owned_blob_batch_log_ms();
2002 if items.is_empty() {
2003 return Ok(PutManyReport::default());
2004 }
2005 let incoming_bytes = items.iter().fold(0u64, |total, (_, data)| {
2006 total.saturating_add(data.len() as u64)
2007 });
2008 let count = items.len();
2009 let raw_started = Instant::now();
2010 let report = self.put_many_durable_blob_bodies(items, incoming_bytes)?;
2011 let raw_write_ms = raw_started.elapsed().as_millis();
2012
2013 let owner_started = Instant::now();
2014 self.put_blob_owners_for_batch(items, pubkey)?;
2015 let owner_index_ms = owner_started.elapsed().as_millis();
2016 let quota_started = Instant::now();
2017 if report.inserted_bytes > 0 {
2018 if let Err(err) = self.enforce_durable_blob_budget_after_insert(report.inserted_bytes) {
2019 for hash in &report.inserted_hashes {
2020 let _ = self.delete_blossom_blob(hash, pubkey);
2021 }
2022 return Err(err);
2023 }
2024 }
2025 let quota_ms = quota_started.elapsed().as_millis();
2026 let total_ms = started_at.elapsed().as_millis();
2027 if slow_log_ms.is_some_and(|threshold| total_ms >= threshold) {
2028 tracing::warn!(
2029 blobs = count,
2030 inserted = report.inserted,
2031 incoming_bytes,
2032 inserted_bytes = report.inserted_bytes,
2033 total_ms,
2034 raw_write_ms,
2035 owner_index_ms,
2036 quota_ms,
2037 "slow owned Blossom blob batch write"
2038 );
2039 }
2040 Ok(report)
2041 }
2042
2043 pub fn put_owned_blobs(&self, items: &[(Hash, Vec<u8>)], pubkey: &[u8; 32]) -> Result<usize> {
2045 self.put_owned_blobs_report(items, pubkey)
2046 .map(|report| report.inserted)
2047 }
2048
2049 pub fn put_cached_blob_with_inserted(&self, data: &[u8]) -> Result<(String, bool)> {
2055 let hash = sha256(data);
2056 let incoming_bytes = data.len() as u64;
2057
2058 if !self
2063 .router
2064 .exists(&hash)
2065 .map_err(|e| anyhow::anyhow!("Failed to check cached blob: {}", e))?
2066 {
2067 self.make_room_for_cached_blob(incoming_bytes)?;
2068 }
2069
2070 let mut retried_after_cleanup = false;
2071 loop {
2072 match self.router.put_sync(hash, data) {
2073 Ok(inserted) => {
2074 if inserted {
2075 if let Err(err) =
2076 self.enforce_cached_blob_budget_after_insert(incoming_bytes)
2077 {
2078 tracing::debug!("Failed to enforce cached blob budget: {}", err);
2079 }
2080 }
2081 return Ok((to_hex(&hash), inserted));
2082 }
2083 Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
2084 let freed = self.relieve_cached_blob_write_pressure(incoming_bytes)?;
2085 if freed == 0 {
2086 return Err(anyhow::anyhow!("Failed to store cached blob: {}", err));
2087 }
2088 retried_after_cleanup = true;
2089 }
2090 Err(err) => return Err(anyhow::anyhow!("Failed to store cached blob: {}", err)),
2091 }
2092 }
2093 }
2094
2095 pub fn put_cached_blob(&self, data: &[u8]) -> Result<String> {
2096 self.put_cached_blob_with_inserted(data)
2097 .map(|(hash, _)| hash)
2098 }
2099
2100 pub fn put_cached_blobs_report(&self, items: &[(Hash, Vec<u8>)]) -> Result<PutManyReport> {
2102 let started_at = Instant::now();
2103 let slow_log_ms = slow_cached_blob_batch_log_ms();
2104 if items.is_empty() {
2105 return Ok(PutManyReport::default());
2106 }
2107
2108 let candidate_bytes = items.iter().fold(0u64, |total, (_, data)| {
2109 total.saturating_add(data.len() as u64)
2110 });
2111
2112 let mut retried_after_cleanup = false;
2113 loop {
2114 let raw_started = Instant::now();
2115 match self.router.put_many_report_sync(items) {
2116 Ok(report) => {
2117 let raw_write_ms = raw_started.elapsed().as_millis();
2118 let quota_started = Instant::now();
2119 if report.inserted_bytes > 0 {
2120 if let Err(err) =
2121 self.enforce_cached_blob_budget_after_insert(report.inserted_bytes)
2122 {
2123 tracing::debug!("Failed to enforce cached blob budget: {}", err);
2124 }
2125 }
2126 let quota_ms = quota_started.elapsed().as_millis();
2127 let total_ms = started_at.elapsed().as_millis();
2128 if slow_log_ms.is_some_and(|threshold| total_ms >= threshold) {
2129 tracing::warn!(
2130 blobs = items.len(),
2131 inserted = report.inserted,
2132 candidate_bytes,
2133 inserted_bytes = report.inserted_bytes,
2134 total_ms,
2135 raw_write_ms,
2136 quota_ms,
2137 "slow cached Blossom blob batch write"
2138 );
2139 }
2140 return Ok(report);
2141 }
2142 Err(err) if !retried_after_cleanup && is_map_full_store_error(&err) => {
2143 let freed = self.relieve_cached_blob_write_pressure(candidate_bytes)?;
2144 if freed == 0 {
2145 return Err(anyhow::anyhow!(
2146 "Failed to store cached blob batch: {}",
2147 err
2148 ));
2149 }
2150 retried_after_cleanup = true;
2151 }
2152 Err(err) => {
2153 return Err(anyhow::anyhow!(
2154 "Failed to store cached blob batch: {}",
2155 err
2156 ));
2157 }
2158 }
2159 }
2160 }
2161
2162 pub fn put_cached_blobs(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize> {
2164 self.put_cached_blobs_report(items)
2165 .map(|report| report.inserted)
2166 }
2167
2168 pub fn get_blob(&self, hash: &[u8; 32]) -> Result<Option<Vec<u8>>> {
2170 let data = self
2171 .router
2172 .get_sync(hash)
2173 .map_err(|e| anyhow::anyhow!("Failed to get blob: {}", e))?;
2174 if data.is_some() {
2175 self.record_blob_accesses(std::iter::once(*hash));
2176 }
2177 Ok(data)
2178 }
2179
2180 pub fn get_blob_range(
2181 &self,
2182 hash: &[u8; 32],
2183 start: u64,
2184 end_inclusive: u64,
2185 ) -> Result<Option<Vec<u8>>> {
2186 let data = self
2187 .router
2188 .get_range_sync(hash, start, end_inclusive)
2189 .map_err(|e| anyhow::anyhow!("Failed to get blob range: {}", e))?;
2190 if data.is_some() {
2191 self.record_blob_accesses(std::iter::once(*hash));
2192 }
2193 Ok(data)
2194 }
2195
2196 pub fn blob_size(&self, hash: &[u8; 32]) -> Result<Option<u64>> {
2197 self.router
2198 .blob_size_sync(hash)
2199 .map_err(|e| anyhow::anyhow!("Failed to get blob size: {}", e))
2200 }
2201
2202 pub fn blob_exists(&self, hash: &[u8; 32]) -> Result<bool> {
2204 self.router
2205 .exists(hash)
2206 .map_err(|e| anyhow::anyhow!("Failed to check blob: {}", e))
2207 }
2208
2209 fn blob_owner_key(sha256: &[u8; 32], pubkey: &[u8; 32]) -> [u8; 64] {
2215 let mut key = [0u8; 64];
2216 key[..32].copy_from_slice(sha256);
2217 key[32..].copy_from_slice(pubkey);
2218 key
2219 }
2220
2221 fn pubkey_blob_key(pubkey: &[u8; 32], sha256: &[u8; 32]) -> [u8; 64] {
2222 let mut key = [0u8; 64];
2223 key[..32].copy_from_slice(pubkey);
2224 key[32..].copy_from_slice(sha256);
2225 key
2226 }
2227
2228 pub fn set_blob_owner(&self, sha256: &[u8; 32], pubkey: &[u8; 32]) -> Result<()> {
2231 let size = self
2232 .router
2233 .blob_size_sync(sha256)
2234 .map_err(|e| anyhow::anyhow!("Failed to get blob size: {}", e))?
2235 .unwrap_or(0);
2236 self.set_blob_owner_with_size(sha256, pubkey, size)
2237 }
2238
2239 fn set_blob_owner_with_size(
2240 &self,
2241 sha256: &[u8; 32],
2242 pubkey: &[u8; 32],
2243 size: u64,
2244 ) -> Result<()> {
2245 let key = Self::blob_owner_key(sha256, pubkey);
2246 let index_key = Self::pubkey_blob_key(pubkey, sha256);
2247 let mut wtxn = self.env.write_txn()?;
2248
2249 match self
2250 .blob_owners
2251 .put_with_flags(&mut wtxn, PutFlags::NO_OVERWRITE, &key[..], &())
2252 {
2253 Ok(()) => {}
2254 Err(HeedError::Mdb(MdbError::KeyExist)) => {
2255 wtxn.commit()?;
2256 return Ok(());
2257 }
2258 Err(error) => return Err(error.into()),
2259 }
2260
2261 let now = SystemTime::now()
2262 .duration_since(UNIX_EPOCH)
2263 .unwrap()
2264 .as_secs();
2265 let metadata = BlobMetadata {
2266 sha256: to_hex(sha256),
2267 size,
2268 mime_type: "application/octet-stream".to_string(),
2269 uploaded: now,
2270 };
2271 self.pubkey_blob_index
2272 .put(&mut wtxn, &index_key[..], &serde_json::to_vec(&metadata)?)?;
2273
2274 wtxn.commit()?;
2275 Ok(())
2276 }
2277
2278 pub fn is_blob_owner(&self, sha256: &[u8; 32], pubkey: &[u8; 32]) -> Result<bool> {
2280 let key = Self::blob_owner_key(sha256, pubkey);
2281 let rtxn = self.env.read_txn()?;
2282 Ok(self.blob_owners.get(&rtxn, &key[..])?.is_some())
2283 }
2284
2285 pub fn get_blob_owners(&self, sha256: &[u8; 32]) -> Result<Vec<[u8; 32]>> {
2287 let rtxn = self.env.read_txn()?;
2288
2289 let mut owners = Vec::new();
2290 for item in self.blob_owners.prefix_iter(&rtxn, &sha256[..])? {
2291 let (key, _) = item?;
2292 if key.len() == 64 {
2293 let mut pubkey = [0u8; 32];
2295 pubkey.copy_from_slice(&key[32..64]);
2296 owners.push(pubkey);
2297 }
2298 }
2299 Ok(owners)
2300 }
2301
2302 pub fn blob_has_owners(&self, sha256: &[u8; 32]) -> Result<bool> {
2304 let rtxn = self.env.read_txn()?;
2305
2306 for item in self.blob_owners.prefix_iter(&rtxn, &sha256[..])? {
2308 if item.is_ok() {
2309 return Ok(true);
2310 }
2311 }
2312 Ok(false)
2313 }
2314
2315 pub fn get_blob_owner(&self, sha256: &[u8; 32]) -> Result<Option<[u8; 32]>> {
2317 Ok(self.get_blob_owners(sha256)?.into_iter().next())
2318 }
2319
2320 pub fn delete_blossom_blob(&self, sha256: &[u8; 32], pubkey: &[u8; 32]) -> Result<bool> {
2324 let key = Self::blob_owner_key(sha256, pubkey);
2325 let mut wtxn = self.env.write_txn()?;
2326
2327 self.blob_owners.delete(&mut wtxn, &key[..])?;
2329 self.pubkey_blob_index
2330 .delete(&mut wtxn, &Self::pubkey_blob_key(pubkey, sha256)[..])?;
2331
2332 let sha256_hex = to_hex(sha256);
2334
2335 if let Some(blobs_bytes) = self.pubkey_blobs.get(&wtxn, pubkey)? {
2337 if let Ok(mut blobs) = serde_json::from_slice::<Vec<BlobMetadata>>(blobs_bytes) {
2338 blobs.retain(|b| b.sha256 != sha256_hex);
2339 let blobs_json = serde_json::to_vec(&blobs)?;
2340 self.pubkey_blobs.put(&mut wtxn, pubkey, &blobs_json)?;
2341 }
2342 }
2343
2344 let mut has_other_owners = false;
2346 for item in self.blob_owners.prefix_iter(&wtxn, &sha256[..])? {
2347 if item.is_ok() {
2348 has_other_owners = true;
2349 break;
2350 }
2351 }
2352
2353 if has_other_owners {
2354 wtxn.commit()?;
2355 tracing::debug!(
2356 "Removed {} from blob {} owners, other owners remain",
2357 &to_hex(pubkey)[..8],
2358 &sha256_hex[..8]
2359 );
2360 return Ok(false);
2361 }
2362
2363 tracing::info!(
2365 "All owners removed from blob {}, deleting",
2366 &sha256_hex[..8]
2367 );
2368
2369 let _ = self.router.delete_sync(sha256);
2371
2372 wtxn.commit()?;
2373 Ok(true)
2374 }
2375
2376 pub fn list_blobs_by_pubkey(
2378 &self,
2379 pubkey: &[u8; 32],
2380 ) -> Result<Vec<crate::server::blossom::BlobDescriptor>> {
2381 let rtxn = self.env.read_txn()?;
2382
2383 let mut blobs: Vec<BlobMetadata> = self
2384 .pubkey_blobs
2385 .get(&rtxn, pubkey)?
2386 .and_then(|b| serde_json::from_slice(b).ok())
2387 .unwrap_or_default();
2388 let mut seen: HashSet<String> = blobs.iter().map(|blob| blob.sha256.clone()).collect();
2389
2390 for item in self.pubkey_blob_index.prefix_iter(&rtxn, pubkey)? {
2391 let (_, metadata_bytes) = item?;
2392 let metadata: BlobMetadata = match serde_json::from_slice(metadata_bytes) {
2393 Ok(metadata) => metadata,
2394 Err(_) => continue,
2395 };
2396 if seen.insert(metadata.sha256.clone()) {
2397 blobs.push(metadata);
2398 }
2399 }
2400
2401 Ok(blobs
2402 .into_iter()
2403 .map(|b| crate::server::blossom::BlobDescriptor {
2404 url: format!("/{}", b.sha256),
2405 sha256: b.sha256,
2406 size: b.size,
2407 mime_type: b.mime_type,
2408 uploaded: b.uploaded,
2409 })
2410 .collect())
2411 }
2412
2413 pub fn get_chunk(&self, hash: &[u8; 32]) -> Result<Option<Vec<u8>>> {
2415 let data = self
2416 .router
2417 .get_sync(hash)
2418 .map_err(|e| anyhow::anyhow!("Failed to get chunk: {}", e))?;
2419 if data.is_some() {
2420 self.record_blob_accesses(std::iter::once(*hash));
2421 }
2422 Ok(data)
2423 }
2424
2425 pub fn get_file(&self, hash: &[u8; 32]) -> Result<Option<Vec<u8>>> {
2428 let (tree, access_store) = self.access_tracking_tree();
2429
2430 let result = sync_block_on(async {
2431 tree.read_file(hash)
2432 .await
2433 .map_err(|e| anyhow::anyhow!("Failed to read file: {}", e))
2434 })?;
2435 if result.is_some() {
2436 self.record_blob_accesses(access_store.take_accessed_hashes());
2437 }
2438 Ok(result)
2439 }
2440
2441 pub fn get_file_by_cid(&self, cid: &Cid) -> Result<Option<Vec<u8>>> {
2444 let (tree, access_store) = self.access_tracking_tree();
2445
2446 let result = sync_block_on(async {
2447 tree.get(cid, None)
2448 .await
2449 .map_err(|e| anyhow::anyhow!("Failed to read file: {}", e))
2450 })?;
2451 if result.is_some() {
2452 self.record_blob_accesses(access_store.take_accessed_hashes());
2453 }
2454 Ok(result)
2455 }
2456
2457 fn ensure_cid_exists(&self, cid: &Cid) -> Result<()> {
2458 let exists = self
2459 .router
2460 .exists(&cid.hash)
2461 .map_err(|e| anyhow::anyhow!("Failed to check cid existence: {}", e))?;
2462 if !exists {
2463 anyhow::bail!("CID not found: {}", to_hex(&cid.hash));
2464 }
2465 Ok(())
2466 }
2467
2468 pub fn write_file_by_cid_to_writer<W: Write>(&self, cid: &Cid, writer: &mut W) -> Result<u64> {
2470 self.ensure_cid_exists(cid)?;
2471
2472 let (tree, access_store) = self.access_tracking_tree();
2473 let mut total_bytes = 0u64;
2474 let mut streamed_any_chunk = false;
2475
2476 sync_block_on(async {
2477 let mut stream = tree.get_stream(cid);
2478 while let Some(chunk) = stream.next().await {
2479 streamed_any_chunk = true;
2480 let chunk =
2481 chunk.map_err(|e| anyhow::anyhow!("Failed to stream file chunk: {}", e))?;
2482 writer
2483 .write_all(&chunk)
2484 .map_err(|e| anyhow::anyhow!("Failed to write file chunk: {}", e))?;
2485 total_bytes += chunk.len() as u64;
2486 }
2487 Ok::<(), anyhow::Error>(())
2488 })?;
2489
2490 if !streamed_any_chunk {
2491 anyhow::bail!("CID not found: {}", to_hex(&cid.hash));
2492 }
2493 self.record_blob_accesses(access_store.take_accessed_hashes());
2494
2495 writer
2496 .flush()
2497 .map_err(|e| anyhow::anyhow!("Failed to flush output: {}", e))?;
2498 Ok(total_bytes)
2499 }
2500
2501 pub fn write_file_by_cid<P: AsRef<Path>>(&self, cid: &Cid, output_path: P) -> Result<u64> {
2503 self.ensure_cid_exists(cid)?;
2504
2505 let output_path = output_path.as_ref();
2506 if let Some(parent) = output_path.parent() {
2507 if !parent.as_os_str().is_empty() {
2508 std::fs::create_dir_all(parent).with_context(|| {
2509 format!("Failed to create output directory {}", parent.display())
2510 })?;
2511 }
2512 }
2513
2514 let mut file = std::fs::File::create(output_path)
2515 .with_context(|| format!("Failed to create output file {}", output_path.display()))?;
2516 self.write_file_by_cid_to_writer(cid, &mut file)
2517 }
2518
2519 pub fn write_file<P: AsRef<Path>>(&self, hash: &[u8; 32], output_path: P) -> Result<u64> {
2521 self.write_file_by_cid(&Cid::public(*hash), output_path)
2522 }
2523
2524 pub fn resolve_path(&self, cid: &Cid, path: &str) -> Result<Option<Cid>> {
2526 let (tree, access_store) = self.access_tracking_tree();
2527
2528 let result = sync_block_on(async {
2529 tree.resolve_path(cid, path)
2530 .await
2531 .map_err(|e| anyhow::anyhow!("Failed to resolve path: {}", e))
2532 })?;
2533 if result.is_some() {
2534 self.record_blob_accesses(access_store.take_accessed_hashes());
2535 }
2536 Ok(result)
2537 }
2538
2539 pub fn get_file_chunk_metadata(
2541 &self,
2542 hash: &[u8; 32],
2543 ) -> Result<Option<Arc<FileChunkMetadata>>> {
2544 if let Ok(mut cache) = self.file_metadata_cache.lock() {
2545 if let Some(metadata) = cache.get(hash).cloned() {
2546 self.record_blob_accesses(std::iter::once(*hash));
2547 return Ok(Some(metadata));
2548 }
2549 }
2550
2551 let access_store = AccessRecordingStore::new(self.store_arc());
2552 let tree = HashTree::new(HashTreeConfig::new(Arc::new(access_store.clone())).public());
2553
2554 let metadata: Result<Option<FileChunkMetadata>> = sync_block_on(async {
2555 let exists = access_store
2558 .has(hash)
2559 .await
2560 .map_err(|e| anyhow::anyhow!("Failed to check existence: {}", e))?;
2561
2562 if !exists {
2563 return Ok(None);
2564 }
2565
2566 let total_size = tree
2568 .get_size(hash)
2569 .await
2570 .map_err(|e| anyhow::anyhow!("Failed to get size: {}", e))?;
2571
2572 let is_tree_node = tree
2574 .is_tree(hash)
2575 .await
2576 .map_err(|e| anyhow::anyhow!("Failed to check tree: {}", e))?;
2577
2578 if !is_tree_node {
2579 return Ok(Some(FileChunkMetadata::single_blob(total_size)));
2581 }
2582
2583 let node = match tree
2585 .get_tree_node(hash)
2586 .await
2587 .map_err(|e| anyhow::anyhow!("Failed to get tree node: {}", e))?
2588 {
2589 Some(n) => n,
2590 None => return Ok(None),
2591 };
2592
2593 let is_directory = tree
2595 .is_directory(hash)
2596 .await
2597 .map_err(|e| anyhow::anyhow!("Failed to check directory: {}", e))?;
2598
2599 if is_directory {
2600 return Ok(None); }
2602
2603 let chunk_hashes: Vec<Hash> = node.links.iter().map(|l| l.hash).collect();
2605 let chunk_sizes: Vec<u64> = node.links.iter().map(|l| l.size).collect();
2606
2607 Ok(Some(FileChunkMetadata::new(
2608 total_size,
2609 chunk_hashes,
2610 chunk_sizes,
2611 )))
2612 });
2613 let metadata = metadata?;
2614 if metadata.is_some() {
2615 self.record_blob_accesses(access_store.take_accessed_hashes());
2616 }
2617 let Some(metadata) = metadata else {
2618 return Ok(None);
2619 };
2620 let metadata = Arc::new(metadata);
2621 if let Ok(mut cache) = self.file_metadata_cache.lock() {
2622 cache.put(*hash, Arc::clone(&metadata));
2623 }
2624 Ok(Some(metadata))
2625 }
2626
2627 pub fn get_file_range(
2629 &self,
2630 hash: &[u8; 32],
2631 start: u64,
2632 end: Option<u64>,
2633 ) -> Result<Option<(Vec<u8>, u64)>> {
2634 let metadata = match self.get_file_chunk_metadata(hash)? {
2635 Some(m) => m,
2636 None => return Ok(None),
2637 };
2638
2639 if metadata.total_size == 0 {
2640 return Ok(Some((Vec::new(), 0)));
2641 }
2642
2643 if start >= metadata.total_size {
2644 return Ok(None);
2645 }
2646
2647 let end = end
2648 .unwrap_or(metadata.total_size - 1)
2649 .min(metadata.total_size - 1);
2650
2651 if !metadata.is_chunked {
2653 let range_content = match self.get_blob_range(hash, start, end)? {
2654 Some(content) => content,
2655 None => return Ok(None),
2656 };
2657 return Ok(Some((range_content, metadata.total_size)));
2658 }
2659
2660 let mut result = Vec::new();
2662 let (start_idx, mut current_offset) = metadata.chunk_start_for_range(start);
2663
2664 for (i, chunk_hash) in metadata.chunk_hashes.iter().enumerate().skip(start_idx) {
2665 let chunk_size = metadata.chunk_sizes[i];
2666 let chunk_end = current_offset + chunk_size - 1;
2667
2668 if chunk_end >= start && current_offset <= end {
2670 let chunk_read_start = start.saturating_sub(current_offset);
2671
2672 let chunk_read_end = if chunk_end <= end {
2673 chunk_size - 1
2674 } else {
2675 end - current_offset
2676 };
2677
2678 let chunk_content =
2679 match self.get_blob_range(chunk_hash, chunk_read_start, chunk_read_end)? {
2680 Some(content) => content,
2681 None => {
2682 return Err(anyhow::anyhow!("Chunk {} not found", to_hex(chunk_hash)));
2683 }
2684 };
2685
2686 let expected_len = chunk_read_end.saturating_sub(chunk_read_start) + 1;
2687 if chunk_content.len() as u64 != expected_len {
2688 return Err(anyhow::anyhow!(
2689 "Chunk {} range returned {} bytes, expected {}",
2690 to_hex(chunk_hash),
2691 chunk_content.len(),
2692 expected_len
2693 ));
2694 }
2695
2696 result.extend_from_slice(&chunk_content);
2697 }
2698
2699 current_offset += chunk_size;
2700
2701 if current_offset > end {
2702 break;
2703 }
2704 }
2705
2706 Ok(Some((result, metadata.total_size)))
2707 }
2708
2709 pub fn stream_file_range_chunks_owned(
2711 self: Arc<Self>,
2712 hash: &[u8; 32],
2713 start: u64,
2714 end: u64,
2715 ) -> Result<Option<FileRangeChunksOwned>> {
2716 let metadata = match self.get_file_chunk_metadata(hash)? {
2717 Some(m) => m,
2718 None => return Ok(None),
2719 };
2720
2721 if metadata.total_size == 0 || start >= metadata.total_size {
2722 return Ok(None);
2723 }
2724
2725 let end = end.min(metadata.total_size - 1);
2726
2727 let (current_chunk_idx, current_offset) = metadata.chunk_start_for_range(start);
2728
2729 Ok(Some(FileRangeChunksOwned {
2730 store: self,
2731 metadata,
2732 start,
2733 end,
2734 current_chunk_idx,
2735 current_offset,
2736 }))
2737 }
2738
2739 pub fn get_directory_listing(&self, hash: &[u8; 32]) -> Result<Option<DirectoryListing>> {
2741 let (tree, access_store) = self.access_tracking_tree();
2742
2743 let listing: Result<Option<DirectoryListing>> = sync_block_on(async {
2744 let is_dir = tree
2746 .is_directory(hash)
2747 .await
2748 .map_err(|e| anyhow::anyhow!("Failed to check directory: {}", e))?;
2749
2750 if !is_dir {
2751 return Ok(None);
2752 }
2753
2754 let cid = hashtree_core::Cid::public(*hash);
2756 let tree_entries = tree
2757 .list_directory(&cid)
2758 .await
2759 .map_err(|e| anyhow::anyhow!("Failed to list directory: {}", e))?;
2760
2761 let entries: Vec<DirEntry> = tree_entries
2762 .into_iter()
2763 .map(|e| DirEntry {
2764 name: e.name,
2765 cid: to_hex(&e.hash),
2766 is_directory: e.link_type.is_tree(),
2767 size: e.size,
2768 })
2769 .collect();
2770
2771 Ok(Some(DirectoryListing {
2772 dir_name: String::new(),
2773 entries,
2774 }))
2775 });
2776 let listing = listing?;
2777 if listing.is_some() {
2778 self.record_blob_accesses(access_store.take_accessed_hashes());
2779 }
2780 Ok(listing)
2781 }
2782
2783 pub fn get_directory_listing_by_cid(&self, cid: &Cid) -> Result<Option<DirectoryListing>> {
2785 let (tree, access_store) = self.access_tracking_tree();
2786 let cid = cid.clone();
2787
2788 let listing: Result<Option<DirectoryListing>> = sync_block_on(async {
2789 let is_dir = tree
2790 .is_dir(&cid)
2791 .await
2792 .map_err(|e| anyhow::anyhow!("Failed to check directory: {}", e))?;
2793
2794 if !is_dir {
2795 return Ok(None);
2796 }
2797
2798 let tree_entries = tree
2799 .list_directory(&cid)
2800 .await
2801 .map_err(|e| anyhow::anyhow!("Failed to list directory: {}", e))?;
2802
2803 let entries: Vec<DirEntry> = tree_entries
2804 .into_iter()
2805 .map(|e| DirEntry {
2806 name: e.name,
2807 cid: Cid {
2808 hash: e.hash,
2809 key: e.key,
2810 }
2811 .to_string(),
2812 is_directory: e.link_type.is_tree(),
2813 size: e.size,
2814 })
2815 .collect();
2816
2817 Ok(Some(DirectoryListing {
2818 dir_name: String::new(),
2819 entries,
2820 }))
2821 });
2822 let listing = listing?;
2823 if listing.is_some() {
2824 self.record_blob_accesses(access_store.take_accessed_hashes());
2825 }
2826 Ok(listing)
2827 }
2828
2829 pub fn add_pinned_ref(&self, key: &str) -> Result<()> {
2833 let mut wtxn = self.env.write_txn()?;
2834 self.pinned_refs.put(&mut wtxn, key, &())?;
2835 wtxn.commit()?;
2836 Ok(())
2837 }
2838
2839 pub fn remove_pinned_ref(&self, key: &str) -> Result<bool> {
2841 let mut wtxn = self.env.write_txn()?;
2842 let removed = self.pinned_refs.delete(&mut wtxn, key)?;
2843 wtxn.commit()?;
2844 Ok(removed)
2845 }
2846
2847 pub fn list_pinned_refs(&self) -> Result<Vec<String>> {
2849 let rtxn = self.env.read_txn()?;
2850 let mut refs = Vec::new();
2851
2852 for item in self.pinned_refs.iter(&rtxn)? {
2853 let (key, _) = item?;
2854 refs.push(key.to_string());
2855 }
2856
2857 refs.sort();
2858 Ok(refs)
2859 }
2860
2861 pub fn add_tracked_author(&self, npub: &str) -> Result<bool> {
2863 let mut wtxn = self.env.write_txn()?;
2864 let inserted = self.tracked_authors.get(&wtxn, npub)?.is_none();
2865 self.tracked_authors.put(&mut wtxn, npub, &())?;
2866 wtxn.commit()?;
2867 Ok(inserted)
2868 }
2869
2870 pub fn remove_tracked_author(&self, npub: &str) -> Result<bool> {
2872 let mut wtxn = self.env.write_txn()?;
2873 let removed = self.tracked_authors.delete(&mut wtxn, npub)?;
2874 wtxn.commit()?;
2875 Ok(removed)
2876 }
2877
2878 pub fn list_tracked_authors(&self) -> Result<Vec<String>> {
2880 let rtxn = self.env.read_txn()?;
2881 let mut authors = Vec::new();
2882
2883 for item in self.tracked_authors.iter(&rtxn)? {
2884 let (npub, _) = item?;
2885 authors.push(npub.to_string());
2886 }
2887
2888 authors.sort();
2889 Ok(authors)
2890 }
2891
2892 pub fn get_cached_root(&self, pubkey_hex: &str, tree_name: &str) -> Result<Option<CachedRoot>> {
2894 let key = format!("{}/{}", pubkey_hex, tree_name);
2895 let rtxn = self.env.read_txn()?;
2896 if let Some(bytes) = self.cached_roots.get(&rtxn, &key)? {
2897 let root: CachedRoot = rmp_serde::from_slice(bytes)
2898 .map_err(|e| anyhow::anyhow!("Failed to deserialize CachedRoot: {}", e))?;
2899 Ok(Some(root))
2900 } else {
2901 Ok(None)
2902 }
2903 }
2904
2905 pub fn set_cached_root(
2907 &self,
2908 pubkey_hex: &str,
2909 tree_name: &str,
2910 hash: &str,
2911 key: Option<&str>,
2912 visibility: &str,
2913 updated_at: u64,
2914 ) -> Result<()> {
2915 let db_key = format!("{}/{}", pubkey_hex, tree_name);
2916 let root = CachedRoot {
2917 hash: hash.to_string(),
2918 key: key.map(|k| k.to_string()),
2919 updated_at,
2920 visibility: visibility.to_string(),
2921 };
2922 let bytes = rmp_serde::to_vec(&root)
2923 .map_err(|e| anyhow::anyhow!("Failed to serialize CachedRoot: {}", e))?;
2924 let mut wtxn = self.env.write_txn()?;
2925 self.cached_roots.put(&mut wtxn, &db_key, &bytes)?;
2926 wtxn.commit()?;
2927 Ok(())
2928 }
2929
2930 pub fn list_cached_roots(&self, pubkey_hex: &str) -> Result<Vec<(String, CachedRoot)>> {
2932 let prefix = format!("{}/", pubkey_hex);
2933 let rtxn = self.env.read_txn()?;
2934 let mut results = Vec::new();
2935
2936 for item in self.cached_roots.iter(&rtxn)? {
2937 let (key, bytes) = item?;
2938 if key.starts_with(&prefix) {
2939 let tree_name = key.strip_prefix(&prefix).unwrap_or(key);
2940 let root: CachedRoot = rmp_serde::from_slice(bytes)
2941 .map_err(|e| anyhow::anyhow!("Failed to deserialize CachedRoot: {}", e))?;
2942 results.push((tree_name.to_string(), root));
2943 }
2944 }
2945
2946 Ok(results)
2947 }
2948
2949 pub fn delete_cached_root(&self, pubkey_hex: &str, tree_name: &str) -> Result<bool> {
2951 let key = format!("{}/{}", pubkey_hex, tree_name);
2952 let mut wtxn = self.env.write_txn()?;
2953 let deleted = self.cached_roots.delete(&mut wtxn, &key)?;
2954 wtxn.commit()?;
2955 Ok(deleted)
2956 }
2957}
2958
2959fn is_map_full_store_error(err: &StoreError) -> bool {
2960 let message = err.to_string();
2961 message.contains("MDB_MAP_FULL") || message.contains("MapFull")
2962}
2963
2964#[derive(Debug, Clone)]
2965pub struct FileChunkMetadata {
2966 pub total_size: u64,
2967 pub chunk_hashes: Vec<Hash>,
2968 pub chunk_sizes: Vec<u64>,
2969 pub is_chunked: bool,
2970 uniform_chunk_size: Option<u64>,
2971}
2972
2973impl FileChunkMetadata {
2974 fn new(total_size: u64, chunk_hashes: Vec<Hash>, chunk_sizes: Vec<u64>) -> Self {
2975 let is_chunked = !chunk_hashes.is_empty();
2976 let uniform_chunk_size = uniform_chunk_size(&chunk_sizes);
2977 Self {
2978 total_size,
2979 chunk_hashes,
2980 chunk_sizes,
2981 is_chunked,
2982 uniform_chunk_size,
2983 }
2984 }
2985
2986 fn single_blob(total_size: u64) -> Self {
2987 Self {
2988 total_size,
2989 chunk_hashes: Vec::new(),
2990 chunk_sizes: Vec::new(),
2991 is_chunked: false,
2992 uniform_chunk_size: None,
2993 }
2994 }
2995
2996 fn chunk_start_for_range(&self, start: u64) -> (usize, u64) {
2997 if !self.is_chunked || self.chunk_sizes.is_empty() {
2998 return (0, 0);
2999 }
3000
3001 if let Some(chunk_size) = self.uniform_chunk_size {
3002 let index = start
3003 .checked_div(chunk_size)
3004 .unwrap_or(0)
3005 .min(self.chunk_sizes.len().saturating_sub(1) as u64)
3006 as usize;
3007 return (index, chunk_size.saturating_mul(index as u64));
3008 }
3009
3010 let mut offset = 0u64;
3011 for (index, chunk_size) in self.chunk_sizes.iter().copied().enumerate() {
3012 let next_offset = offset.saturating_add(chunk_size);
3013 if start < next_offset {
3014 return (index, offset);
3015 }
3016 offset = next_offset;
3017 }
3018
3019 (self.chunk_sizes.len(), offset)
3020 }
3021}
3022
3023fn uniform_chunk_size(chunk_sizes: &[u64]) -> Option<u64> {
3024 let (&first, rest) = chunk_sizes.split_first()?;
3025 if first == 0 {
3026 return None;
3027 }
3028 if rest.is_empty() {
3029 return Some(first);
3030 }
3031 let (last, prefix) = rest.split_last()?;
3032 if prefix.iter().any(|size| *size != first) || *last > first {
3033 return None;
3034 }
3035 Some(first)
3036}
3037
3038pub struct FileRangeChunksOwned {
3040 store: Arc<HashtreeStore>,
3041 metadata: Arc<FileChunkMetadata>,
3042 start: u64,
3043 end: u64,
3044 current_chunk_idx: usize,
3045 current_offset: u64,
3046}
3047
3048impl Iterator for FileRangeChunksOwned {
3049 type Item = Result<Vec<u8>>;
3050
3051 fn next(&mut self) -> Option<Self::Item> {
3052 if !self.metadata.is_chunked || self.current_chunk_idx >= self.metadata.chunk_hashes.len() {
3053 return None;
3054 }
3055
3056 if self.current_offset > self.end {
3057 return None;
3058 }
3059
3060 let chunk_hash = &self.metadata.chunk_hashes[self.current_chunk_idx];
3061 let chunk_size = self.metadata.chunk_sizes[self.current_chunk_idx];
3062 let chunk_end = self.current_offset + chunk_size - 1;
3063
3064 self.current_chunk_idx += 1;
3065
3066 if chunk_end < self.start || self.current_offset > self.end {
3067 self.current_offset += chunk_size;
3068 return self.next();
3069 }
3070
3071 let chunk_read_start = self.start.saturating_sub(self.current_offset);
3072
3073 let chunk_read_end = if chunk_end <= self.end {
3074 chunk_size - 1
3075 } else {
3076 self.end - self.current_offset
3077 };
3078
3079 let chunk_content =
3080 match self
3081 .store
3082 .get_blob_range(chunk_hash, chunk_read_start, chunk_read_end)
3083 {
3084 Ok(Some(content)) => content,
3085 Ok(None) => {
3086 return Some(Err(anyhow::anyhow!(
3087 "Chunk {} not found",
3088 to_hex(chunk_hash)
3089 )));
3090 }
3091 Err(e) => {
3092 return Some(Err(e));
3093 }
3094 };
3095
3096 let expected_len = chunk_read_end.saturating_sub(chunk_read_start) + 1;
3097 if chunk_content.len() as u64 != expected_len {
3098 return Some(Err(anyhow::anyhow!(
3099 "Chunk {} range returned {} bytes, expected {}",
3100 to_hex(chunk_hash),
3101 chunk_content.len(),
3102 expected_len
3103 )));
3104 }
3105
3106 let result = chunk_content;
3107 self.current_offset += chunk_size;
3108
3109 Some(Ok(result))
3110 }
3111}
3112
3113#[derive(Debug)]
3114pub struct GcStats {
3115 pub deleted_dags: usize,
3116 pub freed_bytes: u64,
3117}
3118
3119#[derive(Debug, Clone)]
3120pub struct DirEntry {
3121 pub name: String,
3122 pub cid: String,
3123 pub is_directory: bool,
3124 pub size: u64,
3125}
3126
3127#[derive(Debug, Clone)]
3128pub struct DirectoryListing {
3129 pub dir_name: String,
3130 pub entries: Vec<DirEntry>,
3131}
3132
3133#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
3135pub struct BlobMetadata {
3136 pub sha256: String,
3137 pub size: u64,
3138 pub mime_type: String,
3139 pub uploaded: u64,
3140}
3141
3142impl crate::webrtc::ContentStore for HashtreeStore {
3144 fn get(&self, hash_hex: &str) -> Result<Option<Vec<u8>>> {
3145 let hash = from_hex(hash_hex).map_err(|e| anyhow::anyhow!("Invalid hash: {}", e))?;
3146 self.get_chunk(&hash)
3147 }
3148}
3149
3150#[cfg(test)]
3151mod tests {
3152 use super::*;
3153 #[cfg(feature = "lmdb")]
3154 use std::ffi::OsString;
3155 #[cfg(feature = "lmdb")]
3156 use std::path::Path;
3157 #[cfg(feature = "lmdb")]
3158 use std::sync::Mutex;
3159 #[cfg(feature = "lmdb")]
3160 use tempfile::TempDir;
3161
3162 #[cfg(feature = "lmdb")]
3163 static HOT_BLOB_ENV_LOCK: Mutex<()> = Mutex::new(());
3164
3165 #[cfg(feature = "lmdb")]
3166 struct EnvGuard {
3167 key: &'static str,
3168 previous: Option<OsString>,
3169 }
3170
3171 #[cfg(feature = "lmdb")]
3172 impl EnvGuard {
3173 fn set(key: &'static str, value: &Path) -> Self {
3174 let previous = std::env::var_os(key);
3175 std::env::set_var(key, value);
3176 Self { key, previous }
3177 }
3178
3179 fn set_value(key: &'static str, value: &str) -> Self {
3180 let previous = std::env::var_os(key);
3181 std::env::set_var(key, value);
3182 Self { key, previous }
3183 }
3184 }
3185
3186 #[cfg(feature = "lmdb")]
3187 impl Drop for EnvGuard {
3188 fn drop(&mut self) {
3189 if let Some(previous) = &self.previous {
3190 std::env::set_var(self.key, previous);
3191 } else {
3192 std::env::remove_var(self.key);
3193 }
3194 }
3195 }
3196
3197 #[cfg(feature = "lmdb")]
3198 fn count_files_under(path: &Path) -> Result<usize> {
3199 if !path.exists() {
3200 return Ok(0);
3201 }
3202
3203 let mut count = 0usize;
3204 for entry in walkdir::WalkDir::new(path) {
3205 let entry = entry?;
3206 if entry.file_type().is_file() {
3207 count = count.saturating_add(1);
3208 }
3209 }
3210 Ok(count)
3211 }
3212
3213 #[test]
3214 fn blob_access_update_gate_deduplicates_and_throttles() {
3215 let gate = BlobAccessUpdateGate::default();
3216 let first = sha256(b"first");
3217 let second = sha256(b"second");
3218
3219 assert_eq!(
3220 gate.due_hashes([first, first, second], 10),
3221 vec![first, second]
3222 );
3223 assert!(gate.due_hashes([first, second], 11).is_empty());
3224 assert_eq!(
3225 gate.due_hashes([second, first], 10 + ACCESS_UPDATE_INTERVAL_SECS),
3226 vec![second, first]
3227 );
3228 }
3229
3230 #[cfg(feature = "lmdb")]
3231 #[test]
3232 fn file_range_reads_reuse_metadata_and_seek_to_uniform_chunk() -> Result<()> {
3233 let temp = TempDir::new()?;
3234 let store = Arc::new(HashtreeStore::with_options_and_backend(
3235 temp.path(),
3236 None,
3237 LMDB_BLOB_MIN_MAP_SIZE_BYTES,
3238 true,
3239 &StorageBackend::Fs,
3240 )?);
3241 let tree = HashTree::new(
3242 HashTreeConfig::new(store.store_arc())
3243 .with_chunk_size(4)
3244 .public(),
3245 );
3246 let data = (0u8..20).collect::<Vec<_>>();
3247 let (cid, _) = sync_block_on(tree.put_file(&data))?;
3248
3249 let first = store.get_file_chunk_metadata(&cid.hash)?.unwrap();
3250 let second = store.get_file_chunk_metadata(&cid.hash)?.unwrap();
3251 assert!(
3252 Arc::ptr_eq(&first, &second),
3253 "hot file metadata should be returned from the in-process cache"
3254 );
3255 assert_eq!(first.uniform_chunk_size, Some(4));
3256 assert_eq!(first.chunk_start_for_range(14), (3, 12));
3257
3258 let mut chunks = Arc::clone(&store)
3259 .stream_file_range_chunks_owned(&cid.hash, 14, 17)?
3260 .unwrap();
3261 assert_eq!(chunks.current_chunk_idx, 3);
3262 assert_eq!(chunks.current_offset, 12);
3263 assert_eq!(chunks.next().unwrap()?, vec![14, 15]);
3264 assert_eq!(chunks.next().unwrap()?, vec![16, 17]);
3265 assert!(chunks.next().is_none());
3266
3267 let (range, total_size) = store.get_file_range(&cid.hash, 14, Some(17))?.unwrap();
3268 assert_eq!(total_size, data.len() as u64);
3269 assert_eq!(range, vec![14, 15, 16, 17]);
3270
3271 Ok(())
3272 }
3273
3274 #[cfg(feature = "lmdb")]
3275 #[test]
3276 fn hashtree_store_expands_blob_lmdb_map_size_to_storage_budget() -> Result<()> {
3277 let temp = TempDir::new()?;
3278 let requested = LMDB_BLOB_MIN_MAP_SIZE_BYTES + 64 * 1024 * 1024;
3279 let store = HashtreeStore::with_options_and_backend(
3280 temp.path(),
3281 None,
3282 requested,
3283 true,
3284 &StorageBackend::Lmdb,
3285 )?;
3286
3287 let map_size = match store.router.local.as_ref() {
3288 LocalStore::Lmdb(local) => local.map_size_bytes() as u64,
3289 LocalStore::TieredLmdb { primary, .. } => primary.map_size_bytes() as u64,
3290 LocalStore::Fs(_) => panic!("expected LMDB local store"),
3291 };
3292
3293 assert!(
3294 map_size >= requested,
3295 "expected blob LMDB map to grow to at least {requested} bytes, got {map_size}"
3296 );
3297
3298 drop(store);
3299 Ok(())
3300 }
3301
3302 #[cfg(feature = "lmdb")]
3303 #[test]
3304 fn hashtree_store_expands_metadata_lmdb_map_size_to_storage_budget() -> Result<()> {
3305 let temp = TempDir::new()?;
3306 let storage_budget = 256 * 1024 * 1024 * 1024u64;
3307 let expected = lmdb_metadata_map_size_for_storage_budget(storage_budget);
3308 let store = HashtreeStore::with_options_and_backend(
3309 temp.path(),
3310 None,
3311 storage_budget,
3312 true,
3313 &StorageBackend::Lmdb,
3314 )?;
3315
3316 let map_size = store.env.info().map_size as u64;
3317 assert!(
3318 map_size >= expected,
3319 "expected metadata LMDB map to grow to at least {expected} bytes, got {map_size}"
3320 );
3321
3322 drop(store);
3323 Ok(())
3324 }
3325
3326 #[cfg(feature = "lmdb")]
3327 #[test]
3328 fn embedded_store_uses_filesystem_blobs_and_no_lmdb_lock() -> Result<()> {
3329 let temp = TempDir::new()?;
3330 let store =
3331 HashtreeStore::with_embedded_options(temp.path(), None, LMDB_BLOB_MIN_MAP_SIZE_BYTES)?;
3332
3333 assert_eq!(store.router.local_store().backend(), StorageBackend::Fs);
3334 let flags = store.env.flags()?.unwrap_or(EnvFlags::empty());
3335 assert!(flags.contains(EnvFlags::NO_LOCK));
3336
3337 drop(store);
3338 Ok(())
3339 }
3340
3341 #[cfg(feature = "lmdb")]
3342 #[test]
3343 fn lmdb_map_size_for_existing_env_keeps_matching_requested_size() -> Result<()> {
3344 let temp = TempDir::new()?;
3345 let requested = LMDB_METADATA_MIN_MAP_SIZE_BYTES;
3346 std::fs::File::create(temp.path().join("data.mdb"))?.set_len(requested)?;
3347
3348 let map_size = lmdb_map_size_for_existing_env(temp.path(), requested)? as u64;
3349
3350 assert_eq!(map_size, align_lmdb_map_size(requested));
3351 Ok(())
3352 }
3353
3354 #[cfg(feature = "lmdb")]
3355 #[test]
3356 fn lmdb_map_size_for_existing_env_adds_headroom_when_existing_is_larger() -> Result<()> {
3357 let temp = TempDir::new()?;
3358 let requested = LMDB_METADATA_MIN_MAP_SIZE_BYTES;
3359 let existing = requested + 4096;
3360 std::fs::File::create(temp.path().join("data.mdb"))?.set_len(existing)?;
3361
3362 let map_size = lmdb_map_size_for_existing_env(temp.path(), requested)? as u64;
3363 let expected = align_lmdb_map_size(existing + LMDB_METADATA_REOPEN_HEADROOM_BYTES);
3364
3365 assert_eq!(map_size, expected);
3366 Ok(())
3367 }
3368
3369 #[cfg(feature = "lmdb")]
3370 #[test]
3371 fn local_store_can_override_lmdb_map_size() -> Result<()> {
3372 let temp = TempDir::new()?;
3373 let requested = 512 * 1024 * 1024u64;
3374 let store = LocalStore::new_with_lmdb_map_size(
3375 temp.path().join("lmdb-blobs"),
3376 &StorageBackend::Lmdb,
3377 Some(requested),
3378 )?;
3379
3380 let map_size = match store {
3381 LocalStore::Lmdb(local) => local.map_size_bytes() as u64,
3382 LocalStore::TieredLmdb { primary, .. } => primary.map_size_bytes() as u64,
3383 LocalStore::Fs(_) => panic!("expected LMDB local store"),
3384 };
3385
3386 assert!(
3387 map_size >= requested,
3388 "expected LMDB map to grow to at least {requested} bytes, got {map_size}"
3389 );
3390
3391 Ok(())
3392 }
3393
3394 #[cfg(feature = "lmdb")]
3395 #[test]
3396 fn local_add_reopen_options_leave_ordinary_writes_inline() -> Result<()> {
3397 let temp = TempDir::new()?;
3398 let store_path = temp.path().join("blobs");
3399 let external_path = temp.path().join(LOCAL_ADD_EXTERNAL_BLOB_DIR_NAME);
3400 let store = LmdbBlobStore::with_external_blob_options(
3401 &store_path,
3402 Some(local_add_external_blob_reopen_options(&store_path)),
3403 )?;
3404 let data = vec![7; 192 * 1024];
3405 let hash = sha256(&data);
3406
3407 assert!(store.put_sync(hash, &data)?);
3408 assert_eq!(store.get_sync(&hash)?, Some(data));
3409 assert!(!external_path.exists());
3410 Ok(())
3411 }
3412
3413 #[cfg(feature = "lmdb")]
3414 #[test]
3415 fn lmdb_hot_blob_legacy_guard_scopes_tiered_store() -> Result<()> {
3416 let _lock = HOT_BLOB_ENV_LOCK.lock().unwrap();
3417 let temp = TempDir::new()?;
3418 let legacy = temp.path().join("legacy-blobs");
3419 let unrelated = temp.path().join("unrelated-blobs");
3420 let hot = temp.path().join("hot-blobs");
3421 let _hot_guard = EnvGuard::set(LMDB_HOT_BLOB_DIR_ENV, &hot);
3422 let _legacy_guard = EnvGuard::set(LMDB_HOT_BLOB_LEGACY_DIR_ENV, &legacy);
3423
3424 let store = LocalStore::new_with_lmdb_map_size(
3425 &legacy,
3426 &StorageBackend::Lmdb,
3427 Some(128 * 1024 * 1024),
3428 )?;
3429 assert!(matches!(store, LocalStore::TieredLmdb { .. }));
3430
3431 let store = LocalStore::new_with_lmdb_map_size(
3432 &unrelated,
3433 &StorageBackend::Lmdb,
3434 Some(128 * 1024 * 1024),
3435 )?;
3436 assert!(matches!(store, LocalStore::Lmdb(_)));
3437
3438 Ok(())
3439 }
3440
3441 #[cfg(feature = "lmdb")]
3442 #[test]
3443 fn hashtree_store_uses_scoped_lmdb_hot_blob_dir() -> Result<()> {
3444 let _lock = HOT_BLOB_ENV_LOCK.lock().unwrap();
3445 let temp = TempDir::new()?;
3446 let data_dir = temp.path().join("store");
3447 let hot = temp.path().join("hot-main-blobs");
3448 let legacy = data_dir.join("blobs");
3449 let _hot_guard = EnvGuard::set(LMDB_HOT_BLOB_DIR_ENV, &hot);
3450 let _legacy_guard = EnvGuard::set(LMDB_HOT_BLOB_LEGACY_DIR_ENV, &legacy);
3451
3452 let store = HashtreeStore::with_options_and_backend(
3453 &data_dir,
3454 None,
3455 128 * 1024 * 1024,
3456 true,
3457 &StorageBackend::Lmdb,
3458 )?;
3459
3460 let local = store.router.local_store();
3461 assert!(matches!(local.as_ref(), LocalStore::TieredLmdb { .. }));
3462
3463 Ok(())
3464 }
3465
3466 #[cfg(feature = "lmdb")]
3467 #[test]
3468 fn tiered_lmdb_uses_distinct_external_blob_dirs() -> Result<()> {
3469 let _lock = HOT_BLOB_ENV_LOCK.lock().unwrap();
3470 let temp = TempDir::new()?;
3471 let data_dir = temp.path().join("store");
3472 let hot = temp.path().join("hot-main-blobs");
3473 let legacy = data_dir.join("blobs");
3474 let hot_external = temp.path().join("hot-external");
3475 let legacy_external = temp.path().join("legacy-external");
3476 let _hot_guard = EnvGuard::set(LMDB_HOT_BLOB_DIR_ENV, &hot);
3477 let _legacy_guard = EnvGuard::set(LMDB_HOT_BLOB_LEGACY_DIR_ENV, &legacy);
3478 let _global_external_guard = EnvGuard::set(LMDB_EXTERNAL_BLOB_DIR_ENV, &legacy_external);
3479 let _hot_external_guard = EnvGuard::set(LMDB_HOT_EXTERNAL_BLOB_DIR_ENV, &hot_external);
3480 let _min_guard = EnvGuard::set_value(LMDB_EXTERNAL_BLOB_MIN_BYTES_ENV, "1");
3481 let _sync_guard = EnvGuard::set_value(LMDB_EXTERNAL_BLOB_SYNC_ENV, "0");
3482 let _pack_guard = EnvGuard::set_value(LMDB_EXTERNAL_BLOB_PACK_TARGET_BYTES_ENV, "1024");
3483
3484 let store = HashtreeStore::with_options_and_backend(
3485 &data_dir,
3486 None,
3487 128 * 1024 * 1024,
3488 true,
3489 &StorageBackend::Lmdb,
3490 )?;
3491 let hot_data = b"hot blob written through primary tier".repeat(4);
3492 let hot_hash = sha256(&hot_data);
3493 assert_eq!(store.put_cached_blobs(&[(hot_hash, hot_data.clone())])?, 1);
3494 assert!(
3495 count_files_under(&hot_external.join("packs"))? > 0,
3496 "primary hot writes should create external packs under hot external dir"
3497 );
3498 assert_eq!(
3499 count_files_under(&legacy_external.join("packs"))?,
3500 0,
3501 "hot writes must not spill into the legacy external dir"
3502 );
3503
3504 let legacy_data = b"legacy blob already stored on the old tier".repeat(4);
3505 let legacy_hash = sha256(&legacy_data);
3506 match store.router.local_store().as_ref() {
3507 LocalStore::TieredLmdb { legacy, .. } => {
3508 assert_eq!(
3509 legacy.put_many_sync(&[(legacy_hash, legacy_data.clone())])?,
3510 1
3511 );
3512 }
3513 _ => panic!("expected tiered LMDB local store"),
3514 }
3515 assert!(
3516 count_files_under(&legacy_external.join("packs"))? > 0,
3517 "legacy writes should keep using the legacy external dir"
3518 );
3519
3520 assert_eq!(store.router().get_sync(&hot_hash)?, Some(hot_data));
3521 assert_eq!(store.router().get_sync(&legacy_hash)?, Some(legacy_data));
3522
3523 Ok(())
3524 }
3525
3526 #[cfg(feature = "lmdb")]
3527 #[test]
3528 fn tiered_lmdb_legacy_bytes_do_not_drive_hot_quota() -> Result<()> {
3529 let _lock = HOT_BLOB_ENV_LOCK.lock().unwrap();
3530 let temp = TempDir::new()?;
3531 let data_dir = temp.path().join("store");
3532 let hot = temp.path().join("hot-main-blobs");
3533 let legacy = data_dir.join("blobs");
3534 let legacy_blob = vec![7u8; 10 * 1024 * 1024];
3535 let legacy_hash = sha256(&legacy_blob);
3536 let hot_blob = vec![3u8; 8 * 1024 * 1024];
3537
3538 let _hot_guard = EnvGuard::set(LMDB_HOT_BLOB_DIR_ENV, &hot);
3539 let _legacy_guard = EnvGuard::set(LMDB_HOT_BLOB_LEGACY_DIR_ENV, &legacy);
3540 let store = HashtreeStore::with_options_and_backend(
3541 &data_dir,
3542 None,
3543 LMDB_BLOB_MIN_MAP_SIZE_BYTES,
3544 true,
3545 &StorageBackend::Lmdb,
3546 )?;
3547
3548 let local = store.router.local_store();
3549 match local.as_ref() {
3550 LocalStore::TieredLmdb { primary: _, legacy } => {
3551 assert_eq!(legacy.max_bytes(), None);
3552 assert!(legacy.put_sync(legacy_hash, &legacy_blob)?);
3553 }
3554 _ => panic!("expected tiered LMDB local store"),
3555 }
3556
3557 assert!(store.blob_exists(&legacy_hash)?);
3558 assert_eq!(
3559 store.blob_size(&legacy_hash)?,
3560 Some(legacy_blob.len() as u64)
3561 );
3562 assert_eq!(store.router.writable_stats()?.total_bytes, 0);
3563
3564 let pubkey = [1u8; 32];
3565 let hot_hash_hex = store.put_owned_blob(&hot_blob, &pubkey)?;
3566 let hot_hash = from_hex(&hot_hash_hex)?;
3567 assert_eq!(store.blob_size(&hot_hash)?, Some(hot_blob.len() as u64));
3568 assert!(store.blob_exists(&legacy_hash)?);
3569 assert!(!store.router.delete_local_only(&legacy_hash)?);
3570 assert!(store.blob_exists(&legacy_hash)?);
3571
3572 let local = store.router.local_store();
3573 match local.as_ref() {
3574 LocalStore::TieredLmdb { primary, legacy } => {
3575 assert!(primary.exists(&hot_hash)?);
3576 assert!(!primary.exists(&legacy_hash)?);
3577 assert!(legacy.exists(&legacy_hash)?);
3578 }
3579 _ => panic!("expected tiered LMDB local store"),
3580 }
3581
3582 let writable_stats = store.router.writable_stats()?;
3583 assert_eq!(writable_stats.count, 1);
3584 assert_eq!(writable_stats.total_bytes, hot_blob.len() as u64);
3585
3586 drop(store);
3587 Ok(())
3588 }
3589
3590 #[cfg(feature = "lmdb")]
3591 #[test]
3592 fn lmdb_local_store_removes_stale_fs_blob_shard_dirs() -> Result<()> {
3593 let temp = TempDir::new()?;
3594 let path = temp.path().join("lmdb-blobs");
3595 std::fs::create_dir_all(path.join("aa"))?;
3596 std::fs::create_dir_all(path.join("b2"))?;
3597 std::fs::create_dir_all(path.join("keep-me"))?;
3598 std::fs::write(path.join("aa").join("blob.bin"), b"old fs shard")?;
3599 std::fs::write(path.join("b2").join("blob.bin"), b"old fs shard")?;
3600 std::fs::write(path.join("keep-me").join("note.txt"), b"keep")?;
3601
3602 let _store = LocalStore::new_with_lmdb_map_size(
3603 &path,
3604 &StorageBackend::Lmdb,
3605 Some(128 * 1024 * 1024),
3606 )?;
3607
3608 assert!(!path.join("aa").exists());
3609 assert!(!path.join("b2").exists());
3610 assert!(path.join("keep-me").exists());
3611 assert!(path.join("data.mdb").exists());
3612 assert!(path.join("lock.mdb").exists());
3613
3614 Ok(())
3615 }
3616
3617 #[cfg(feature = "lmdb")]
3618 #[test]
3619 fn duplicate_blossom_writes_do_not_refresh_blob_last_accessed() -> Result<()> {
3620 let temp = TempDir::new()?;
3621 let store = HashtreeStore::with_options_and_backend(
3622 temp.path(),
3623 None,
3624 LMDB_BLOB_MIN_MAP_SIZE_BYTES,
3625 true,
3626 &StorageBackend::Lmdb,
3627 )?;
3628
3629 let raw = b"raw duplicate";
3630 let raw_hash = sha256(raw);
3631 store.put_blob(raw)?;
3632 let raw_accessed = store.blob_last_accessed_at(&raw_hash)?;
3633 store.put_blob(raw)?;
3634 assert_eq!(store.blob_last_accessed_at(&raw_hash)?, raw_accessed);
3635
3636 let data = b"cached blossom duplicate";
3637 let hash = sha256(data);
3638 store.put_cached_blob(data)?;
3639 let cached_accessed = store.blob_last_accessed_at(&hash)?;
3640 store.put_cached_blob(data)?;
3641 assert_eq!(store.blob_last_accessed_at(&hash)?, cached_accessed);
3642
3643 let cached_batch = [
3644 (
3645 sha256(b"cached blossom batch 1"),
3646 b"cached blossom batch 1".to_vec(),
3647 ),
3648 (
3649 sha256(b"cached blossom batch 2"),
3650 b"cached blossom batch 2".to_vec(),
3651 ),
3652 ];
3653 assert_eq!(store.put_cached_blobs(&cached_batch)?, 2);
3654 assert_eq!(store.put_cached_blobs(&cached_batch)?, 0);
3655 assert_eq!(
3656 store.get_blob(&cached_batch[0].0)?.as_deref(),
3657 Some(cached_batch[0].1.as_slice())
3658 );
3659
3660 let owned = b"owned blossom duplicate";
3661 let owned_hash = sha256(owned);
3662 let owner = [7u8; 32];
3663 store.put_owned_blob(owned, &owner)?;
3664 let owned_accessed = store.blob_last_accessed_at(&owned_hash)?;
3665 store.put_owned_blob(owned, &owner)?;
3666 assert_eq!(store.blob_last_accessed_at(&owned_hash)?, owned_accessed);
3667 let owned_blobs = store.list_blobs_by_pubkey(&owner)?;
3668 assert_eq!(owned_blobs.len(), 1);
3669 assert_eq!(owned_blobs[0].sha256, to_hex(&owned_hash));
3670
3671 let other_owner = [8u8; 32];
3672 store.put_owned_blob(owned, &other_owner)?;
3673 assert_eq!(store.blob_last_accessed_at(&owned_hash)?, owned_accessed);
3674 let other_owned_blobs = store.list_blobs_by_pubkey(&other_owner)?;
3675 assert_eq!(other_owned_blobs.len(), 1);
3676 assert_eq!(other_owned_blobs[0].sha256, to_hex(&owned_hash));
3677
3678 let batch = [
3679 (
3680 sha256(b"owned blossom batch 1"),
3681 b"owned blossom batch 1".to_vec(),
3682 ),
3683 (
3684 sha256(b"owned blossom batch 2"),
3685 b"owned blossom batch 2".to_vec(),
3686 ),
3687 ];
3688 store.put_owned_blobs(&batch, &owner)?;
3689 assert_eq!(store.put_owned_blobs(&batch, &owner)?, 0);
3690 let owned_blobs = store.list_blobs_by_pubkey(&owner)?;
3691 assert_eq!(owned_blobs.len(), 3);
3692
3693 Ok(())
3694 }
3695
3696 #[cfg(feature = "lmdb")]
3697 #[test]
3698 fn duplicate_heavy_cached_batch_uses_actual_inserted_bytes_for_quota() -> Result<()> {
3699 let temp = TempDir::new()?;
3700 let store = HashtreeStore::with_options_and_backend(
3701 temp.path(),
3702 None,
3703 35,
3704 true,
3705 &StorageBackend::Lmdb,
3706 )?;
3707
3708 let first = [1u8; 10];
3709 let second = [2u8; 10];
3710 let third = [3u8; 10];
3711 let new = [4u8; 5];
3712 let first_hash = sha256(&first);
3713 let second_hash = sha256(&second);
3714 let third_hash = sha256(&third);
3715 let new_hash = sha256(&new);
3716
3717 store.put_cached_blob(&first)?;
3718 store.put_cached_blob(&second)?;
3719 store.put_cached_blob(&third)?;
3720 assert_eq!(store.router.writable_stats()?.total_bytes, 30);
3721
3722 let inserted = store.put_cached_blobs(&[
3723 (first_hash, first.to_vec()),
3724 (second_hash, second.to_vec()),
3725 (new_hash, new.to_vec()),
3726 ])?;
3727
3728 assert_eq!(inserted, 1);
3729 assert_eq!(store.router.writable_stats()?.total_bytes, 35);
3730 assert!(store.blob_exists(&first_hash)?);
3731 assert!(store.blob_exists(&second_hash)?);
3732 assert!(store.blob_exists(&third_hash)?);
3733 assert!(store.blob_exists(&new_hash)?);
3734
3735 Ok(())
3736 }
3737
3738 #[cfg(feature = "lmdb")]
3739 #[test]
3740 fn replacing_tree_ref_unpins_and_unindexes_superseded_root() -> Result<()> {
3741 let temp = TempDir::new()?;
3742 let store = HashtreeStore::with_options_and_backend(
3743 temp.path(),
3744 None,
3745 LMDB_BLOB_MIN_MAP_SIZE_BYTES,
3746 true,
3747 &StorageBackend::Lmdb,
3748 )?;
3749
3750 let old_bytes = b"old published root";
3751 let new_bytes = b"new published root";
3752 let old_root = sha256(old_bytes);
3753 let new_root = sha256(new_bytes);
3754
3755 store.put_blob(old_bytes)?;
3756 store.pin(&old_root)?;
3757 store.index_tree(
3758 &old_root,
3759 "owner",
3760 Some("playlist"),
3761 PRIORITY_OWN,
3762 Some("npub1owner/playlist"),
3763 )?;
3764
3765 assert!(store.is_pinned(&old_root)?);
3766 assert!(store.get_tree_meta(&old_root)?.is_some());
3767
3768 store.put_blob(new_bytes)?;
3769 store.pin(&new_root)?;
3770 store.index_tree(
3771 &new_root,
3772 "owner",
3773 Some("playlist"),
3774 PRIORITY_OWN,
3775 Some("npub1owner/playlist"),
3776 )?;
3777
3778 assert!(
3779 !store.is_pinned(&old_root)?,
3780 "superseded root should be unpinned when ref is replaced"
3781 );
3782 assert!(
3783 store.get_tree_meta(&old_root)?.is_none(),
3784 "superseded root metadata should be removed when ref is replaced"
3785 );
3786 assert!(store.is_pinned(&new_root)?);
3787 assert!(store.get_tree_meta(&new_root)?.is_some());
3788
3789 Ok(())
3790 }
3791
3792 #[test]
3793 fn tracked_authors_round_trip_sorted_and_deduplicated() -> Result<()> {
3794 let temp = TempDir::new()?;
3795 let store = HashtreeStore::with_options(temp.path(), None, 1024 * 1024)?;
3796
3797 store
3798 .add_tracked_author("npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk")?;
3799 store
3800 .add_tracked_author("npub1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaqf5slm")?;
3801 store
3802 .add_tracked_author("npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk")?;
3803
3804 assert_eq!(
3805 store.list_tracked_authors()?,
3806 vec![
3807 "npub1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaqf5slm".to_string(),
3808 "npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk".to_string(),
3809 ]
3810 );
3811 assert!(store.remove_tracked_author(
3812 "npub1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaqf5slm"
3813 )?);
3814 assert!(!store.remove_tracked_author(
3815 "npub1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbpqqqqq"
3816 )?);
3817 assert_eq!(
3818 store.list_tracked_authors()?,
3819 vec!["npub1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzs9d3kk".to_string()]
3820 );
3821
3822 Ok(())
3823 }
3824
3825 #[cfg(feature = "s3")]
3826 #[test]
3827 fn async_store_s3_fallback_does_not_reenter_futures_executor() -> Result<()> {
3828 let temp = tempfile::TempDir::new()?;
3829 let local = Arc::new(LocalStore::new(
3830 temp.path().join("blobs"),
3831 &StorageBackend::Fs,
3832 )?);
3833
3834 let outcome = std::panic::catch_unwind(|| {
3835 sync_block_on(async {
3836 let aws_config = aws_config::from_env()
3837 .region(aws_sdk_s3::config::Region::new("auto"))
3838 .load()
3839 .await;
3840 let s3_client = aws_sdk_s3::Client::from_conf(
3841 aws_sdk_s3::config::Builder::from(&aws_config)
3842 .endpoint_url("http://127.0.0.1:9")
3843 .force_path_style(true)
3844 .build(),
3845 );
3846
3847 let router = StorageRouter {
3848 local,
3849 s3_client: Some(s3_client),
3850 s3_bucket: Some("test-bucket".to_string()),
3851 s3_prefix: String::new(),
3852 sync_tx: None,
3853 };
3854 let hash = [0u8; 32];
3855
3856 let _ = Store::has(&router, &hash).await;
3857 let _ = Store::get(&router, &hash).await;
3858 });
3859 });
3860
3861 assert!(
3862 outcome.is_ok(),
3863 "S3-backed async store methods should not panic inside futures::block_on"
3864 );
3865
3866 Ok(())
3867 }
3868}