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