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