1use anyhow::{Context, Result};
2use futures::executor::block_on as sync_block_on;
3use hashtree_core::store::Store;
4use hashtree_core::{
5 decode_tree_node_by_cid, sha256, to_hex, types::Hash, Cid, HashTree, HashTreeConfig, LinkType,
6};
7use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor};
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, HashSet};
10use std::fs::{File, OpenOptions};
11#[cfg(unix)]
12use std::os::fd::AsRawFd;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use super::quota::{CacheQuotaAdmission, CacheWritePermit};
18use super::{BlobMetadata, HashtreeStore, PRIORITY_FOLLOWED, PRIORITY_OWN};
19
20const MAX_PINNED_TREE_NODES: usize = 10_000_000;
21const MAX_UNBOUNDED_PINNED_TREE_BYTES: u64 = 1 << 50;
22const ORPHAN_SCAN_PAGE_SIZE: usize = 4_096;
23const RETENTION_ROOTS_LOCK_FILE: &str = ".retention-roots.lock";
24const MAX_PROFILE_REPAIR_RETENTION_LEASE_BYTES: u64 = 64 * 1024;
25const MAX_PROFILE_REPAIR_RETENTION_ROOTS: usize = 64;
26
27pub const PROFILE_REPAIR_RETENTION_LEASE_FORMAT: &str =
28 "iris-social/bulk-profile-index-repair-retention@1";
29pub const PROFILE_REPAIR_RETENTION_LEASE_RELATIVE_PATH: &str =
30 "nostr-index/bulk-projection-v2/profile-repair-v1/retention.json";
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(deny_unknown_fields)]
34pub struct ProfileRepairRetentionLease {
35 pub format: String,
36 pub authority_sha256: String,
37 pub roots: BTreeMap<String, String>,
38}
39
40impl ProfileRepairRetentionLease {
41 pub fn validate(&self) -> Result<()> {
42 if self.format != PROFILE_REPAIR_RETENTION_LEASE_FORMAT {
43 anyhow::bail!(
44 "profile repair retention lease has unsupported format {}",
45 self.format
46 );
47 }
48 if self.authority_sha256.len() != 64
49 || !self
50 .authority_sha256
51 .bytes()
52 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
53 {
54 anyhow::bail!(
55 "profile repair retention lease authority must be 64 lowercase hexadecimal characters"
56 );
57 }
58 if self.roots.is_empty() || self.roots.len() > MAX_PROFILE_REPAIR_RETENTION_ROOTS {
59 anyhow::bail!(
60 "profile repair retention lease must contain between 1 and {} roots",
61 MAX_PROFILE_REPAIR_RETENTION_ROOTS
62 );
63 }
64 for (label, encoded) in &self.roots {
65 if label.is_empty()
66 || label.len() > 128
67 || !label
68 .bytes()
69 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
70 {
71 anyhow::bail!("profile repair retention lease has invalid root label {label:?}");
72 }
73 let cid = Cid::parse(encoded)
74 .map_err(|error| anyhow::anyhow!("invalid retained root {label}: {error}"))?;
75 if cid.to_string() != *encoded {
76 anyhow::bail!(
77 "profile repair retention lease root {label} is not canonical CID text"
78 );
79 }
80 }
81 Ok(())
82 }
83
84 pub fn canonical_bytes(&self) -> Result<Vec<u8>> {
85 self.validate()?;
86 let mut bytes =
87 serde_json::to_vec(self).map_err(|error| anyhow::anyhow!("encode lease: {error}"))?;
88 bytes.push(b'\n');
89 Ok(bytes)
90 }
91
92 pub fn sha256(&self) -> Result<String> {
93 Ok(to_hex(&hashtree_core::sha256(&self.canonical_bytes()?)))
94 }
95
96 fn root_cids(&self) -> Result<Vec<Cid>> {
97 self.validate()?;
98 self.roots
99 .iter()
100 .map(|(label, encoded)| {
101 Cid::parse(encoded)
102 .map_err(|error| anyhow::anyhow!("invalid retained root {label}: {error}"))
103 })
104 .collect()
105 }
106}
107
108#[derive(Debug, Clone, Copy)]
109enum RetentionRootsLockMode {
110 Shared,
111 Exclusive,
112}
113
114pub struct ProfileRepairRetentionPublicationGuard {
115 file: File,
116}
117
118impl Drop for ProfileRepairRetentionPublicationGuard {
119 fn drop(&mut self) {
120 #[cfg(unix)]
121 unsafe {
122 libc::flock(self.file.as_raw_fd(), libc::LOCK_UN);
123 }
124 }
125}
126
127pub fn acquire_existing_profile_repair_retention_guard(
135 base_path: &Path,
136) -> Result<ProfileRepairRetentionPublicationGuard> {
137 let path = base_path.join(RETENTION_ROOTS_LOCK_FILE);
138 let before = std::fs::symlink_metadata(&path)
139 .with_context(|| format!("inspect existing retention-roots lock {}", path.display()))?;
140 if before.file_type().is_symlink() || !before.file_type().is_file() {
141 anyhow::bail!(
142 "existing retention-roots lock is not a direct regular file: {}",
143 path.display()
144 );
145 }
146 let file = OpenOptions::new()
147 .read(true)
148 .write(true)
149 .open(&path)
150 .with_context(|| format!("open existing retention-roots lock {}", path.display()))?;
151 #[cfg(unix)]
152 {
153 let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
154 if result != 0 {
155 return Err(std::io::Error::last_os_error()).with_context(|| {
156 format!("acquire existing retention-roots lock {}", path.display())
157 });
158 }
159 use std::os::unix::fs::MetadataExt;
160 let opened = file
161 .metadata()
162 .with_context(|| format!("inspect opened retention-roots lock {}", path.display()))?;
163 let current = std::fs::symlink_metadata(&path)
164 .with_context(|| format!("reinspect retention-roots lock {}", path.display()))?;
165 if current.file_type().is_symlink()
166 || !current.file_type().is_file()
167 || opened.dev() != before.dev()
168 || opened.ino() != before.ino()
169 || current.dev() != before.dev()
170 || current.ino() != before.ino()
171 {
172 anyhow::bail!(
173 "retention-roots lock identity changed while it was acquired: {}",
174 path.display()
175 );
176 }
177 }
178 #[cfg(not(unix))]
179 {
180 let _ = before;
181 anyhow::bail!("retention-root transactions require an operating-system advisory file lock");
182 }
183 Ok(ProfileRepairRetentionPublicationGuard { file })
184}
185
186pub(super) struct ActiveRetentionProtection {
187 _guard: ProfileRepairRetentionPublicationGuard,
188 _profile_roots: Option<crate::socialgraph::ProfileRootSnapshotGuard>,
189 hashes: HashSet<Hash>,
190}
191
192impl ActiveRetentionProtection {
193 pub(super) fn contains(&self, hash: &Hash) -> bool {
194 self.hashes.contains(hash)
195 }
196
197 fn hashes(&self) -> &HashSet<Hash> {
198 &self.hashes
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203struct OrphanCleanupProgress {
204 freed_bytes: u64,
205 scanned: usize,
206 sweep_complete: bool,
207}
208
209#[derive(Debug, Clone, Copy)]
211pub struct TreeIndexLimits {
212 pub max_nodes: usize,
213 pub max_bytes: u64,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub struct PinTreeResult {
219 pub indexed_hashes: usize,
220 pub total_size: u64,
221 pub already_pinned: bool,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub struct RootRetentionReport {
226 pub total_hashes: usize,
227 pub reachable_hashes: usize,
228 pub pinned_hashes: usize,
229 pub candidate_hashes: usize,
230 pub deleted_hashes: usize,
231 pub logical_bytes_before: u64,
232 pub logical_bytes_after: u64,
233}
234
235#[derive(Debug, thiserror::Error)]
236pub enum PinTreeError {
237 #[error("root blob {hash} is missing")]
238 MissingRoot { hash: String },
239 #[error("descendant blob {hash} is missing")]
240 MissingDescendant { hash: String },
241 #[error("invalid DAG node {hash}: {message}")]
242 InvalidDag { hash: String, message: String },
243 #[error("DAG exceeds the {max_nodes} node limit")]
244 NodeLimitExceeded { max_nodes: usize },
245 #[error("DAG exceeds the {max_bytes} byte limit")]
246 ByteLimitExceeded { max_bytes: u64 },
247 #[error("storage error: {0}")]
248 Storage(String),
249}
250
251struct TreeIndexPlan {
252 tracked_hashes: HashSet<Hash>,
253 total_size: u64,
254}
255
256#[derive(Debug, Clone, Serialize)]
258pub struct TreeMeta {
259 pub owner: String,
261 pub name: Option<String>,
263 pub synced_at: u64,
265 pub total_size: u64,
267 pub priority: u8,
269}
270
271impl<'de> Deserialize<'de> for TreeMeta {
272 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
273 where
274 D: serde::Deserializer<'de>,
275 {
276 const FIELDS: &[&str] = &[
277 "owner",
278 "name",
279 "synced_at",
280 "last_accessed_at",
281 "total_size",
282 "priority",
283 ];
284
285 struct TreeMetaVisitor;
286
287 impl<'de> Visitor<'de> for TreeMetaVisitor {
288 type Value = TreeMeta;
289
290 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
291 formatter.write_str("TreeMeta as current or legacy metadata")
292 }
293
294 fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
295 where
296 A: SeqAccess<'de>,
297 {
298 let has_accidental_access_field = matches!(seq.size_hint(), Some(6));
299 let owner = seq
300 .next_element()?
301 .ok_or_else(|| de::Error::invalid_length(0, &self))?;
302 let name = seq
303 .next_element()?
304 .ok_or_else(|| de::Error::invalid_length(1, &self))?;
305 let synced_at = seq
306 .next_element()?
307 .ok_or_else(|| de::Error::invalid_length(2, &self))?;
308
309 if has_accidental_access_field {
310 let _: IgnoredAny = seq
311 .next_element()?
312 .ok_or_else(|| de::Error::invalid_length(3, &self))?;
313 }
314
315 let total_size = seq
316 .next_element()?
317 .ok_or_else(|| de::Error::invalid_length(3, &self))?;
318 let priority = seq
319 .next_element()?
320 .ok_or_else(|| de::Error::invalid_length(4, &self))?;
321
322 Ok(TreeMeta {
323 owner,
324 name,
325 synced_at,
326 total_size,
327 priority,
328 })
329 }
330
331 fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
332 where
333 A: MapAccess<'de>,
334 {
335 let mut owner = None;
336 let mut name = None;
337 let mut synced_at = None;
338 let mut total_size = None;
339 let mut priority = None;
340
341 while let Some(key) = map.next_key::<String>()? {
342 match key.as_str() {
343 "owner" => owner = Some(map.next_value()?),
344 "name" => name = Some(map.next_value()?),
345 "synced_at" => synced_at = Some(map.next_value()?),
346 "last_accessed_at" => {
347 let _: IgnoredAny = map.next_value()?;
348 }
349 "total_size" => total_size = Some(map.next_value()?),
350 "priority" => priority = Some(map.next_value()?),
351 _ => {
352 let _: IgnoredAny = map.next_value()?;
353 }
354 }
355 }
356
357 Ok(TreeMeta {
358 owner: owner.ok_or_else(|| de::Error::missing_field("owner"))?,
359 name: name.unwrap_or(None),
360 synced_at: synced_at.ok_or_else(|| de::Error::missing_field("synced_at"))?,
361 total_size: total_size.ok_or_else(|| de::Error::missing_field("total_size"))?,
362 priority: priority.ok_or_else(|| de::Error::missing_field("priority"))?,
363 })
364 }
365 }
366
367 deserializer.deserialize_struct("TreeMeta", FIELDS, TreeMetaVisitor)
368 }
369}
370
371#[derive(Debug)]
372pub struct StorageStats {
373 pub total_dags: usize,
374 pub pinned_dags: usize,
375 pub total_bytes: u64,
376}
377
378#[derive(Debug, Clone)]
380pub struct StorageByPriority {
381 pub own: u64,
383 pub followed: u64,
385 pub other: u64,
387}
388
389#[derive(Debug, Clone)]
390pub struct PinnedItem {
391 pub cid: String,
392 pub name: String,
393 pub is_directory: bool,
394 pub size_bytes: u64,
395}
396
397#[derive(Debug, Clone)]
398pub struct OwnedBlobStats {
399 pub owner: [u8; 32],
400 pub count: usize,
401 pub total_bytes: u64,
402}
403
404fn pinned_item_name(hash: &Hash, meta: Option<&TreeMeta>) -> String {
405 let Some(meta) = meta else {
406 return to_hex(hash);
407 };
408
409 match (meta.owner.as_str(), meta.name.as_deref()) {
410 ("pinned", Some(name)) => name.to_string(),
411 ("", Some(name)) => name.to_string(),
412 (owner, Some(name)) if !owner.is_empty() => format!("{owner}/{name}"),
413 (owner, None) if !owner.is_empty() && owner != "pinned" => owner.to_string(),
414 _ => to_hex(hash),
415 }
416}
417
418fn unix_timestamp_now() -> u64 {
419 SystemTime::now()
420 .duration_since(UNIX_EPOCH)
421 .unwrap_or_default()
422 .as_secs()
423}
424
425impl HashtreeStore {
426 fn acquire_retention_roots_lock(
427 &self,
428 mode: RetentionRootsLockMode,
429 ) -> Result<ProfileRepairRetentionPublicationGuard> {
430 let path = self.base_path().join(RETENTION_ROOTS_LOCK_FILE);
431 let file = OpenOptions::new()
432 .read(true)
433 .write(true)
434 .create(true)
435 .truncate(false)
436 .open(&path)
437 .with_context(|| format!("open retention-roots lock {}", path.display()))?;
438 #[cfg(unix)]
439 {
440 let operation = match mode {
441 RetentionRootsLockMode::Shared => libc::LOCK_SH,
442 RetentionRootsLockMode::Exclusive => libc::LOCK_EX,
443 };
444 let result = unsafe { libc::flock(file.as_raw_fd(), operation) };
445 if result != 0 {
446 return Err(std::io::Error::last_os_error())
447 .with_context(|| format!("acquire retention-roots lock {}", path.display()));
448 }
449 }
450 #[cfg(not(unix))]
451 {
452 let _ = mode;
453 anyhow::bail!(
454 "retention-root transactions require an operating-system advisory file lock"
455 );
456 }
457 Ok(ProfileRepairRetentionPublicationGuard { file })
458 }
459
460 pub fn acquire_profile_repair_retention_publication_guard(
464 &self,
465 ) -> Result<ProfileRepairRetentionPublicationGuard> {
466 self.acquire_retention_roots_lock(RetentionRootsLockMode::Exclusive)
467 }
468
469 pub fn profile_repair_retention_lease_path(&self) -> PathBuf {
470 self.base_path()
471 .join(PROFILE_REPAIR_RETENTION_LEASE_RELATIVE_PATH)
472 }
473
474 pub fn validate_profile_repair_retention_lease(
475 &self,
476 expected_sha256: &str,
477 ) -> Result<ProfileRepairRetentionLease> {
478 let path = self.profile_repair_retention_lease_path();
479 let metadata = std::fs::symlink_metadata(&path)
480 .with_context(|| format!("inspect {}", path.display()))?;
481 if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
482 anyhow::bail!(
483 "profile repair retention lease is not a direct regular file: {}",
484 path.display()
485 );
486 }
487 if metadata.len() > MAX_PROFILE_REPAIR_RETENTION_LEASE_BYTES {
488 anyhow::bail!(
489 "profile repair retention lease exceeds {} bytes: {}",
490 MAX_PROFILE_REPAIR_RETENTION_LEASE_BYTES,
491 path.display()
492 );
493 }
494 let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
495 let actual_sha256 = to_hex(&hashtree_core::sha256(&bytes));
496 if actual_sha256 != expected_sha256 {
497 anyhow::bail!(
498 "profile repair retention lease SHA-256 mismatch: expected {expected_sha256}, found {actual_sha256}"
499 );
500 }
501 let lease: ProfileRepairRetentionLease =
502 serde_json::from_slice(&bytes).with_context(|| format!("decode {}", path.display()))?;
503 if lease.canonical_bytes()? != bytes {
504 anyhow::bail!(
505 "profile repair retention lease is not canonical: {}",
506 path.display()
507 );
508 }
509 Ok(lease)
510 }
511
512 pub(super) fn active_retention_protection(&self) -> Result<ActiveRetentionProtection> {
516 let guard = self.acquire_retention_roots_lock(RetentionRootsLockMode::Shared)?;
517 let profile_roots =
518 crate::socialgraph::acquire_profile_root_snapshot_guard(self.base_path())?;
519 let mut roots = match profile_roots.as_ref() {
520 Some(profile_roots) => profile_roots.retention_roots()?,
521 None => Vec::new(),
522 };
523 roots.extend(self.profile_repair_retention_roots()?);
524 roots.sort_by_key(Cid::to_string);
525 roots.dedup();
526 let hashes = self.collect_socialgraph_protected(&roots)?;
527 Ok(ActiveRetentionProtection {
528 _guard: guard,
529 _profile_roots: profile_roots,
530 hashes,
531 })
532 }
533
534 pub fn retain_nostr_root(&self, root: &Cid, apply: bool) -> Result<RootRetentionReport> {
541 let retention = self.active_retention_protection()?;
542 let mut reachable = self.nostr_index_hashes(root)?;
543 reachable.extend(retention.hashes().iter().copied());
544
545 let rtxn = self.env.read_txn()?;
546 let pinned = self
547 .pins
548 .iter(&rtxn)?
549 .filter_map(std::result::Result::ok)
550 .filter_map(|(hash, _)| hash.try_into().ok())
551 .collect::<HashSet<Hash>>();
552 drop(rtxn);
553
554 let stats_before = self
555 .router
556 .writable_stats()
557 .map_err(|error| anyhow::anyhow!("read writable storage stats: {error}"))?;
558 let all_hashes = self
559 .router
560 .list_writable()
561 .map_err(|error| anyhow::anyhow!("list writable hashes: {error}"))?;
562 let candidates = all_hashes
563 .iter()
564 .filter(|hash| !reachable.contains(*hash) && !pinned.contains(*hash))
565 .copied()
566 .collect::<Vec<_>>();
567
568 let mut deleted = 0usize;
569 if apply {
570 const DELETE_BATCH_SIZE: usize = 16_384;
571 for (batch_index, batch) in candidates.chunks(DELETE_BATCH_SIZE).enumerate() {
572 deleted = deleted.saturating_add(
573 self.router
574 .delete_many_local_only(batch)
575 .map_err(|error| anyhow::anyhow!("delete unreachable batch: {error}"))?,
576 );
577 if (batch_index + 1).is_multiple_of(32) {
578 eprintln!(
579 "Retained-root cleanup: deleted {deleted}/{} unreachable hashes",
580 candidates.len()
581 );
582 }
583 }
584 }
585 let stats_after = self
586 .router
587 .writable_stats()
588 .map_err(|error| anyhow::anyhow!("read writable storage stats: {error}"))?;
589
590 Ok(RootRetentionReport {
591 total_hashes: all_hashes.len(),
592 reachable_hashes: reachable.len(),
593 pinned_hashes: pinned.len(),
594 candidate_hashes: candidates.len(),
595 deleted_hashes: deleted,
596 logical_bytes_before: stats_before.total_bytes,
597 logical_bytes_after: stats_after.total_bytes,
598 })
599 }
600
601 pub(crate) fn nostr_index_hashes(&self, root: &Cid) -> Result<HashSet<Hash>> {
603 let tree = HashTree::new(HashTreeConfig::new(self.store_arc()));
604 sync_block_on(async {
605 let mut reachable = HashSet::new();
606 let mut stack = vec![(root.clone(), "root".to_string())];
607 while let Some((cid, path)) = stack.pop() {
608 if !reachable.insert(cid.hash) {
609 continue;
610 }
611 let node = tree
612 .get_tree_node_by_cid(&cid)
613 .await
614 .map_err(|error| anyhow::anyhow!("read retained root DAG: {error}"))?
615 .ok_or_else(|| {
616 anyhow::anyhow!(
617 "retained directory node {} is missing at {path}",
618 to_hex(&cid.hash)
619 )
620 })?;
621 for link in node.links {
622 if link.link_type.is_directory_like() {
623 let name = link.name.as_deref().unwrap_or("<unnamed>");
624 stack.push((link.to_cid(), format!("{path}/{name}")));
625 } else {
626 reachable.insert(link.hash);
627 }
628 }
629 }
630 Ok::<_, anyhow::Error>(reachable)
631 })
632 }
633
634 async fn collect_tree_hashes<S: Store>(
635 &self,
636 tree: &HashTree<S>,
637 root: &Cid,
638 require_tree_root: bool,
639 ) -> Result<HashSet<Hash>> {
640 let mut hashes = HashSet::new();
641 let mut stack = vec![(root.clone(), true)];
642
643 while let Some((cid, is_root)) = stack.pop() {
644 if !hashes.insert(cid.hash) {
645 continue;
646 }
647
648 let node = tree
649 .get_node(&cid)
650 .await
651 .map_err(|e| anyhow::anyhow!("Failed to get protected tree node: {}", e))?;
652 if let Some(node) = node {
653 for link in &node.links {
654 stack.push((link.to_cid(), false));
655 }
656 } else if is_root && require_tree_root {
657 anyhow::bail!(
658 "protected retention root {} is missing or is not a tree",
659 cid
660 );
661 }
662 }
663
664 Ok(hashes)
665 }
666
667 fn profile_repair_retention_roots(&self) -> Result<Vec<Cid>> {
668 let path = self.profile_repair_retention_lease_path();
669 let metadata = match std::fs::symlink_metadata(&path) {
670 Ok(metadata) => metadata,
671 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
672 Err(error) => {
673 return Err(error).with_context(|| format!("inspect {}", path.display()));
674 }
675 };
676 if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
677 anyhow::bail!(
678 "profile repair retention lease is not a direct regular file: {}",
679 path.display()
680 );
681 }
682 if metadata.len() > MAX_PROFILE_REPAIR_RETENTION_LEASE_BYTES {
683 anyhow::bail!(
684 "profile repair retention lease exceeds {} bytes: {}",
685 MAX_PROFILE_REPAIR_RETENTION_LEASE_BYTES,
686 path.display()
687 );
688 }
689 let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
690 let lease: ProfileRepairRetentionLease =
691 serde_json::from_slice(&bytes).with_context(|| format!("decode {}", path.display()))?;
692 if lease.canonical_bytes()? != bytes {
693 anyhow::bail!(
694 "profile repair retention lease is not canonical: {}",
695 path.display()
696 );
697 }
698 lease.root_cids()
699 }
700
701 fn collect_socialgraph_protected(&self, roots: &[Cid]) -> Result<HashSet<Hash>> {
702 let mut protected = HashSet::new();
703 let tree = HashTree::new(HashTreeConfig::new(self.store_arc()).public());
704 for root in roots {
705 protected.extend(sync_block_on(self.collect_tree_hashes(&tree, root, true))?);
706 }
707 Ok(protected)
708 }
709
710 fn socialgraph_protected_for_orphan_sweep(&self, roots: &[Cid]) -> Result<Arc<HashSet<Hash>>> {
717 let root_ids = roots.iter().map(Cid::to_string).collect::<Vec<_>>();
718 {
719 let state = self
720 .orphan_scan
721 .lock()
722 .unwrap_or_else(std::sync::PoisonError::into_inner);
723 if state.socialgraph_roots.as_ref() == Some(&root_ids) {
724 return Ok(Arc::clone(&state.socialgraph_protected));
725 }
726 }
727
728 let newly_protected = self.collect_socialgraph_protected(roots)?;
729 let mut state = self
730 .orphan_scan
731 .lock()
732 .unwrap_or_else(std::sync::PoisonError::into_inner);
733 if state.socialgraph_roots.as_ref() == Some(&root_ids) {
734 return Ok(Arc::clone(&state.socialgraph_protected));
735 }
736
737 let protected = if state.sweep.is_some() && state.socialgraph_roots.is_some() {
738 let mut union = (*state.socialgraph_protected).clone();
739 union.extend(newly_protected);
740 union
741 } else {
742 newly_protected
743 };
744 state.socialgraph_roots = Some(root_ids);
745 state.socialgraph_protected = Arc::new(protected);
746 Ok(Arc::clone(&state.socialgraph_protected))
747 }
748
749 fn metadata_protects_orphan(&self, hash: &Hash) -> Result<bool> {
750 let rtxn = self.env.read_txn()?;
751 if self.pins.get(&rtxn, hash.as_slice())?.is_some() {
752 return Ok(true);
753 }
754 if self
755 .blob_trees
756 .prefix_iter(&rtxn, hash.as_slice())?
757 .next()
758 .transpose()?
759 .is_some()
760 {
761 return Ok(true);
762 }
763 let has_owner = self
764 .blob_owners
765 .prefix_iter(&rtxn, hash.as_slice())?
766 .next()
767 .transpose()?
768 .is_some();
769 Ok(has_owner)
770 }
771
772 fn evict_disposable_orphans_page(
773 &self,
774 target_bytes: u64,
775 additional_protected: &HashSet<Hash>,
776 page_size: usize,
777 ) -> Result<OrphanCleanupProgress> {
778 if page_size == 0 {
779 anyhow::bail!("orphan cleanup page size must be greater than zero");
780 }
781
782 let _retention_roots = self.acquire_retention_roots_lock(RetentionRootsLockMode::Shared)?;
783 let profile_roots =
784 crate::socialgraph::acquire_profile_root_snapshot_guard(self.base_path())?;
785 let mut roots = match profile_roots.as_ref() {
786 Some(profile_roots) => profile_roots.retention_roots()?,
787 None => Vec::new(),
788 };
789 roots.extend(self.profile_repair_retention_roots()?);
790 roots.sort_by_key(Cid::to_string);
791 roots.dedup();
792 let stats = self
793 .router
794 .writable_stats()
795 .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
796 let mut current_size = stats.total_bytes;
797 if current_size <= target_bytes {
798 let mut state = self
799 .orphan_scan
800 .lock()
801 .unwrap_or_else(std::sync::PoisonError::into_inner);
802 state.sweep = None;
803 state.socialgraph_roots = None;
804 state.socialgraph_protected = Arc::new(HashSet::new());
805 return Ok(OrphanCleanupProgress {
806 freed_bytes: 0,
807 scanned: 0,
808 sweep_complete: false,
809 });
810 }
811
812 let (after, start_after, wrapped) = {
813 let mut state = self
814 .orphan_scan
815 .lock()
816 .unwrap_or_else(std::sync::PoisonError::into_inner);
817 if state.sweep.is_none() {
818 state.sweep = Some(super::OrphanSweep {
819 start_after: state.cursor,
820 wrapped: false,
821 });
822 }
823 let sweep = state.sweep.expect("orphan sweep initialized");
824 (state.cursor, sweep.start_after, sweep.wrapped)
825 };
826 let socialgraph_protected = self.socialgraph_protected_for_orphan_sweep(&roots)?;
827 let mut candidates = self
828 .router
829 .scan_writable_hashes_after(after, page_size)
830 .map_err(|e| anyhow::anyhow!("Failed to scan writable hashes: {}", e))?;
831 let backend_page_len = candidates.len();
832 let mut crossed_start = false;
833 if wrapped {
834 let start_after = start_after.expect("only a non-zero cursor sweep wraps");
835 let keep = candidates.partition_point(|hash| *hash <= start_after);
836 crossed_start = keep < candidates.len();
837 candidates.truncate(keep);
838 }
839
840 let mut freed = 0u64;
841 let mut scanned = 0usize;
842 let mut last_examined = None;
843 for hash in &candidates {
844 if current_size <= target_bytes {
845 break;
846 }
847 let hash = *hash;
848 scanned += 1;
849 last_examined = Some(hash);
850
851 if socialgraph_protected.contains(&hash)
852 || additional_protected.contains(&hash)
853 || self.metadata_protects_orphan(&hash)?
854 {
855 continue;
856 }
857
858 let Some(_delete_guard) = self.cache_quota.begin_retention_delete(hash) else {
859 continue;
860 };
861 if self.metadata_protects_orphan(&hash)? {
865 continue;
866 }
867
868 let Some(size) = self
869 .router
870 .blob_size_sync(&hash)
871 .map_err(|e| anyhow::anyhow!("Failed to get blob size: {}", e))?
872 else {
873 continue;
874 };
875
876 if self
877 .router
878 .delete_local_only(&hash)
879 .map_err(|e| anyhow::anyhow!("Failed to delete orphaned blob: {}", e))?
880 {
881 freed = freed.saturating_add(size);
882 current_size = current_size.saturating_sub(size);
883 tracing::debug!(
884 "Deleted disposable orphaned blob {} ({} bytes)",
885 &to_hex(&hash)[..8],
886 size
887 );
888 }
889 }
890
891 let target_reached = current_size <= target_bytes;
892 let processed_whole_page = scanned == candidates.len();
893 let backend_exhausted = backend_page_len < page_size;
894 let mut sweep_complete = false;
895 let mut state = self
896 .orphan_scan
897 .lock()
898 .unwrap_or_else(std::sync::PoisonError::into_inner);
899 if let Some(last_examined) = last_examined {
900 state.cursor = Some(last_examined);
901 }
902
903 if target_reached {
904 state.sweep = None;
905 state.socialgraph_roots = None;
906 state.socialgraph_protected = Arc::new(HashSet::new());
907 } else if processed_whole_page {
908 let sweep = state.sweep.expect("active orphan sweep");
909 if sweep.wrapped {
910 if crossed_start || backend_exhausted {
911 state.cursor = sweep.start_after;
912 state.sweep = None;
913 sweep_complete = true;
914 }
915 } else if backend_exhausted {
916 if sweep.start_after.is_none() {
917 state.cursor = None;
918 state.sweep = None;
919 sweep_complete = true;
920 } else {
921 state.cursor = None;
922 state.sweep = Some(super::OrphanSweep {
923 wrapped: true,
924 ..sweep
925 });
926 }
927 }
928 if sweep_complete {
929 state.socialgraph_roots = None;
930 state.socialgraph_protected = Arc::new(HashSet::new());
931 }
932 }
933
934 Ok(OrphanCleanupProgress {
935 freed_bytes: freed,
936 scanned,
937 sweep_complete,
938 })
939 }
940
941 fn evict_disposable_orphans_to_target_raw(
942 &self,
943 target_bytes: u64,
944 additional_protected: &HashSet<Hash>,
945 ) -> Result<OrphanCleanupProgress> {
946 self.evict_disposable_orphans_page(
947 target_bytes,
948 additional_protected,
949 ORPHAN_SCAN_PAGE_SIZE,
950 )
951 }
952
953 fn evict_disposable_orphans_to_target(&self, target_bytes: u64) -> Result<u64> {
954 let cleanup = self
955 .cache_quota
956 .begin_standalone_cleanup()
957 .map_err(|denial| anyhow::anyhow!(denial.to_string()))?;
958 let result =
959 self.evict_disposable_orphans_to_target_raw(target_bytes, cleanup.inflight_hashes());
960 if result.is_ok() {
961 let after_usage = self
962 .router
963 .writable_stats()
964 .map_err(|error| {
965 anyhow::anyhow!("Failed to get writable stats after cache cleanup: {error}")
966 })?
967 .total_bytes;
968 cleanup.complete(after_usage);
969 }
970 result.map(|progress| progress.freed_bytes)
971 }
972
973 pub(super) fn prepare_cached_blob_write(
974 &self,
975 incoming_bytes: u64,
976 hashes: Vec<Hash>,
977 force_cleanup: bool,
978 ) -> Result<CacheWritePermit<'_>> {
979 if let Some(denial) = self.cache_quota.quick_denial() {
980 anyhow::bail!(denial.to_string());
981 }
982
983 let observed_usage = if self.max_size_bytes == 0 {
984 0
985 } else {
986 self.router
987 .writable_stats()
988 .map_err(|error| anyhow::anyhow!("Failed to get writable stats: {error}"))?
989 .total_bytes
990 };
991 match self
992 .cache_quota
993 .begin_admission(
994 observed_usage,
995 incoming_bytes,
996 hashes,
997 self.max_size_bytes,
998 force_cleanup,
999 )
1000 .map_err(|denial| anyhow::anyhow!(denial.to_string()))?
1001 {
1002 CacheQuotaAdmission::Admitted(permit) => Ok(permit),
1003 CacheQuotaAdmission::Cleanup(cleanup) => {
1004 let progress = self.evict_disposable_orphans_to_target_raw(
1005 cleanup.target_bytes(),
1006 cleanup.inflight_hashes(),
1007 )?;
1008 let after_usage = self
1009 .router
1010 .writable_stats()
1011 .map_err(|error| {
1012 anyhow::anyhow!("Failed to get writable stats after cache cleanup: {error}")
1013 })?
1014 .total_bytes;
1015 cleanup
1016 .complete(after_usage, progress.freed_bytes, progress.sweep_complete)
1017 .map_err(|denial| anyhow::anyhow!(denial.to_string()))
1018 }
1019 }
1020 }
1021
1022 pub fn cache_cleanup_epoch_count(&self) -> u64 {
1027 self.cache_quota.cleanup_epoch_count()
1028 }
1029
1030 pub fn make_room_for_cached_blob(&self, incoming_bytes: u64) -> Result<u64> {
1031 if self.max_size_bytes == 0 {
1032 return Ok(0);
1033 }
1034
1035 let stats = self
1036 .router
1037 .writable_stats()
1038 .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1039 if stats.total_bytes.saturating_add(incoming_bytes) <= self.max_size_bytes {
1040 return Ok(0);
1041 }
1042
1043 let target = if incoming_bytes >= self.max_size_bytes {
1044 0
1045 } else {
1046 (self.max_size_bytes.saturating_mul(9) / 10)
1047 .min(self.max_size_bytes.saturating_sub(incoming_bytes))
1048 };
1049 self.evict_disposable_orphans_to_target(target)
1050 }
1051
1052 pub fn enforce_cached_blob_budget_after_insert(&self, inserted_bytes: u64) -> Result<u64> {
1053 if self.max_size_bytes == 0 || inserted_bytes == 0 {
1054 return Ok(0);
1055 }
1056
1057 let stats = self
1058 .router
1059 .writable_stats()
1060 .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1061 if stats.total_bytes <= self.max_size_bytes {
1062 return Ok(0);
1063 }
1064
1065 let target = if inserted_bytes >= self.max_size_bytes {
1066 inserted_bytes
1067 } else {
1068 (self.max_size_bytes.saturating_mul(9) / 10)
1069 .saturating_add(inserted_bytes)
1070 .min(self.max_size_bytes)
1071 };
1072 self.evict_disposable_orphans_to_target(target)
1073 }
1074
1075 pub fn make_room_for_durable_blob(&self, incoming_bytes: u64) -> Result<u64> {
1076 if self.max_size_bytes == 0 || incoming_bytes == 0 {
1077 return Ok(0);
1078 }
1079
1080 if incoming_bytes > self.max_size_bytes {
1081 anyhow::bail!(
1082 "storage limit exceeded: incoming blob is {} bytes but limit is {} bytes",
1083 incoming_bytes,
1084 self.max_size_bytes
1085 );
1086 }
1087
1088 let stats = self
1089 .router
1090 .writable_stats()
1091 .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1092 if stats.total_bytes.saturating_add(incoming_bytes) <= self.max_size_bytes {
1093 return Ok(0);
1094 }
1095
1096 let target = (self.max_size_bytes.saturating_mul(9) / 10)
1097 .min(self.max_size_bytes.saturating_sub(incoming_bytes));
1098 let freed = self.evict_with_policy_to_target(stats.total_bytes, target)?;
1099
1100 let next_stats = self
1101 .router
1102 .writable_stats()
1103 .map_err(|e| anyhow::anyhow!("Failed to get writable stats after eviction: {}", e))?;
1104 if next_stats.total_bytes.saturating_add(incoming_bytes) > self.max_size_bytes {
1105 anyhow::bail!(
1106 "storage limit exceeded: {} bytes used, {} byte incoming blob, {} byte limit",
1107 next_stats.total_bytes,
1108 incoming_bytes,
1109 self.max_size_bytes
1110 );
1111 }
1112
1113 Ok(freed)
1114 }
1115
1116 pub fn enforce_durable_blob_budget_after_insert(&self, inserted_bytes: u64) -> Result<u64> {
1117 if self.max_size_bytes == 0 || inserted_bytes == 0 {
1118 return Ok(0);
1119 }
1120
1121 if inserted_bytes > self.max_size_bytes {
1122 anyhow::bail!(
1123 "storage limit exceeded: inserted blobs are {} bytes but limit is {} bytes",
1124 inserted_bytes,
1125 self.max_size_bytes
1126 );
1127 }
1128
1129 let stats = self
1130 .router
1131 .writable_stats()
1132 .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1133 if stats.total_bytes <= self.max_size_bytes {
1134 return Ok(0);
1135 }
1136
1137 let target = (self.max_size_bytes.saturating_mul(9) / 10)
1138 .saturating_add(inserted_bytes)
1139 .min(self.max_size_bytes);
1140 let freed = self.evict_with_policy_to_target(stats.total_bytes, target)?;
1141
1142 let next_stats = self
1143 .router
1144 .writable_stats()
1145 .map_err(|e| anyhow::anyhow!("Failed to get writable stats after eviction: {}", e))?;
1146 if next_stats.total_bytes > self.max_size_bytes {
1147 anyhow::bail!(
1148 "storage limit exceeded: {} bytes used after inserting {} bytes, {} byte limit",
1149 next_stats.total_bytes,
1150 inserted_bytes,
1151 self.max_size_bytes
1152 );
1153 }
1154
1155 Ok(freed)
1156 }
1157
1158 pub fn relieve_cached_blob_write_pressure(&self, incoming_bytes: u64) -> Result<u64> {
1159 let stats = self
1160 .router
1161 .writable_stats()
1162 .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1163 if stats.total_bytes == 0 {
1164 return Ok(0);
1165 }
1166
1167 let headroom = incoming_bytes.max(stats.total_bytes / 10).max(1);
1168 let target = stats.total_bytes.saturating_sub(headroom);
1169 self.evict_disposable_orphans_to_target(target)
1170 }
1171
1172 pub fn pin(&self, hash: &[u8; 32]) -> Result<()> {
1174 let mut wtxn = self.env.write_txn()?;
1175 self.pins.put(&mut wtxn, hash.as_slice(), &())?;
1176 wtxn.commit()?;
1177 Ok(())
1178 }
1179
1180 pub fn unpin(&self, hash: &[u8; 32]) -> Result<()> {
1182 let mut wtxn = self.env.write_txn()?;
1183 self.pins.delete(&mut wtxn, hash.as_slice())?;
1184 wtxn.commit()?;
1185 Ok(())
1186 }
1187
1188 pub fn is_pinned(&self, hash: &[u8; 32]) -> Result<bool> {
1190 let rtxn = self.env.read_txn()?;
1191 Ok(self.pins.get(&rtxn, hash.as_slice())?.is_some())
1192 }
1193
1194 pub fn list_pins_raw(&self) -> Result<Vec<[u8; 32]>> {
1196 let rtxn = self.env.read_txn()?;
1197 let mut pins = Vec::new();
1198
1199 for item in self.pins.iter(&rtxn)? {
1200 let (hash_bytes, _) = item?;
1201 if hash_bytes.len() == 32 {
1202 let mut hash = [0u8; 32];
1203 hash.copy_from_slice(hash_bytes);
1204 pins.push(hash);
1205 }
1206 }
1207
1208 Ok(pins)
1209 }
1210
1211 pub fn list_pins_with_names(&self) -> Result<Vec<PinnedItem>> {
1213 let rtxn = self.env.read_txn()?;
1214 let store = self.store_arc();
1215 let tree = HashTree::new(HashTreeConfig::new(store).public());
1216 let mut pins = Vec::new();
1217
1218 for item in self.pins.iter(&rtxn)? {
1219 let (hash_bytes, _) = item?;
1220 if hash_bytes.len() != 32 {
1221 continue;
1222 }
1223 let mut hash = [0u8; 32];
1224 hash.copy_from_slice(hash_bytes);
1225
1226 let is_directory =
1228 sync_block_on(async { tree.is_directory(&hash).await.unwrap_or(false) });
1229
1230 let meta = self
1231 .tree_meta
1232 .get(&rtxn, hash.as_slice())?
1233 .map(|bytes| {
1234 rmp_serde::from_slice::<TreeMeta>(bytes)
1235 .map_err(|e| anyhow::anyhow!("Failed to deserialize TreeMeta: {}", e))
1236 })
1237 .transpose()?;
1238 let size_bytes = if let Some(meta) = meta.as_ref() {
1239 meta.total_size
1240 } else {
1241 self.router
1242 .blob_size_sync(&hash)
1243 .map_err(|e| anyhow::anyhow!("Failed to get pinned blob size: {}", e))?
1244 .unwrap_or(0)
1245 };
1246
1247 pins.push(PinnedItem {
1248 cid: to_hex(&hash),
1249 name: pinned_item_name(&hash, meta.as_ref()),
1250 is_directory,
1251 size_bytes,
1252 });
1253 }
1254
1255 Ok(pins)
1256 }
1257
1258 pub fn owned_blob_stats(&self) -> Result<Vec<OwnedBlobStats>> {
1259 let rtxn = self.env.read_txn()?;
1260 let mut owners = Vec::new();
1261
1262 for item in self.pubkey_blobs.iter(&rtxn)? {
1263 let (owner_bytes, blobs_bytes) = item?;
1264 if owner_bytes.len() != 32 {
1265 continue;
1266 }
1267
1268 let blobs: Vec<BlobMetadata> = serde_json::from_slice(blobs_bytes)
1269 .map_err(|e| anyhow::anyhow!("Failed to deserialize blob metadata: {}", e))?;
1270 let mut owner = [0u8; 32];
1271 owner.copy_from_slice(owner_bytes);
1272 let total_bytes = blobs
1273 .iter()
1274 .fold(0u64, |total, blob| total.saturating_add(blob.size));
1275 owners.push(OwnedBlobStats {
1276 owner,
1277 count: blobs.len(),
1278 total_bytes,
1279 });
1280 }
1281
1282 owners.sort_by_key(|stats| stats.owner);
1283 Ok(owners)
1284 }
1285
1286 pub fn tree_index_limits(&self) -> TreeIndexLimits {
1292 TreeIndexLimits {
1293 max_nodes: MAX_PINNED_TREE_NODES,
1294 max_bytes: if self.max_size_bytes == 0 {
1295 MAX_UNBOUNDED_PINNED_TREE_BYTES
1296 } else {
1297 self.max_size_bytes
1298 },
1299 }
1300 }
1301
1302 pub fn pin_and_index_tree(
1306 &self,
1307 root: &Cid,
1308 owner: &str,
1309 name: Option<&str>,
1310 priority: u8,
1311 limits: TreeIndexLimits,
1312 ) -> std::result::Result<PinTreeResult, PinTreeError> {
1313 let store = self.store_arc();
1314 let tree = HashTree::new(HashTreeConfig::new(store).public());
1315 let plan = sync_block_on(self.collect_tree_index(&tree, root, limits))?;
1316 let already_pinned = self
1317 .write_tree_index(
1318 &root.hash,
1319 &plan.tracked_hashes,
1320 plan.total_size,
1321 owner,
1322 name,
1323 priority,
1324 None,
1325 true,
1326 )
1327 .map_err(|error| PinTreeError::Storage(error.to_string()))?;
1328
1329 Ok(PinTreeResult {
1330 indexed_hashes: plan.tracked_hashes.len(),
1331 total_size: plan.total_size,
1332 already_pinned,
1333 })
1334 }
1335
1336 pub fn index_tree(
1341 &self,
1342 root_hash: &Hash,
1343 owner: &str,
1344 name: Option<&str>,
1345 priority: u8,
1346 ref_key: Option<&str>,
1347 ) -> Result<()> {
1348 let root_hex = to_hex(root_hash);
1349
1350 if let Some(key) = ref_key {
1352 let rtxn = self.env.read_txn()?;
1353 if let Some(old_hash_bytes) = self.tree_refs.get(&rtxn, key)? {
1354 if old_hash_bytes != root_hash.as_slice() {
1355 let old_hash: Hash = old_hash_bytes
1356 .try_into()
1357 .map_err(|_| anyhow::anyhow!("Invalid hash in tree_refs"))?;
1358 drop(rtxn);
1359 let _ = self.unpin(&old_hash);
1360 let _ = self.unindex_tree(&old_hash);
1362 tracing::debug!("Replaced old tree for ref {}", key);
1363 }
1364 }
1365 }
1366
1367 let store = self.store_arc();
1368 let tree = HashTree::new(HashTreeConfig::new(store).public());
1369
1370 let plan = sync_block_on(self.collect_tree_index(
1371 &tree,
1372 &Cid::public(*root_hash),
1373 TreeIndexLimits {
1374 max_nodes: MAX_PINNED_TREE_NODES,
1375 max_bytes: MAX_UNBOUNDED_PINNED_TREE_BYTES,
1376 },
1377 ))?;
1378 self.write_tree_index(
1379 root_hash,
1380 &plan.tracked_hashes,
1381 plan.total_size,
1382 owner,
1383 name,
1384 priority,
1385 ref_key,
1386 false,
1387 )?;
1388
1389 tracing::debug!(
1390 "Indexed tree {} ({} blobs, {} bytes, priority {})",
1391 &root_hex[..8],
1392 plan.tracked_hashes.len(),
1393 plan.total_size,
1394 priority
1395 );
1396
1397 Ok(())
1398 }
1399
1400 #[allow(clippy::too_many_arguments)]
1401 fn write_tree_index(
1402 &self,
1403 root_hash: &Hash,
1404 tracked_hashes: &HashSet<Hash>,
1405 total_size: u64,
1406 owner: &str,
1407 name: Option<&str>,
1408 priority: u8,
1409 ref_key: Option<&str>,
1410 pin: bool,
1411 ) -> Result<bool> {
1412 let mut wtxn = self.env.write_txn()?;
1413 let already_pinned = self.pins.get(&wtxn, root_hash.as_slice())?.is_some();
1414
1415 for tracked_hash in tracked_hashes {
1416 let mut key = [0u8; 64];
1417 key[..32].copy_from_slice(tracked_hash);
1418 key[32..].copy_from_slice(root_hash);
1419 self.blob_trees.put(&mut wtxn, &key[..], &())?;
1420 }
1421
1422 let meta = TreeMeta {
1423 owner: owner.to_string(),
1424 name: name.map(str::to_string),
1425 synced_at: unix_timestamp_now(),
1426 total_size,
1427 priority,
1428 };
1429 let meta_bytes = rmp_serde::to_vec(&meta)
1430 .map_err(|error| anyhow::anyhow!("Failed to serialize TreeMeta: {error}"))?;
1431 self.tree_meta
1432 .put(&mut wtxn, root_hash.as_slice(), &meta_bytes)?;
1433
1434 if let Some(key) = ref_key {
1435 self.tree_refs.put(&mut wtxn, key, root_hash.as_slice())?;
1436 }
1437 if pin {
1438 self.pins.put(&mut wtxn, root_hash.as_slice(), &())?;
1439 }
1440
1441 wtxn.commit()?;
1442 Ok(already_pinned)
1443 }
1444
1445 async fn collect_tree_index<S: Store>(
1446 &self,
1447 tree: &HashTree<S>,
1448 root: &Cid,
1449 limits: TreeIndexLimits,
1450 ) -> std::result::Result<TreeIndexPlan, PinTreeError> {
1451 let mut hashes = HashSet::new();
1452 let mut visited = HashSet::new();
1453 let mut total_size = 0u64;
1454 let mut stored_size = 0u64;
1455 let mut stack = vec![(root.clone(), true, true, false)];
1457
1458 while let Some((cid, count_bytes, follow_tree, require_tree)) = stack.pop() {
1459 let visit_key = (cid.hash, cid.key, follow_tree);
1460 if !visited.insert(visit_key) {
1461 continue;
1462 }
1463 if visited.len() > limits.max_nodes {
1464 return Err(PinTreeError::NodeLimitExceeded {
1465 max_nodes: limits.max_nodes,
1466 });
1467 }
1468
1469 let missing = || {
1470 if cid.hash == root.hash {
1471 PinTreeError::MissingRoot {
1472 hash: to_hex(&cid.hash),
1473 }
1474 } else {
1475 PinTreeError::MissingDescendant {
1476 hash: to_hex(&cid.hash),
1477 }
1478 }
1479 };
1480 let size = self
1481 .router
1482 .blob_size_sync(&cid.hash)
1483 .map_err(|error| PinTreeError::Storage(error.to_string()))?
1484 .ok_or_else(missing)?;
1485 if hashes.insert(cid.hash) {
1486 stored_size = stored_size
1487 .checked_add(size)
1488 .filter(|size| *size <= limits.max_bytes)
1489 .ok_or(PinTreeError::ByteLimitExceeded {
1490 max_bytes: limits.max_bytes,
1491 })?;
1492 }
1493
1494 let data = tree
1498 .get_blob(&cid.hash)
1499 .await
1500 .map_err(|error| PinTreeError::Storage(error.to_string()))?
1501 .ok_or_else(missing)?;
1502 if data.len() as u64 != size || sha256(&data) != cid.hash {
1503 return Err(PinTreeError::InvalidDag {
1504 hash: to_hex(&cid.hash),
1505 message: "stored payload does not match its size or content hash".to_string(),
1506 });
1507 }
1508 if !follow_tree {
1509 continue;
1510 }
1511
1512 let node =
1513 decode_tree_node_by_cid(&cid, data).map_err(|error| PinTreeError::InvalidDag {
1514 hash: to_hex(&cid.hash),
1515 message: error.to_string(),
1516 })?;
1517 let Some(node) = node else {
1518 if require_tree {
1519 return Err(PinTreeError::InvalidDag {
1520 hash: to_hex(&cid.hash),
1521 message: "directory link does not contain a tree node".to_string(),
1522 });
1523 }
1524 if count_bytes {
1525 total_size = total_size
1526 .checked_add(size)
1527 .filter(|size| *size <= limits.max_bytes)
1528 .ok_or(PinTreeError::ByteLimitExceeded {
1529 max_bytes: limits.max_bytes,
1530 })?;
1531 }
1532 continue;
1533 };
1534
1535 if visited
1536 .len()
1537 .saturating_add(stack.len())
1538 .saturating_add(node.links.len())
1539 > limits.max_nodes
1540 {
1541 return Err(PinTreeError::NodeLimitExceeded {
1542 max_nodes: limits.max_nodes,
1543 });
1544 }
1545
1546 for link in &node.links {
1547 match link.link_type {
1548 LinkType::Blob => {
1549 if count_bytes {
1550 total_size = total_size
1551 .checked_add(link.size)
1552 .filter(|size| *size <= limits.max_bytes)
1553 .ok_or(PinTreeError::ByteLimitExceeded {
1554 max_bytes: limits.max_bytes,
1555 })?;
1556 }
1557 stack.push((link.to_cid(), false, false, false));
1558 }
1559 LinkType::File => {
1560 if count_bytes {
1561 total_size = total_size
1562 .checked_add(link.size)
1563 .filter(|size| *size <= limits.max_bytes)
1564 .ok_or(PinTreeError::ByteLimitExceeded {
1565 max_bytes: limits.max_bytes,
1566 })?;
1567 }
1568 stack.push((link.to_cid(), false, true, false));
1569 }
1570 LinkType::Dir | LinkType::Fanout => {
1571 stack.push((link.to_cid(), count_bytes, true, true));
1572 }
1573 }
1574 }
1575 }
1576
1577 Ok(TreeIndexPlan {
1578 tracked_hashes: hashes,
1579 total_size,
1580 })
1581 }
1582
1583 pub fn unindex_tree(&self, root_hash: &Hash) -> Result<u64> {
1586 let cleanup = self
1587 .cache_quota
1588 .begin_standalone_cleanup()
1589 .map_err(|denial| anyhow::anyhow!(denial.to_string()))?;
1590 let retention = self.active_retention_protection()?;
1591 let mut protected = cleanup.inflight_hashes().clone();
1592 protected.extend(retention.hashes().iter().copied());
1593 let result = self.unindex_tree_raw(root_hash, &protected);
1594 if result.is_ok() {
1595 let after_usage = self
1596 .router
1597 .writable_stats()
1598 .map_err(|error| {
1599 anyhow::anyhow!("Failed to get writable stats after tree unindex: {error}")
1600 })?
1601 .total_bytes;
1602 cleanup.complete(after_usage);
1603 }
1604 result
1605 }
1606
1607 fn unindex_tree_raw(
1608 &self,
1609 root_hash: &Hash,
1610 additional_protected: &HashSet<Hash>,
1611 ) -> Result<u64> {
1612 let root_hex = to_hex(root_hash);
1613
1614 let store = self.store_arc();
1615 let tree = HashTree::new(HashTreeConfig::new(store).public());
1616
1617 let tracked_hashes =
1619 sync_block_on(self.collect_tree_hashes(&tree, &Cid::public(*root_hash), false))?;
1620
1621 let mut wtxn = self.env.write_txn()?;
1622 let mut freed = 0u64;
1623
1624 for tracked_hash in &tracked_hashes {
1626 let mut key = [0u8; 64];
1628 key[..32].copy_from_slice(tracked_hash);
1629 key[32..].copy_from_slice(root_hash);
1630 self.blob_trees.delete(&mut wtxn, &key[..])?;
1631
1632 let mut has_other_tree = false;
1634 for item in self.blob_trees.prefix_iter(&wtxn, &tracked_hash[..])? {
1635 if item.is_ok() {
1636 has_other_tree = true;
1637 break;
1638 }
1639 }
1640
1641 let has_owner = self
1642 .blob_owners
1643 .prefix_iter(&wtxn, tracked_hash.as_slice())?
1644 .next()
1645 .transpose()?
1646 .is_some();
1647
1648 if !has_other_tree && !has_owner && !additional_protected.contains(tracked_hash) {
1651 let Some(_delete_guard) = self.cache_quota.begin_retention_delete(*tracked_hash)
1652 else {
1653 continue;
1654 };
1655 if let Some(size) = self
1656 .router
1657 .blob_size_sync(tracked_hash)
1658 .map_err(|e| anyhow::anyhow!("Failed to get blob size: {}", e))?
1659 {
1660 freed += size;
1661 self.router
1663 .delete_local_only(tracked_hash)
1664 .map_err(|e| anyhow::anyhow!("Failed to delete blob: {}", e))?;
1665 }
1666 }
1667 }
1668
1669 self.tree_meta.delete(&mut wtxn, root_hash.as_slice())?;
1671
1672 wtxn.commit()?;
1673
1674 tracing::debug!("Unindexed tree {} ({} bytes freed)", &root_hex[..8], freed);
1675
1676 Ok(freed)
1677 }
1678
1679 pub fn get_tree_meta(&self, root_hash: &Hash) -> Result<Option<TreeMeta>> {
1681 let rtxn = self.env.read_txn()?;
1682 if let Some(bytes) = self.tree_meta.get(&rtxn, root_hash.as_slice())? {
1683 let meta: TreeMeta = rmp_serde::from_slice(bytes)
1684 .map_err(|e| anyhow::anyhow!("Failed to deserialize TreeMeta: {}", e))?;
1685 Ok(Some(meta))
1686 } else {
1687 Ok(None)
1688 }
1689 }
1690
1691 pub fn get_tree_ref(&self, key: &str) -> Result<Option<Hash>> {
1692 let rtxn = self.env.read_txn()?;
1693 let Some(bytes) = self.tree_refs.get(&rtxn, key)? else {
1694 return Ok(None);
1695 };
1696
1697 let hash: Hash = bytes
1698 .try_into()
1699 .map_err(|_| anyhow::anyhow!("Invalid hash in tree_refs"))?;
1700 Ok(Some(hash))
1701 }
1702
1703 pub fn list_indexed_trees(&self) -> Result<Vec<(Hash, TreeMeta)>> {
1705 let rtxn = self.env.read_txn()?;
1706 let mut trees = Vec::new();
1707
1708 for item in self.tree_meta.iter(&rtxn)? {
1709 let (hash_bytes, meta_bytes) = item?;
1710 let hash: Hash = hash_bytes
1711 .try_into()
1712 .map_err(|_| anyhow::anyhow!("Invalid hash in tree_meta"))?;
1713 let meta: TreeMeta = rmp_serde::from_slice(meta_bytes)
1714 .map_err(|e| anyhow::anyhow!("Failed to deserialize TreeMeta: {}", e))?;
1715 trees.push((hash, meta));
1716 }
1717
1718 Ok(trees)
1719 }
1720
1721 pub fn tracked_size(&self) -> Result<u64> {
1723 let rtxn = self.env.read_txn()?;
1724 let mut total = 0u64;
1725
1726 for item in self.tree_meta.iter(&rtxn)? {
1727 let (_, bytes) = item?;
1728 let meta: TreeMeta = rmp_serde::from_slice(bytes)
1729 .map_err(|e| anyhow::anyhow!("Failed to deserialize TreeMeta: {}", e))?;
1730 total += meta.total_size;
1731 }
1732
1733 Ok(total)
1734 }
1735
1736 fn get_evictable_trees(&self) -> Result<Vec<(Hash, TreeMeta)>> {
1742 let mut trees = self.list_indexed_trees()?;
1743
1744 trees.sort_by(|a, b| match a.1.priority.cmp(&b.1.priority) {
1746 std::cmp::Ordering::Equal => a.1.synced_at.cmp(&b.1.synced_at),
1747 other => other,
1748 });
1749
1750 Ok(trees)
1751 }
1752
1753 pub fn evict_if_needed(&self) -> Result<u64> {
1760 let stats = self
1762 .router
1763 .writable_stats()
1764 .map_err(|e| anyhow::anyhow!("Failed to get writable stats: {}", e))?;
1765 let current = stats.total_bytes;
1766
1767 if current <= self.max_size_bytes {
1768 return Ok(0);
1769 }
1770
1771 let target = self.max_size_bytes * 90 / 100;
1773 self.evict_with_policy_to_target(current, target)
1774 }
1775
1776 fn evict_with_policy_to_target(&self, current: u64, target: u64) -> Result<u64> {
1777 let cleanup = self
1778 .cache_quota
1779 .begin_standalone_cleanup()
1780 .map_err(|denial| anyhow::anyhow!(denial.to_string()))?;
1781 let mut freed = 0u64;
1782 let mut current_size = current;
1783
1784 if self.evict_orphans {
1786 let orphan_progress =
1787 self.evict_disposable_orphans_to_target_raw(target, cleanup.inflight_hashes())?;
1788 freed += orphan_progress.freed_bytes;
1789 current_size = current_size.saturating_sub(orphan_progress.freed_bytes);
1790
1791 if orphan_progress.freed_bytes > 0 {
1792 tracing::info!(
1793 "Evicted orphaned blobs: {} bytes freed",
1794 orphan_progress.freed_bytes
1795 );
1796 }
1797
1798 if current_size > target && !orphan_progress.sweep_complete {
1802 let after_usage = self
1803 .router
1804 .writable_stats()
1805 .map_err(|error| {
1806 anyhow::anyhow!(
1807 "Failed to get writable stats after bounded orphan cleanup: {error}"
1808 )
1809 })?
1810 .total_bytes;
1811 cleanup.complete(after_usage);
1812 return Ok(freed);
1813 }
1814 } else {
1815 tracing::debug!("Skipping orphan blob eviction; storage.evict_orphans=false");
1816 }
1817
1818 if current_size <= target {
1820 if freed > 0 {
1821 tracing::info!("Eviction complete: {} bytes freed", freed);
1822 }
1823 let after_usage = self
1824 .router
1825 .writable_stats()
1826 .map_err(|error| {
1827 anyhow::anyhow!("Failed to get writable stats after retention cleanup: {error}")
1828 })?
1829 .total_bytes;
1830 cleanup.complete(after_usage);
1831 return Ok(freed);
1832 }
1833
1834 let retention = self.active_retention_protection()?;
1837 let retention_protected = retention.hashes();
1838 let mut additional_protected = cleanup.inflight_hashes().clone();
1839 additional_protected.extend(retention_protected.iter().copied());
1840 let evictable = self.get_evictable_trees()?;
1841
1842 for (root_hash, meta) in evictable {
1843 if current_size <= target {
1844 break;
1845 }
1846
1847 let root_hex = to_hex(&root_hash);
1848
1849 if self.is_pinned(&root_hash)? {
1851 continue;
1852 }
1853 if retention_protected.contains(&root_hash) {
1854 continue;
1855 }
1856
1857 let tree_freed = self.unindex_tree_raw(&root_hash, &additional_protected)?;
1858 freed += tree_freed;
1859 current_size = current_size.saturating_sub(tree_freed);
1860
1861 tracing::info!(
1862 "Evicted tree {} (owner={}, priority={}, {} bytes)",
1863 &root_hex[..8],
1864 &meta.owner[..8.min(meta.owner.len())],
1865 meta.priority,
1866 tree_freed
1867 );
1868 }
1869
1870 if freed > 0 {
1871 tracing::info!("Eviction complete: {} bytes freed", freed);
1872 }
1873
1874 let after_usage = self
1875 .router
1876 .writable_stats()
1877 .map_err(|error| {
1878 anyhow::anyhow!("Failed to get writable stats after retention cleanup: {error}")
1879 })?
1880 .total_bytes;
1881 cleanup.complete(after_usage);
1882 Ok(freed)
1883 }
1884
1885 pub fn max_size_bytes(&self) -> u64 {
1887 self.max_size_bytes
1888 }
1889
1890 pub fn storage_by_priority(&self) -> Result<StorageByPriority> {
1892 let rtxn = self.env.read_txn()?;
1893 let mut own = 0u64;
1894 let mut followed = 0u64;
1895 let mut other = 0u64;
1896
1897 for item in self.tree_meta.iter(&rtxn)? {
1898 let (_, bytes) = item?;
1899 let meta: TreeMeta = rmp_serde::from_slice(bytes)
1900 .map_err(|e| anyhow::anyhow!("Failed to deserialize TreeMeta: {}", e))?;
1901
1902 if meta.priority == PRIORITY_OWN {
1903 own += meta.total_size;
1904 } else if meta.priority >= PRIORITY_FOLLOWED {
1905 followed += meta.total_size;
1906 } else {
1907 other += meta.total_size;
1908 }
1909 }
1910
1911 Ok(StorageByPriority {
1912 own,
1913 followed,
1914 other,
1915 })
1916 }
1917
1918 pub fn get_storage_stats(&self) -> Result<StorageStats> {
1920 let rtxn = self.env.read_txn()?;
1921 let total_pins = self.pins.len(&rtxn)? as usize;
1922
1923 let stats = self
1924 .router
1925 .stats()
1926 .map_err(|e| anyhow::anyhow!("Failed to get stats: {}", e))?;
1927
1928 Ok(StorageStats {
1929 total_dags: stats.count,
1930 pinned_dags: total_pins,
1931 total_bytes: stats.total_bytes,
1932 })
1933 }
1934}
1935
1936#[cfg(test)]
1937mod tests {
1938 use super::*;
1939 use hashtree_config::StorageBackend;
1940 use hashtree_core::Cid;
1941 use hashtree_index::{BTree, BTreeOptions};
1942 use nostr::{EventBuilder, Keys, Kind, Timestamp};
1943 use std::io::Write;
1944 use std::path::Path;
1945 use tempfile::TempDir;
1946
1947 use crate::storage::PRIORITY_OTHER;
1948
1949 #[cfg(unix)]
1950 #[test]
1951 fn existing_profile_repair_retention_guard_never_creates_its_authority() {
1952 let temp_dir = TempDir::new().expect("temp dir");
1953 let lock_path = temp_dir.path().join(RETENTION_ROOTS_LOCK_FILE);
1954
1955 let error = acquire_existing_profile_repair_retention_guard(temp_dir.path())
1956 .err()
1957 .expect("missing authority must fail");
1958 assert!(error
1959 .to_string()
1960 .contains("inspect existing retention-roots lock"));
1961 assert!(
1962 !lock_path.exists(),
1963 "recovery guard created a missing lock authority"
1964 );
1965
1966 std::fs::write(&lock_path, b"existing-authority").expect("create existing lock authority");
1967 let before = std::fs::metadata(&lock_path).expect("inspect existing lock authority");
1968 let guard = acquire_existing_profile_repair_retention_guard(temp_dir.path())
1969 .expect("acquire existing lock authority");
1970 let after = std::fs::metadata(&lock_path).expect("reinspect existing lock authority");
1971 assert_eq!(before.len(), after.len());
1972 assert_eq!(
1973 std::fs::read(&lock_path).expect("read existing lock authority"),
1974 b"existing-authority"
1975 );
1976 drop(guard);
1977 }
1978
1979 fn write_root_file(path: &Path, cid: &Cid) {
1980 #[derive(Serialize)]
1981 struct StoredCid {
1982 hash: [u8; 32],
1983 key: Option<[u8; 32]>,
1984 }
1985
1986 std::fs::create_dir_all(path.parent().expect("root file parent")).expect("create dir");
1987 let bytes = rmp_serde::to_vec_named(&StoredCid {
1988 hash: cid.hash,
1989 key: cid.key,
1990 })
1991 .expect("encode cid");
1992 std::fs::write(path, bytes).expect("write root file");
1993 }
1994
1995 fn build_test_tree(store: &HashtreeStore) -> Cid {
1996 let index = BTree::new(store.store_arc(), BTreeOptions { order: Some(8) });
1997 sync_block_on(index.build(vec![
1998 ("alpha".to_string(), "one".to_string()),
1999 ("beta".to_string(), "two".to_string()),
2000 ("gamma".to_string(), "three".to_string()),
2001 ]))
2002 .expect("build btree")
2003 .expect("non-empty root")
2004 }
2005
2006 fn build_deep_test_tree(store: &HashtreeStore) -> Cid {
2007 let index = BTree::new(store.store_arc(), BTreeOptions { order: Some(4) });
2008 let entries: Vec<_> = (0..256)
2009 .map(|index| (format!("key-{index:04}"), format!("value-{index:04}")))
2010 .collect();
2011 sync_block_on(index.build(entries))
2012 .expect("build deep btree")
2013 .expect("non-empty deep root")
2014 }
2015
2016 fn build_generated_encrypted_tree(
2017 store: &HashtreeStore,
2018 namespace: &str,
2019 entry_count: usize,
2020 ) -> Cid {
2021 let index = BTree::new(store.store_arc(), BTreeOptions { order: Some(4) });
2022 let entries: Vec<_> = (0..entry_count)
2023 .map(|index| {
2024 (
2025 format!("{namespace}-key-{index:04}"),
2026 format!("{namespace}-value-{index:04}"),
2027 )
2028 })
2029 .collect();
2030 let root = sync_block_on(index.build(entries))
2031 .expect("build generated encrypted btree")
2032 .expect("non-empty generated encrypted root");
2033 assert!(
2034 root.key.is_some(),
2035 "generated retention coverage must exercise a full encrypted CID"
2036 );
2037 root
2038 }
2039
2040 fn generated_tree_hashes(store: &HashtreeStore, root: &Cid) -> HashSet<Hash> {
2041 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
2042 let hashes = sync_block_on(store.collect_tree_hashes(&tree, root, true))
2043 .expect("collect generated encrypted DAG");
2044 assert!(hashes.len() > 2, "generated DAG must contain descendants");
2045 hashes
2046 }
2047
2048 fn publish_generated_profile_repair_lease(store: &HashtreeStore, label: &str, root: &Cid) {
2049 let lease = ProfileRepairRetentionLease {
2050 format: PROFILE_REPAIR_RETENTION_LEASE_FORMAT.to_string(),
2051 authority_sha256: to_hex(&hashtree_core::sha256(root.to_string().as_bytes())),
2052 roots: BTreeMap::from([(label.to_string(), root.to_string())]),
2053 };
2054 let lease_bytes = lease.canonical_bytes().expect("canonical generated lease");
2055 let publication = store
2056 .acquire_profile_repair_retention_publication_guard()
2057 .expect("exclusive generated retention publication");
2058 let lease_path = store.profile_repair_retention_lease_path();
2059 std::fs::create_dir_all(lease_path.parent().expect("lease parent"))
2060 .expect("create generated lease parent");
2061 let mut lease_file = File::create(&lease_path).expect("create generated lease");
2062 lease_file
2063 .write_all(&lease_bytes)
2064 .expect("write generated lease");
2065 lease_file.sync_all().expect("sync generated lease");
2066 File::open(lease_path.parent().expect("lease parent"))
2067 .expect("open generated lease parent")
2068 .sync_all()
2069 .expect("sync generated lease parent");
2070 drop(publication);
2071 }
2072
2073 #[cfg(feature = "lmdb")]
2074 fn bounded_lmdb_store(path: &Path, max_size_bytes: u64) -> HashtreeStore {
2075 drop(
2079 super::super::LocalStore::new_unbounded_with_lmdb_map_size(
2080 path.join("blobs"),
2081 &StorageBackend::Lmdb,
2082 Some(16 * 1024 * 1024),
2083 )
2084 .expect("seed single LMDB"),
2085 );
2086 HashtreeStore::with_options_and_backend(
2087 path,
2088 None,
2089 max_size_bytes,
2090 true,
2091 &StorageBackend::Lmdb,
2092 )
2093 .expect("LMDB store")
2094 }
2095
2096 #[cfg(feature = "lmdb")]
2097 fn put_ordered_hashes(store: &HashtreeStore, count: u8) -> Vec<Hash> {
2098 let mut hashes = Vec::new();
2099 for value in 1..=count {
2100 let data = [value];
2101 let hash = hashtree_core::sha256(&data);
2102 store
2103 .router
2104 .put_sync(hash, &data)
2105 .expect("put ordered test hash");
2106 hashes.push(hash);
2107 }
2108 hashes.sort_unstable();
2109 hashes
2110 }
2111
2112 #[cfg(feature = "lmdb")]
2113 #[test]
2114 fn bounded_orphan_sweep_progresses_and_preserves_all_durable_classes() {
2115 let temp_dir = TempDir::new().expect("temp dir");
2116 let store = bounded_lmdb_store(temp_dir.path(), 1024 * 1024);
2117 let hashes = put_ordered_hashes(&store, 7);
2118
2119 store.pin(&hashes[0]).expect("pin first hash");
2120 let mut tree_key = [0u8; 64];
2121 tree_key[..32].copy_from_slice(&hashes[1]);
2122 tree_key[32..].fill(99);
2123 let mut wtxn = store.env.write_txn().expect("metadata write");
2124 store
2125 .blob_trees
2126 .put(&mut wtxn, &tree_key, &())
2127 .expect("index second hash");
2128 wtxn.commit().expect("commit tree index");
2129 store
2130 .set_blob_owner(&hashes[2], &[77; 32])
2131 .expect("own third hash");
2132 let socialgraph_root = build_test_tree(&store);
2133 let socialgraph_hashes = generated_tree_hashes(&store, &socialgraph_root);
2134 write_root_file(
2135 &temp_dir.path().join("socialgraph/events-root.msgpack"),
2136 &socialgraph_root,
2137 );
2138
2139 let mut progress = Vec::new();
2140 loop {
2141 let page = store
2142 .evict_disposable_orphans_page(0, &HashSet::new(), 2)
2143 .expect("bounded orphan page");
2144 assert!(page.scanned <= 2, "page exceeded its candidate bound");
2145 progress.push(page);
2146 if page.sweep_complete {
2147 break;
2148 }
2149 assert!(progress.len() < 50, "bounded sweep did not converge");
2150 }
2151
2152 assert_eq!(progress.iter().map(|page| page.freed_bytes).sum::<u64>(), 4);
2153 assert!(progress.iter().all(|page| page.scanned <= 2));
2154 for hash in &hashes[..3] {
2155 assert!(store.blob_exists(hash).expect("protected blob lookup"));
2156 }
2157 for hash in &hashes[3..] {
2158 assert!(!store.blob_exists(hash).expect("orphan blob lookup"));
2159 }
2160 for hash in socialgraph_hashes {
2161 assert!(
2162 store.blob_exists(&hash).expect("socialgraph blob lookup"),
2163 "bounded sweep deleted generated socialgraph DAG hash {}",
2164 to_hex(&hash)
2165 );
2166 }
2167 }
2168
2169 #[cfg(feature = "lmdb")]
2170 #[test]
2171 fn socialgraph_root_change_unions_protection_until_sweep_boundary() {
2172 let temp_dir = TempDir::new().expect("temp dir");
2173 let store = bounded_lmdb_store(temp_dir.path(), 1024 * 1024);
2174 let old_root = build_generated_encrypted_tree(&store, "old-live-root", 24);
2175 let old_hashes = generated_tree_hashes(&store, &old_root);
2176 let root_path = temp_dir.path().join("socialgraph/events-root.msgpack");
2177 write_root_file(&root_path, &old_root);
2178
2179 let first = store
2180 .evict_disposable_orphans_page(0, &HashSet::new(), 1)
2181 .expect("first root page");
2182 assert_eq!(first.scanned, 1);
2183 assert_eq!(first.freed_bytes, 0);
2184 let new_root = build_generated_encrypted_tree(&store, "new-live-root", 24);
2185 let new_hashes = generated_tree_hashes(&store, &new_root);
2186 write_root_file(&root_path, &new_root);
2187
2188 let second = store
2189 .evict_disposable_orphans_page(0, &HashSet::new(), 1)
2190 .expect("changed root page");
2191 assert_eq!(second.scanned, 1);
2192 assert_eq!(second.freed_bytes, 0);
2193 {
2194 let state = store
2195 .orphan_scan
2196 .lock()
2197 .unwrap_or_else(std::sync::PoisonError::into_inner);
2198 assert!(old_hashes
2199 .iter()
2200 .all(|hash| state.socialgraph_protected.contains(hash)));
2201 assert!(new_hashes
2202 .iter()
2203 .all(|hash| state.socialgraph_protected.contains(hash)));
2204 }
2205
2206 let mut sweep_complete = false;
2207 for _ in 0..256 {
2208 let page = store
2209 .evict_disposable_orphans_page(0, &HashSet::new(), 1)
2210 .expect("finish unioned socialgraph sweep");
2211 assert_eq!(page.freed_bytes, 0);
2212 if page.sweep_complete {
2213 sweep_complete = true;
2214 break;
2215 }
2216 }
2217 assert!(sweep_complete, "unioned socialgraph sweep did not converge");
2218 for hash in old_hashes.iter().chain(new_hashes.iter()) {
2219 assert!(
2220 store.blob_exists(hash).expect("unioned root lookup"),
2221 "active sweep deleted union-protected DAG hash {}",
2222 to_hex(hash)
2223 );
2224 }
2225 }
2226
2227 #[cfg(feature = "lmdb")]
2228 #[test]
2229 fn immutable_profile_repair_lease_preserves_complete_generated_dag() {
2230 let temp_dir = TempDir::new().expect("temp dir");
2231 let store = bounded_lmdb_store(temp_dir.path(), 64 * 1024 * 1024);
2232 let protected_root = build_deep_test_tree(&store);
2233 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
2234 let protected_hashes =
2235 sync_block_on(store.collect_tree_hashes(&tree, &protected_root, true))
2236 .expect("collect generated repair DAG");
2237 assert!(protected_hashes.len() > 2);
2238
2239 let orphan_bytes = b"unleased generated orphan";
2240 let orphan = hashtree_core::sha256(orphan_bytes);
2241 store
2242 .router
2243 .put_sync(orphan, orphan_bytes)
2244 .expect("put generated orphan");
2245
2246 let lease = ProfileRepairRetentionLease {
2247 format: PROFILE_REPAIR_RETENTION_LEASE_FORMAT.to_string(),
2248 authority_sha256: "11".repeat(32),
2249 roots: BTreeMap::from([("profile-search".to_string(), protected_root.to_string())]),
2250 };
2251 let lease_bytes = lease.canonical_bytes().expect("canonical lease");
2252 let publication = store
2253 .acquire_profile_repair_retention_publication_guard()
2254 .expect("exclusive retention publication");
2255 let lease_path = store.profile_repair_retention_lease_path();
2256 std::fs::create_dir_all(lease_path.parent().expect("lease parent"))
2257 .expect("create lease parent");
2258 let mut lease_file = File::create(&lease_path).expect("create lease");
2259 lease_file.write_all(&lease_bytes).expect("write lease");
2260 lease_file.sync_all().expect("sync lease");
2261 File::open(lease_path.parent().expect("lease parent"))
2262 .expect("open lease parent")
2263 .sync_all()
2264 .expect("sync lease parent");
2265 drop(publication);
2266
2267 loop {
2268 let page = store
2269 .evict_disposable_orphans_page(0, &HashSet::new(), 31)
2270 .expect("lease-protected orphan sweep");
2271 if page.sweep_complete {
2272 break;
2273 }
2274 }
2275
2276 for hash in protected_hashes {
2277 assert!(
2278 store.blob_exists(&hash).expect("protected blob lookup"),
2279 "lease lost generated DAG blob {}",
2280 to_hex(&hash)
2281 );
2282 }
2283 assert!(!store.blob_exists(&orphan).expect("orphan lookup"));
2284 }
2285
2286 #[cfg(feature = "lmdb")]
2287 #[test]
2288 fn garbage_collection_preserves_leased_generated_encrypted_dag() {
2289 let temp_dir = TempDir::new().expect("temp dir");
2290 let store = bounded_lmdb_store(temp_dir.path(), 64 * 1024 * 1024);
2291 let leased_root = build_generated_encrypted_tree(&store, "gc-leased", 96);
2292 let leased_hashes = generated_tree_hashes(&store, &leased_root);
2293 let current_root = build_generated_encrypted_tree(&store, "gc-current", 96);
2294 let current_hashes = generated_tree_hashes(&store, ¤t_root);
2295 write_root_file(
2296 &temp_dir.path().join("socialgraph/events-root.msgpack"),
2297 ¤t_root,
2298 );
2299 let orphan_bytes = format!("gc-unleased-orphan:{}", leased_root);
2300 let orphan_hash = hashtree_core::sha256(orphan_bytes.as_bytes());
2301 store
2302 .router
2303 .put_sync(orphan_hash, orphan_bytes.as_bytes())
2304 .expect("put generated GC orphan");
2305 publish_generated_profile_repair_lease(&store, "gc-leased", &leased_root);
2306
2307 let report = store.gc().expect("lease-aware garbage collection");
2308
2309 assert_eq!(report.deleted_dags, 1);
2310 assert!(!store.blob_exists(&orphan_hash).expect("GC orphan lookup"));
2311 for hash in leased_hashes.into_iter().chain(current_hashes) {
2312 assert!(
2313 store.blob_exists(&hash).expect("leased GC hash lookup"),
2314 "GC deleted active encrypted DAG hash {}",
2315 to_hex(&hash)
2316 );
2317 }
2318 }
2319
2320 #[test]
2321 fn garbage_collection_preserves_generated_pending_profile_root_pair_until_recovery() {
2322 let temp_dir = TempDir::new().expect("temp dir");
2323 let store = HashtreeStore::with_embedded_options(temp_dir.path(), None, 64 * 1024 * 1024)
2324 .expect("embedded generated store");
2325 let graph = crate::socialgraph::open_test_social_graph_store_with_storage(
2326 temp_dir.path(),
2327 store.store_arc(),
2328 None,
2329 )
2330 .expect("open generated social graph");
2331
2332 let mut profiles = Vec::new();
2333 let mut decisions = BTreeMap::new();
2334 for index in 0..80 {
2335 let keys = Keys::generate();
2336 let event = EventBuilder::new(
2337 Kind::Metadata,
2338 serde_json::json!({
2339 "display_name": format!("pending-profile-{index:04}")
2340 })
2341 .to_string(),
2342 )
2343 .custom_created_at(Timestamp::from_secs(index + 1))
2344 .sign_with_keys(&keys)
2345 .expect("sign generated profile");
2346 decisions.insert(event.pubkey.to_hex(), Some(1));
2347 profiles.push(event);
2348 }
2349 let expected_profile = profiles[0].clone();
2350 let prepared = graph
2351 .build_unpublished_profile_index_repair_with_frozen_distances(&profiles, &decisions)
2352 .expect("build generated replacement profile roots");
2353 let mut pending_hashes = HashSet::new();
2354 for root in [
2355 prepared
2356 .new_roots()
2357 .by_pubkey
2358 .as_ref()
2359 .expect("generated by-pubkey root"),
2360 prepared
2361 .new_roots()
2362 .search
2363 .as_ref()
2364 .expect("generated search root"),
2365 ] {
2366 pending_hashes.extend(generated_tree_hashes(&store, root));
2367 }
2368
2369 let crash = graph
2370 .crash_after_prepared_profile_root_pair_intent(&prepared)
2371 .expect_err("generated commit must stop after its durable intent");
2372 assert!(format!("{crash:#}").contains("injected interruption after durable"));
2373 let commit_path = temp_dir
2374 .path()
2375 .join("socialgraph/profile-root-pair.commit.json");
2376 assert!(commit_path.is_file(), "generated commit intent is missing");
2377 assert!(
2378 !temp_dir
2379 .path()
2380 .join("socialgraph/profiles-by-pubkey-root.msgpack")
2381 .exists(),
2382 "crash injection unexpectedly installed by-pubkey root"
2383 );
2384 assert!(
2385 !temp_dir
2386 .path()
2387 .join("socialgraph/profile-search-root.msgpack")
2388 .exists(),
2389 "crash injection unexpectedly installed search root"
2390 );
2391 drop(graph);
2392
2393 let orphan_bytes = b"pending-root-pair-unrelated-orphan";
2394 let orphan_hash = hashtree_core::sha256(orphan_bytes);
2395 store
2396 .router
2397 .put_sync(orphan_hash, orphan_bytes)
2398 .expect("put generated orphan");
2399
2400 let report = store.gc().expect("pending-commit-aware garbage collection");
2401 assert_eq!(report.deleted_dags, 1);
2402 assert!(!store.blob_exists(&orphan_hash).expect("orphan lookup"));
2403 for hash in &pending_hashes {
2404 assert!(
2405 store.blob_exists(hash).expect("pending DAG lookup"),
2406 "GC deleted pending root-pair DAG hash {}",
2407 to_hex(hash)
2408 );
2409 }
2410
2411 let recovered = crate::socialgraph::open_test_social_graph_store_with_storage(
2412 temp_dir.path(),
2413 store.store_arc(),
2414 None,
2415 )
2416 .expect("recover generated pending root pair");
2417 assert!(!commit_path.exists(), "pending commit was not recovered");
2418 assert_eq!(
2419 recovered
2420 .latest_profile_event(&expected_profile.pubkey.to_hex())
2421 .expect("read recovered profile")
2422 .expect("recovered profile is missing")
2423 .id,
2424 expected_profile.id
2425 );
2426 }
2427
2428 #[cfg(feature = "lmdb")]
2429 #[test]
2430 fn applied_root_retention_preserves_leased_generated_encrypted_dag() {
2431 let temp_dir = TempDir::new().expect("temp dir");
2432 let store = bounded_lmdb_store(temp_dir.path(), 64 * 1024 * 1024);
2433 let retained_root = build_generated_encrypted_tree(&store, "retained-target", 96);
2434 let leased_root = build_generated_encrypted_tree(&store, "retention-leased", 96);
2435 let leased_hashes = generated_tree_hashes(&store, &leased_root);
2436 let current_root = build_generated_encrypted_tree(&store, "retention-current", 96);
2437 let current_hashes = generated_tree_hashes(&store, ¤t_root);
2438 write_root_file(
2439 &temp_dir
2440 .path()
2441 .join("socialgraph/profile-search-root.msgpack"),
2442 ¤t_root,
2443 );
2444 let orphan_bytes = format!("retention-unleased-orphan:{}", leased_root);
2445 let orphan_hash = hashtree_core::sha256(orphan_bytes.as_bytes());
2446 store
2447 .router
2448 .put_sync(orphan_hash, orphan_bytes.as_bytes())
2449 .expect("put generated retention orphan");
2450 publish_generated_profile_repair_lease(&store, "retention-leased", &leased_root);
2451
2452 let report = store
2453 .retain_nostr_root(&retained_root, true)
2454 .expect("lease-aware apply retention");
2455
2456 assert_eq!(report.candidate_hashes, 1);
2457 assert_eq!(report.deleted_hashes, 1);
2458 assert!(!store
2459 .blob_exists(&orphan_hash)
2460 .expect("retention orphan lookup"));
2461 for hash in leased_hashes.into_iter().chain(current_hashes) {
2462 assert!(
2463 store
2464 .blob_exists(&hash)
2465 .expect("leased retention hash lookup"),
2466 "apply retention deleted active encrypted DAG hash {}",
2467 to_hex(&hash)
2468 );
2469 }
2470 let leased_index = BTree::new(store.store_arc(), BTreeOptions { order: Some(4) });
2471 assert_eq!(
2472 sync_block_on(leased_index.get(Some(&leased_root), "retention-leased-key-0095"))
2473 .expect("read leased encrypted DAG after retention"),
2474 Some("retention-leased-value-0095".to_string())
2475 );
2476 }
2477
2478 #[cfg(feature = "lmdb")]
2479 #[test]
2480 fn poolstore_orphan_cleanup_fails_closed_without_deleting_catalog_data() {
2481 let temp_dir = TempDir::new().expect("temp dir");
2482 let store = HashtreeStore::with_options_and_backend(
2483 temp_dir.path(),
2484 None,
2485 1024 * 1024,
2486 true,
2487 &StorageBackend::Lmdb,
2488 )
2489 .expect("fresh shared store");
2490 assert!(matches!(
2491 store.router.local_store().as_ref(),
2492 super::super::LocalStore::Pool(_)
2493 ));
2494 let data = b"durable pool catalog data";
2495 let hash = hashtree_core::sha256(data);
2496 store.router.put_sync(hash, data).expect("put pool blob");
2497
2498 let error = store
2499 .evict_disposable_orphans_to_target(0)
2500 .expect_err("PoolStore orphan deletion must fail closed");
2501 assert!(
2502 error.to_string().contains("tier-aware deletion"),
2503 "unexpected error: {error}"
2504 );
2505 assert!(store.blob_exists(&hash).expect("pool blob lookup"));
2506 }
2507
2508 #[cfg(feature = "lmdb")]
2509 #[test]
2510 fn hash_inserted_before_cursor_is_seen_after_wrap() {
2511 let temp_dir = TempDir::new().expect("temp dir");
2512 let store = bounded_lmdb_store(temp_dir.path(), 1024 * 1024);
2513 let mut entries = (1u8..=8)
2514 .map(|value| {
2515 let data = vec![value];
2516 (hashtree_core::sha256(&data), data)
2517 })
2518 .collect::<Vec<_>>();
2519 entries.sort_unstable_by_key(|(hash, _)| *hash);
2520 let (behind_cursor_hash, behind_cursor_data) = entries[0].clone();
2521 for (hash, data) in &entries[1..=3] {
2522 store
2523 .router
2524 .put_sync(*hash, data)
2525 .expect("put initial cursor fixture");
2526 }
2527
2528 let first = store
2529 .evict_disposable_orphans_page(0, &HashSet::new(), 1)
2530 .expect("first page");
2531 assert_eq!(first.scanned, 1);
2532 assert_eq!(first.freed_bytes, 1);
2533 store
2534 .router
2535 .put_sync(behind_cursor_hash, &behind_cursor_data)
2536 .expect("insert behind active cursor");
2537
2538 for _ in 0..8 {
2539 store
2540 .evict_disposable_orphans_page(0, &HashSet::new(), 1)
2541 .expect("continue wrapped sweep");
2542 if !store
2543 .blob_exists(&behind_cursor_hash)
2544 .expect("behind-cursor lookup")
2545 {
2546 return;
2547 }
2548 }
2549 panic!("hash inserted behind the active cursor was not seen after wrap");
2550 }
2551
2552 #[cfg(feature = "lmdb")]
2553 #[test]
2554 fn orphan_cleanup_keeps_indexed_tree_hashes() {
2555 let temp_dir = TempDir::new().expect("temp dir");
2556 let store = bounded_lmdb_store(temp_dir.path(), 1024);
2557 let cid = build_test_tree(&store);
2558
2559 store
2560 .index_tree(
2561 &cid.hash,
2562 "owner",
2563 Some("tree"),
2564 PRIORITY_OTHER,
2565 Some("owner/tree"),
2566 )
2567 .expect("index tree");
2568 let freed = store
2569 .evict_disposable_orphans_to_target(0)
2570 .expect("orphan cleanup");
2571
2572 assert!(freed < 1024);
2573 assert!(store.blob_exists(&cid.hash).expect("root exists"));
2574 }
2575
2576 #[test]
2577 fn list_pins_with_names_uses_indexed_tree_metadata() {
2578 let temp_dir = TempDir::new().expect("temp dir");
2579 let store = HashtreeStore::with_options(temp_dir.path(), None, 1024 * 1024).expect("store");
2580 let cid = build_test_tree(&store);
2581
2582 store.pin(&cid.hash).expect("pin tree");
2583 store
2584 .index_tree(
2585 &cid.hash,
2586 "npub1example",
2587 Some("playlist"),
2588 PRIORITY_OTHER,
2589 Some("npub1example/playlist"),
2590 )
2591 .expect("index tree");
2592
2593 let pins = store.list_pins_with_names().expect("list pins");
2594
2595 assert_eq!(pins.len(), 1);
2596 assert_eq!(pins[0].name, "npub1example/playlist");
2597 assert!(pins[0].size_bytes > 0);
2598 }
2599
2600 #[test]
2601 fn index_tree_records_multilevel_file_size_from_links() {
2602 let temp_dir = TempDir::new().expect("temp dir");
2603 let store = HashtreeStore::with_options(temp_dir.path(), None, 1024 * 1024).expect("store");
2604 let tree = HashTree::new(
2605 HashTreeConfig::new(store.store_arc())
2606 .public()
2607 .with_chunk_size(4)
2608 .with_max_links(2),
2609 );
2610 let data = (0u8..31).collect::<Vec<_>>();
2611 let (cid, size) = sync_block_on(tree.put(&data)).expect("put file");
2612
2613 store
2614 .index_tree(
2615 &cid.hash,
2616 "npub1example",
2617 Some("large-file"),
2618 PRIORITY_OTHER,
2619 Some("npub1example/large-file"),
2620 )
2621 .expect("index tree");
2622
2623 let meta = store
2624 .get_tree_meta(&cid.hash)
2625 .expect("tree meta")
2626 .expect("indexed meta");
2627 assert_eq!(size, data.len() as u64);
2628 assert_eq!(meta.total_size, data.len() as u64);
2629 }
2630
2631 #[test]
2632 fn get_tree_ref_returns_stored_root() {
2633 let temp_dir = TempDir::new().expect("temp dir");
2634 let store = HashtreeStore::with_options(temp_dir.path(), None, 1024 * 1024).expect("store");
2635 let cid = build_test_tree(&store);
2636
2637 store
2638 .index_tree(
2639 &cid.hash,
2640 "npub1example",
2641 Some("playlist"),
2642 PRIORITY_OTHER,
2643 Some("npub1example/playlist"),
2644 )
2645 .expect("index tree");
2646
2647 assert_eq!(
2648 store
2649 .get_tree_ref("npub1example/playlist")
2650 .expect("tree ref lookup"),
2651 Some(cid.hash)
2652 );
2653 }
2654
2655 #[test]
2656 fn tree_meta_deserializes_metadata_without_tree_access_field() {
2657 #[derive(Serialize)]
2658 struct LegacyTreeMeta {
2659 owner: String,
2660 name: Option<String>,
2661 synced_at: u64,
2662 total_size: u64,
2663 priority: u8,
2664 }
2665
2666 let bytes = rmp_serde::to_vec(&LegacyTreeMeta {
2667 owner: "owner".to_string(),
2668 name: Some("tree".to_string()),
2669 synced_at: 123,
2670 total_size: 456,
2671 priority: PRIORITY_OTHER,
2672 })
2673 .expect("serialize legacy metadata");
2674 let meta: TreeMeta = rmp_serde::from_slice(&bytes).expect("deserialize tree metadata");
2675
2676 assert_eq!(meta.owner, "owner");
2677 assert_eq!(meta.name.as_deref(), Some("tree"));
2678 assert_eq!(meta.synced_at, 123);
2679 assert_eq!(meta.total_size, 456);
2680 assert_eq!(meta.priority, PRIORITY_OTHER);
2681 }
2682
2683 #[test]
2684 fn tree_meta_deserializes_accidental_access_field_but_drops_it_on_write() {
2685 #[derive(Serialize)]
2686 struct AccidentalTreeMeta {
2687 owner: String,
2688 name: Option<String>,
2689 synced_at: u64,
2690 last_accessed_at: u64,
2691 total_size: u64,
2692 priority: u8,
2693 }
2694
2695 let bytes = rmp_serde::to_vec(&AccidentalTreeMeta {
2696 owner: "owner".to_string(),
2697 name: Some("tree".to_string()),
2698 synced_at: 123,
2699 last_accessed_at: 999,
2700 total_size: 456,
2701 priority: PRIORITY_OTHER,
2702 })
2703 .expect("serialize accidental metadata");
2704 let meta: TreeMeta = rmp_serde::from_slice(&bytes).expect("deserialize tree metadata");
2705 let encoded = rmp_serde::to_vec(&meta).expect("serialize current metadata");
2706 let reparsed: (String, Option<String>, u64, u64, u8) =
2707 rmp_serde::from_slice(&encoded).expect("parse current metadata shape");
2708
2709 assert_eq!(meta.owner, "owner");
2710 assert_eq!(meta.name.as_deref(), Some("tree"));
2711 assert_eq!(meta.synced_at, 123);
2712 assert_eq!(meta.total_size, 456);
2713 assert_eq!(meta.priority, PRIORITY_OTHER);
2714 assert_eq!(reparsed.0, "owner");
2715 assert_eq!(reparsed.3, 456);
2716 assert_eq!(reparsed.4, PRIORITY_OTHER);
2717 }
2718
2719 #[cfg(feature = "lmdb")]
2720 #[test]
2721 fn eviction_prefers_oldest_tree_within_priority() {
2722 let temp_dir = TempDir::new().expect("temp dir");
2723 let store = bounded_lmdb_store(temp_dir.path(), 500);
2724
2725 let hash1 = hashtree_core::sha256(&[1u8; 200]);
2726 let hash2 = hashtree_core::sha256(&[2u8; 200]);
2727 let hash3 = hashtree_core::sha256(&[3u8; 200]);
2728 store.put_blob(&[1u8; 200]).expect("put blob 1");
2729 store.put_blob(&[2u8; 200]).expect("put blob 2");
2730 store.put_blob(&[3u8; 200]).expect("put blob 3");
2731 store
2732 .index_tree(&hash1, "owner1", Some("tree1"), PRIORITY_OTHER, None)
2733 .expect("index tree 1");
2734 store
2735 .index_tree(&hash2, "owner2", Some("tree2"), PRIORITY_OTHER, None)
2736 .expect("index tree 2");
2737 store
2738 .index_tree(&hash3, "owner3", Some("tree3"), PRIORITY_OTHER, None)
2739 .expect("index tree 3");
2740
2741 let freed = store.evict_if_needed().expect("evict");
2742
2743 assert!(freed > 0);
2744 assert!(
2745 store.get_tree_meta(&hash3).expect("tree meta").is_some(),
2746 "newest tree should survive before older peers at the same priority"
2747 );
2748 }
2749
2750 #[cfg(feature = "lmdb")]
2751 #[test]
2752 fn orphan_cleanup_keeps_socialgraph_root_hashes() {
2753 let temp_dir = TempDir::new().expect("temp dir");
2754 let store = bounded_lmdb_store(temp_dir.path(), 1024);
2755 let cid = build_test_tree(&store);
2756 write_root_file(
2757 &temp_dir.path().join("socialgraph/events-root.msgpack"),
2758 &cid,
2759 );
2760
2761 let freed = store
2762 .evict_disposable_orphans_to_target(0)
2763 .expect("orphan cleanup");
2764
2765 assert!(freed < 1024);
2766 assert!(store.blob_exists(&cid.hash).expect("root exists"));
2767 }
2768
2769 #[test]
2770 fn retained_nostr_root_cleanup_is_dry_run_first_and_keeps_the_dag() {
2771 let temp_dir = TempDir::new().expect("temp dir");
2772 let store = HashtreeStore::with_options(temp_dir.path(), None, 1024 * 1024).expect("store");
2773 let root = build_deep_test_tree(&store);
2774 let orphan_bytes = b"unreachable historical index node";
2775 let orphan = hashtree_core::sha256(orphan_bytes);
2776 store.put_blob(orphan_bytes).expect("put orphan");
2777
2778 let dry_run = store
2779 .retain_nostr_root(&root, false)
2780 .expect("retention dry run");
2781 assert_eq!(dry_run.deleted_hashes, 0);
2782 assert_eq!(dry_run.candidate_hashes, 1);
2783 assert_eq!(dry_run.total_hashes, dry_run.reachable_hashes + 1);
2784 assert!(dry_run.reachable_hashes > 256);
2785 assert!(store.blob_exists(&orphan).expect("orphan exists"));
2786
2787 let applied = store
2788 .retain_nostr_root(&root, true)
2789 .expect("apply retention");
2790 assert_eq!(applied.deleted_hashes, applied.candidate_hashes);
2791 assert!(!store.blob_exists(&orphan).expect("orphan deleted"));
2792 let index = BTree::new(store.store_arc(), BTreeOptions { order: Some(8) });
2793 assert_eq!(
2794 sync_block_on(index.get(Some(&root), "key-0255")).expect("read retained index"),
2795 Some("value-0255".to_string())
2796 );
2797 }
2798}