Skip to main content

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}