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 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}