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