lsm_tree/vlog/blob_file/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
4
5pub mod merge;
6pub mod meta;
7pub mod multi_writer;
8pub mod reader;
9pub mod scanner;
10pub mod writer;
11
12use crate::path::{Path, PathBuf};
13use crate::{
14 Checksum, GlobalTableId, TreeId, blob_tree::FragmentationMap, deletion_pause::DeletionPause,
15 file_accessor::FileAccessor, fs::Fs, vlog::BlobFileId,
16};
17#[cfg(not(feature = "std"))]
18use alloc::boxed::Box;
19use alloc::sync::Arc;
20use core::sync::atomic::AtomicBool;
21pub use meta::Metadata;
22
23/// A blob file is an immutable, sorted, contiguous file that contains large key-value pairs (blobs)
24//
25// `#[derive(Debug)]` cannot be used because [`Fs`] is not `Debug` (trait
26// objects without an explicit `Debug` bound would require boxing through
27// `dyn Debug`). A manual impl that prints stable identifiers gives the
28// same operational ergonomics as the previous derived `Debug` without
29// pulling `Debug` into the `Fs` trait bound (which would cascade through
30// every backend).
31pub struct Inner {
32 /// Blob file ID
33 pub id: BlobFileId,
34
35 pub tree_id: TreeId,
36
37 /// File path
38 pub path: PathBuf,
39
40 /// Statistics
41 pub meta: Metadata,
42
43 /// Whether this blob file is deleted (logically)
44 pub is_deleted: AtomicBool,
45
46 /// Tight-space punch-on-drop offset, or [`u64::MAX`] (default) for "no
47 /// punch". When tight-space blob relocation rewrites this file's live
48 /// entries below an offset into a fresh compact file, the PRIOR view is
49 /// marked here with that absolute data-section offset; once every reader
50 /// holding it drops, this view's [`Drop`] reclaims the consumed
51 /// `[data_start, offset)` data frames via
52 /// [`Fs::punch_hole`] and LEAVES the file in
53 /// place (the restricted view still serves the suffix). Mirrors
54 /// `table::Inner::punch_on_drop`. Distinct from [`Self::is_deleted`].
55 #[cfg_attr(
56 not(feature = "std"),
57 allow(
58 dead_code,
59 reason = "tight-space punch-on-drop frontier; the punch consumer is std-gated, so unread under no_std"
60 )
61 )]
62 pub(crate) punch_on_drop: portable_atomic::AtomicU64,
63
64 /// How far this file's data section is known punched, shared by the views
65 /// of the file a tight-space relocation restricts it to one after another:
66 /// each view punches only above what an earlier one finished. Raised only
67 /// by a punch that completed. Mirrors `table::Inner::punched_through`.
68 #[cfg_attr(
69 not(feature = "std"),
70 allow(
71 dead_code,
72 reason = "tight-space punch frontier; the punch consumer is std-gated, so unread under no_std"
73 )
74 )]
75 pub(crate) punched_through: once_cell::race::OnceBox<Arc<portable_atomic::AtomicU64>>,
76
77 pub checksum: Checksum,
78
79 /// First LIVE byte of this view, or `0` for a whole file. Set on the
80 /// RESTRICTED view a tight-space relocation installs: everything below it
81 /// was relocated into a fresh file and its frames are punched out (they
82 /// read back as zeros). [`Self::checksum`] then covers only
83 /// `[live_data_start, end)`, so integrity checks must hash from here —
84 /// whole-file hashing would fold in the punched prefix and report a healthy
85 /// file as corrupt. Persisted per version edit, the blob analogue of a
86 /// table's restriction bound.
87 pub(crate) live_data_start: u64,
88
89 pub(crate) file_accessor: FileAccessor,
90
91 /// Filesystem backend used by [`Drop`] for the physical removal.
92 /// Carries the same `Fs` instance the file was opened through so that
93 /// in-memory and routed-tier backends behave consistently with the
94 /// rest of the tree.
95 pub(crate) fs: Arc<dyn Fs>,
96
97 /// Tree-wide file-deletion gate. Installed once by
98 /// [`BlobFile::install_deletion_pause`] after the file is registered
99 /// with a tree. When `Some` and active, the [`Drop`] impl defers the
100 /// underlying `remove_file` so an in-progress checkpoint can hard-link
101 /// the file before it disappears.
102 // `once_cell::race::OnceBox` — see Table::Inner::deletion_pause
103 // for the rationale (no-std-friendly one-shot slot).
104 pub(crate) deletion_pause: once_cell::race::OnceBox<Arc<DeletionPause>>,
105
106 /// The dictionary this file's blobs were compressed against, resolved once
107 /// when the handle is built and held for its life. `None` when
108 /// [`Metadata::compression`] is not
109 /// [`ZstdDict`](crate::CompressionType::ZstdDict), or when the tree did not
110 /// hold the id the file records.
111 ///
112 /// Pinned here rather than looked up per read, exactly as a table pins
113 /// its own: the handle that keeps a blob file readable has to keep the
114 /// bytes that decode it readable too. A reader captures a version, and
115 /// that capture defers the file's deletion past any install that replaces
116 /// it; a dictionary resolved from the LIVE registry instead would be
117 /// collected out from under that reader, since collection only spares what
118 /// the current version still names.
119 #[cfg(zstd_any)]
120 pub(crate) zstd_dictionary: Option<Arc<crate::compression::ZstdDictionary>>,
121
122 /// Tree-wide background file deleter. See
123 /// [`Table::install_background_deleter`](crate::Table) for the contract:
124 /// when present (and no checkpoint pause is active) the [`Drop`] impl frees
125 /// the blob file's blocks synchronously via
126 /// [`Fs::truncate_file`] and hands the
127 /// directory-entry `unlink` to this deleter, off the foreground path.
128 // std-only (the deleter spawns a thread); see Table::Inner for rationale.
129 #[cfg(feature = "std")]
130 pub(crate) background_deleter: once_cell::race::OnceBox<Arc<crate::BackgroundDeleter>>,
131}
132
133impl Inner {
134 fn global_id(&self) -> GlobalTableId {
135 GlobalTableId::from((self.tree_id, self.id))
136 }
137}
138
139impl core::fmt::Debug for Inner {
140 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
141 f.debug_struct("blob_file::Inner")
142 .field("id", &self.id)
143 .field("tree_id", &self.tree_id)
144 .field("path", &self.path)
145 .field(
146 "is_deleted",
147 &self.is_deleted.load(core::sync::atomic::Ordering::Relaxed),
148 )
149 .field("meta", &self.meta)
150 .finish_non_exhaustive()
151 }
152}
153
154impl Drop for Inner {
155 fn drop(&mut self) {
156 if self.is_deleted.load(core::sync::atomic::Ordering::Acquire) {
157 log::trace!(
158 "Cleanup deleted blob file {:?} at {}",
159 self.id,
160 self.path.display(),
161 );
162
163 // Move the accessor out and drop it FIRST so every pinned
164 // Arc<dyn FsFile> the file_accessor holds is released before
165 // we try to unlink. On Windows (and any other platform where
166 // an open handle blocks unlink) a live handle here would
167 // make remove_file fail silently, leaking the blob file's
168 // disk space — the same hazard already handled in
169 // table::Inner::drop. Eviction from the descriptor table
170 // happens through the same accessor before the drop.
171 let global_id = self.global_id();
172 let file_accessor = core::mem::replace(&mut self.file_accessor, FileAccessor::Closed);
173 file_accessor
174 .as_descriptor_table()
175 .inspect(|d| d.remove_for_blob_file(&global_id));
176 drop(file_accessor);
177
178 // A pin may list the file through another view of it (the one
179 // before a tight-space relocation): it stays until that pin drops.
180 // Checked before the checkpoint below, whose release would
181 // otherwise remove the file while the pin still lists it; the
182 // pin's release hands the removal to a checkpoint open then.
183 #[cfg(feature = "std")]
184 if let Some(pause) = self.deletion_pause.get()
185 && pause.defer_removal_if_pinned(
186 &self.fs,
187 &self.path,
188 crate::deletion_pause::PinnedKind::BlobFile,
189 )
190 {
191 return;
192 }
193
194 // If a checkpoint is active, defer the physical deletion so the
195 // file remains hard-linkable until the checkpoint releases its
196 // pause. Short-circuit on the common no-checkpoint path: skip
197 // the Arc<dyn Fs> bump and PathBuf clone unless a pause is
198 // both installed AND currently active. `try_enqueue` still
199 // re-checks `is_active()` under the queue lock to close the
200 // publish-then-release race, so the outer check is pure perf.
201 let deferred = match self.deletion_pause.get() {
202 Some(pause) if pause.is_active() => {
203 pause.try_enqueue(Arc::clone(&self.fs), self.path.clone())
204 }
205 _ => false,
206 };
207
208 if deferred {
209 log::trace!(
210 "Deferred deletion of blob file {:?} at {} (checkpoint active)",
211 self.id,
212 self.path.display(),
213 );
214 return;
215 }
216
217 // Off-foreground reclaim: free the blocks synchronously (accurate
218 // footprint scan) and hand the unlink to the background deleter.
219 // Falls through to a synchronous remove_file when none installed.
220 #[cfg(feature = "std")]
221 if let Some(deleter) = self.background_deleter.get() {
222 // Truncate only when we own the sole hard link — a checkpoint
223 // may have hard-linked this blob file, and truncating the shared
224 // inode would zero the checkpoint's copy. Otherwise skip the
225 // truncate and just unlink (data survives via the other link).
226 if self.fs.hard_link_count(&self.path).is_ok_and(|n| n <= 1)
227 && let Err(e) = self.fs.truncate_file(&self.path)
228 {
229 log::warn!(
230 "Failed to truncate deleted blob file {:?} at {}: {e:?}",
231 self.id,
232 self.path.display(),
233 );
234 }
235 deleter.enqueue(Arc::clone(&self.fs), self.path.clone());
236 return;
237 }
238
239 if let Err(e) = self.fs.remove_file(&self.path) {
240 log::warn!(
241 "Failed to cleanup deleted blob file {:?} at {}: {e:?}",
242 self.id,
243 self.path.display(),
244 );
245 }
246 } else {
247 // Not deleted, but possibly marked for tight-space prefix reclaim:
248 // this (old) view's last Arc is dropping, so no reader can touch the
249 // relocated prefix anymore. Punch the consumed data frames
250 // `[data_start, offset)` and LEAVE the file — the restricted view (a
251 // distinct Inner) still serves the suffix. A blob file is an SFA
252 // archive, so the punch must start at the `data` section (skip the
253 // header); the TOC sits at the tail and stays intact. `offset` is an
254 // absolute data-section position (a frame boundary from the
255 // relocation scanner). Re-read the data-section start from the TOC
256 // here rather than carrying it on every blob-file Inner — the punch
257 // is a rare, tight-space-only path.
258 //
259 // Hole punching is a std-only capability (the tight-space relocation
260 // loop that arms it is itself `#[cfg(feature = "std")]`), so the punch
261 // action is gated. The atomic load is no-std-safe but pointless when
262 // nothing can arm it.
263 #[cfg(feature = "std")]
264 {
265 let off = self
266 .punch_on_drop
267 .load(core::sync::atomic::Ordering::Acquire);
268 // Reclaim only what this tree exclusively owns. A checkpoint
269 // hard-links blob files, and its captured SSTs still reference
270 // values in the prefix being reclaimed — punching a shared
271 // inode would zero live data inside an immutable snapshot. Same
272 // guard the delete path applies before truncating; a link-count
273 // probe that FAILS is treated as shared (fail closed), losing
274 // only reclaimable space. An ACTIVE deletion pause additionally
275 // defers the reclaim: the pause covers the checkpoint's whole
276 // copy/link pass, so standing down removes the probe-then-punch
277 // window in which the checkpoint could link this inode after
278 // the probe read 1 — mirroring the table-prefix punch.
279 //
280 // The residual window (a checkpoint whose pause lands after
281 // this check) is closed by lifetimes, not by a lock: the
282 // checkpoint captures its version UNDER the held link window
283 // and that version holds an Arc on every blob handle it links,
284 // so a capture that still sees the pre-relocation view keeps
285 // THIS Inner alive (this drop cannot run concurrently), and a
286 // capture of the post-relocation view records the restricted
287 // frontier, whose digest never covers the prefix punched here.
288 // Blocking on the mutation gate instead is not an option in a
289 // Drop impl: the checkpoint drops its captured version while
290 // holding the gate's write half, and if that drop releases the
291 // last Arc of an armed Inner, taking the read half here would
292 // self-deadlock.
293 //
294 // Deferral does not DISCARD the reclaim: the intent lives in
295 // this dropping view, so it is handed to the pause, which
296 // re-probes the link count and punches once the checkpoint's
297 // window closes.
298 if off != u64::MAX {
299 let extent = match reclaimable_prefix(&*self.fs, &self.path, off) {
300 Ok(extent) => extent,
301 Err(e) => {
302 log::warn!(
303 "Skipping tight-space punch of blob file {:?} at {}: could not read data section: {e:?}",
304 self.id,
305 self.path.display(),
306 );
307 None
308 }
309 };
310 // An earlier view of this file punched everything below the
311 // floor; from the aligned edge under it, as a table's views do.
312 let floor = self.punched_through.get().map_or(0, |punched| {
313 punched.load(core::sync::atomic::Ordering::Acquire)
314 });
315 let floor = floor - floor % crate::table::PUNCH_ALIGN;
316 let extent = extent.and_then(|(data_start, _)| {
317 let start = data_start.max(floor);
318 (start < off).then(|| (start, off - start))
319 });
320 if let Some((data_start, len)) = extent {
321 let deferred = self.deletion_pause.get().is_some_and(|pause| {
322 pause.is_active()
323 && pause.try_enqueue_punch(
324 Arc::clone(&self.fs),
325 self.path.clone(),
326 alloc::vec![(data_start, len)],
327 )
328 });
329 // A shared inode (a COMPLETED checkpoint's surviving
330 // link), an unanswerable probe, or a failed punch does
331 // not DISCARD the reclaim: this dropping view holds its
332 // only record, so it is RETAINED for
333 // `retry_pending_reclaims` — mirroring the table-prefix
334 // punch. A bare retention, never a blocking re-probe:
335 // this is a Drop impl (see `retain_reclaim`).
336 if deferred {
337 return;
338 }
339 // A pinned file keeps its bytes until its last pin
340 // drops, which retries the retained reclaim; a pin
341 // being taken waits for this punch to finish.
342 let _unpinned = if let Some(pause) = self.deletion_pause.get() {
343 let Some(guard) = pause.begin_mutation(&self.path) else {
344 pause.retain_reclaim(
345 Arc::clone(&self.fs),
346 self.path.clone(),
347 alloc::vec![(data_start, len)],
348 );
349 return;
350 };
351 Some(guard)
352 } else {
353 None
354 };
355 let exclusively_owned = match self.fs.hard_link_count(&self.path) {
356 Ok(n) => n <= 1,
357 Err(e) if e.kind() == crate::io::ErrorKind::NotFound => {
358 // The file is gone: its space is already back.
359 return;
360 }
361 Err(e) => {
362 log::debug!(
363 "Retaining tight-space punch of blob file {:?} at {} for a retry: link-count probe failed: {e:?}",
364 self.id,
365 self.path.display(),
366 );
367 false
368 }
369 };
370 let punch_failed = exclusively_owned
371 && match self.fs.punch_hole(&self.path, data_start, len) {
372 Ok(()) => false,
373 Err(e) => {
374 log::warn!(
375 "Failed to punch tight-space data [{data_start}, {off}) of blob file {:?} at {}; retaining it for a retry: {e:?}",
376 self.id,
377 self.path.display(),
378 );
379 true
380 }
381 };
382 if exclusively_owned
383 && !punch_failed
384 && let Some(punched) = self.punched_through.get()
385 {
386 punched.fetch_max(off, core::sync::atomic::Ordering::Release);
387 }
388 if (!exclusively_owned || punch_failed)
389 && let Some(pause) = self.deletion_pause.get()
390 {
391 pause.retain_reclaim(
392 Arc::clone(&self.fs),
393 self.path.clone(),
394 alloc::vec![(data_start, len)],
395 );
396 }
397 }
398 }
399 }
400 }
401 }
402}
403
404/// Byte offset where a blob file's `data` section begins, read from its SFA TOC.
405/// Used by the tight-space punch so it reclaims only data frames and never the
406/// SFA header that precedes them.
407/// The extent a reclaim frees when everything below `live_up_to` is consumed:
408/// `[data section start, live_up_to)`, or `None` when nothing lies below it.
409///
410/// The two callers are the punch-on-drop of a superseded view and recovery's
411/// re-derivation of a reclaim a previous session could not finish; sharing the
412/// arithmetic keeps them from disagreeing about where the reclaimable region
413/// begins.
414///
415/// # Errors
416///
417/// Propagates the TOC read of `path`.
418#[cfg(feature = "std")]
419fn reclaimable_prefix(
420 fs: &dyn Fs,
421 path: &Path,
422 live_up_to: u64,
423) -> crate::Result<Option<(u64, u64)>> {
424 let data_start = data_section_start(fs, path)?;
425 Ok((live_up_to > data_start).then(|| (data_start, live_up_to - data_start)))
426}
427
428#[cfg(feature = "std")]
429fn data_section_start(fs: &dyn Fs, path: &Path) -> crate::Result<u64> {
430 let mut file = fs.open(path, &crate::fs::FsOpenOptions::new().read(true))?;
431 let reader = crate::sfa::Reader::from_reader(&mut file)?;
432 let data = reader
433 .toc()
434 .section(b"data")
435 .ok_or(crate::Error::InvalidHeader("BlobFile"))?;
436 Ok(data.pos())
437}
438
439/// A blob file stores large values and is part of the value log
440#[derive(Clone)]
441pub struct BlobFile(pub(crate) Arc<Inner>);
442
443impl Eq for BlobFile {}
444
445impl PartialEq for BlobFile {
446 fn eq(&self, other: &Self) -> bool {
447 self.id().eq(&other.id())
448 }
449}
450
451impl core::hash::Hash for BlobFile {
452 fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
453 self.id().hash(state);
454 }
455}
456
457impl BlobFile {
458 pub(crate) fn mark_as_deleted(&self) {
459 self.0
460 .is_deleted
461 .store(true, core::sync::atomic::Ordering::Release);
462 }
463
464 /// Marks this view to punch the consumed `[data_start, offset)` data frames
465 /// when its last `Arc` drops (see [`Inner::punch_on_drop`]). `offset` is an
466 /// absolute data-section position. Set on the PRIOR view once a tight-space
467 /// relocation slice has moved its `[data_start, offset)` live entries into a
468 /// fresh compact file and that move is durably installed.
469 #[cfg(feature = "std")]
470 pub(crate) fn mark_punch_on_drop(&self, offset: u64) {
471 self.0
472 .punch_on_drop
473 .store(offset, core::sync::atomic::Ordering::Release);
474 }
475
476 /// A handle that tells whether this view's file is still held: its
477 /// punch runs only once no handle is left.
478 #[cfg(feature = "std")]
479 pub(crate) fn downgrade(&self) -> alloc::sync::Weak<Inner> {
480 Arc::downgrade(&self.0)
481 }
482
483 /// Re-opens this blob file as a DISTINCT [`Inner`] (its own file handle and
484 /// a fresh punch-on-drop atomic) restricted to `[frontier, end)`: the
485 /// tight-space relocation loop installs this view in the new version and
486 /// arms the PRIOR view to punch everything below the frontier once its
487 /// readers drain, so a stale blob file is reclaimed in place while the
488 /// suffix keeps serving the not-yet-relocated entries — the blob analog of
489 /// [`Table::reopen_restricted`](crate::Table::reopen_restricted).
490 ///
491 /// The digest is re-computed over that LIVE SUFFIX now, while the file is
492 /// still whole — the punch is what makes a whole-file digest unusable, and
493 /// reading the suffix fresh also folds in anything the relocation just
494 /// wrote. The frontier rides on the view, so `diff` / the snapshot encoder
495 /// persist it and integrity checks hash from there.
496 ///
497 /// # Errors
498 ///
499 /// Propagates any error from re-opening the file or hashing its suffix.
500 #[cfg(feature = "std")]
501 pub(crate) fn reopen_restricted(&self, frontier: u64) -> crate::Result<Self> {
502 let checksum = crate::Checksum::from_raw(crate::repair::compute_table_checksum_from(
503 &*self.0.fs,
504 &self.0.path,
505 frontier,
506 )?);
507 // The restricted view is the SAME file, so its dictionary is the one
508 // already pinned here. Carrying it over re-resolves nothing and cannot
509 // disagree with the descriptor the view keeps.
510 #[cfg(zstd_any)]
511 let dicts = self
512 .0
513 .zstd_dictionary
514 .clone()
515 .map_or_else(crate::compression::ZstdDictionaries::new, |d| {
516 crate::compression::ZstdDictionaries::new().with(d)
517 });
518 let reopened = super::recover_blob_file_from(
519 &self.0.path,
520 self.0.id,
521 checksum,
522 self.0.tree_id,
523 &self.0.fs,
524 frontier,
525 #[cfg(zstd_any)]
526 &dicts,
527 )?;
528 // How far the file is punched is the file's, not a view's.
529 let punched = self
530 .0
531 .punched_through
532 .get_or_init(|| Box::new(Arc::new(portable_atomic::AtomicU64::new(0))));
533 reopened
534 .0
535 .punched_through
536 .get_or_init(|| Box::new(Arc::clone(punched)));
537 Ok(reopened)
538 }
539
540 /// Installs the tree-wide deletion pause used by checkpoints.
541 /// Idempotent: a second call is a no-op.
542 pub(crate) fn install_deletion_pause(&self, pause: Arc<DeletionPause>) {
543 let _ = self.0.deletion_pause.set(Box::new(pause));
544 }
545
546 /// Installs the tree-wide background file deleter. Idempotent.
547 #[cfg(feature = "std")]
548 pub(crate) fn install_background_deleter(&self, deleter: Arc<crate::BackgroundDeleter>) {
549 let _ = self.0.background_deleter.set(Box::new(deleter));
550 }
551
552 /// Binds this freshly created blob file to the tree's shared machinery.
553 ///
554 /// **Every path that makes a new blob file reachable must call this**, for
555 /// the same reason its table counterpart exists
556 /// ([`Table::bind_to_tree`](crate::Table::bind_to_tree)): a file that
557 /// skips it looks healthy and fails silently later. Without the deletion
558 /// pause its `Drop` can unlink the file while a checkpoint is capturing —
559 /// before the checkpoint links it — and a tight-space prefix punch can
560 /// zero bytes the checkpoint has already hard-linked.
561 ///
562 /// Idempotent per sink, so re-binding is harmless.
563 pub(crate) fn bind_to_tree(&self, sinks: &crate::table::TableSinks<'_>) {
564 self.install_deletion_pause(Arc::clone(sinks.deletion_pause));
565 #[cfg(feature = "std")]
566 self.install_background_deleter(Arc::clone(sinks.background_deleter));
567 }
568
569 /// The installed deletion pause, so tests can assert that every path
570 /// publishing a blob file binds it.
571 #[cfg(test)]
572 pub(crate) fn deletion_pause_for_test(&self) -> Option<Arc<DeletionPause>> {
573 self.0.deletion_pause.get().cloned()
574 }
575
576 /// Returns the blob file ID.
577 #[must_use]
578 pub fn id(&self) -> BlobFileId {
579 self.0.id
580 }
581
582 /// First LIVE byte of this view: `0` for a whole file, or the frontier a
583 /// tight-space relocation left after reclaiming the consumed prefix. The
584 /// recorded [`checksum`](Self::checksum) covers `[live_data_start, end)`,
585 /// so integrity checks hash from here rather than over the punched prefix.
586 #[must_use]
587 pub fn live_data_start(&self) -> u64 {
588 self.0.live_data_start
589 }
590
591 /// The extent this view's committed frontier declares consumed, or `None`
592 /// when nothing is.
593 ///
594 /// Recovery uses it to re-derive a reclaim a previous session could not
595 /// finish: the punch intent lived only in that session's queue, and the
596 /// superseded view able to re-arm it is gone after a restart, so a blob
597 /// prefix a checkpoint's link once deferred would otherwise stay allocated
598 /// for the life of the recovered file. Nothing is persisted for this; the
599 /// extent follows from `live_data_start`.
600 ///
601 /// # Errors
602 ///
603 /// Propagates the TOC read of the file.
604 #[cfg(feature = "std")]
605 pub(crate) fn committed_reclaimable_prefix(&self) -> crate::Result<Option<(u64, u64)>> {
606 reclaimable_prefix(&*self.0.fs, &self.0.path, self.0.live_data_start)
607 }
608
609 /// Returns the full blob file checksum.
610 #[must_use]
611 pub fn checksum(&self) -> Checksum {
612 self.0.checksum
613 }
614
615 /// The compression applied to this blob file's values (the descriptor a
616 /// reader uses to decode each record's on-disk bytes).
617 #[must_use]
618 pub(crate) fn compression(&self) -> crate::CompressionType {
619 self.0.meta.compression
620 }
621
622 /// The lifetime class this file's values were written under; `0` is the
623 /// shortest-lived. See
624 /// [`KvSeparationOptions::lifetime_groups`](crate::KvSeparationOptions::lifetime_groups).
625 ///
626 /// It decides only where a relocation writes the values, never whether
627 /// the file may be collected.
628 #[must_use]
629 pub fn lifetime_class(&self) -> u8 {
630 self.0.meta.lifetime_class
631 }
632
633 /// The file's decoded metadata block (counters, key range, compression).
634 #[must_use]
635 pub(crate) fn meta(&self) -> &Metadata {
636 &self.0.meta
637 }
638
639 /// Returns the blob file path.
640 #[must_use]
641 pub fn path(&self) -> &Path {
642 &self.0.path
643 }
644
645 /// Returns the blob file accessor.
646 #[must_use]
647 pub(crate) fn file_accessor(&self) -> &FileAccessor {
648 &self.0.file_accessor
649 }
650
651 /// Returns the number of items in the blob file.
652 #[must_use]
653 #[expect(clippy::len_without_is_empty)]
654 pub fn len(&self) -> u64 {
655 self.0.meta.item_count
656 }
657
658 /// Physical on-disk file size in bytes, including the per-entry framing
659 /// (V4 header + key) and the metadata block / trailer — not just the
660 /// compressed payload (`meta.total_compressed_bytes`). Used as a
661 /// conservative upper bound on the transient output of a blob relocation:
662 /// the rewritten file re-emits the same framing, so the source file's
663 /// physical size bounds the output (and includes the dead blobs a relocation
664 /// drops, making it strictly conservative).
665 ///
666 /// # Errors
667 ///
668 /// Returns an error if the blob file's size cannot be stat-ed.
669 pub(crate) fn physical_size(&self) -> crate::Result<u64> {
670 Ok(self.0.fs.metadata(&self.0.path)?.len)
671 }
672
673 /// Returns `true` if the blob file is stale (based on the given staleness threshold).
674 pub(crate) fn is_stale(&self, frag_map: &FragmentationMap, threshold: f32) -> bool {
675 frag_map.get(&self.id()).is_some_and(|x| {
676 #[expect(
677 clippy::cast_precision_loss,
678 reason = "ok to lose precision as this is an approximate calculation"
679 )]
680 let stale_bytes = x.bytes as f32;
681 #[expect(
682 clippy::cast_precision_loss,
683 reason = "ok to lose precision as this is an approximate calculation"
684 )]
685 let all_bytes = self.0.meta.total_uncompressed_bytes as f32;
686 let ratio = stale_bytes / all_bytes;
687 ratio >= threshold
688 })
689 }
690
691 /// Returns `true` if the blob file has no more incoming references, and can be safely removed from a Version.
692 pub(crate) fn is_dead(&self, frag_map: &FragmentationMap) -> bool {
693 frag_map.get(&self.id()).is_some_and(|x| {
694 let stale_bytes = x.bytes;
695 let all_bytes = self.0.meta.total_uncompressed_bytes;
696 stale_bytes == all_bytes
697 })
698 }
699
700 /// [`Self::is_dead`] against `stats` with `pass`, the charges of an
701 /// install not merged into them yet, added: whether the install that lands
702 /// `pass` leaves the file dead. Reads the two maps in place, with no merged
703 /// copy made.
704 pub(crate) fn is_dead_after(&self, stats: &FragmentationMap, pass: &FragmentationMap) -> bool {
705 let (before, now) = (stats.get(&self.id()), pass.get(&self.id()));
706 if before.is_none() && now.is_none() {
707 return false;
708 }
709 // Each charge is at most the file's size, far below `u64::MAX`, so the
710 // sum cannot overflow.
711 let charged = before.map_or(0, |e| e.bytes) + now.map_or(0, |e| e.bytes);
712 charged == self.0.meta.total_uncompressed_bytes
713 }
714}