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