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