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