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    pub checksum: Checksum,
65
66    /// First LIVE byte of this view, or `0` for a whole file. Set on the
67    /// RESTRICTED view a tight-space relocation installs: everything below it
68    /// was relocated into a fresh file and its frames are punched out (they
69    /// read back as zeros). [`Self::checksum`] then covers only
70    /// `[live_data_start, end)`, so integrity checks must hash from here —
71    /// whole-file hashing would fold in the punched prefix and report a healthy
72    /// file as corrupt. Persisted per version edit, the blob analogue of a
73    /// table's restriction bound.
74    pub(crate) live_data_start: u64,
75
76    pub(crate) file_accessor: FileAccessor,
77
78    /// Filesystem backend used by [`Drop`] for the physical removal.
79    /// Carries the same `Fs` instance the file was opened through so that
80    /// in-memory and routed-tier backends behave consistently with the
81    /// rest of the tree.
82    pub(crate) fs: Arc<dyn Fs>,
83
84    /// Tree-wide file-deletion gate. Installed once by
85    /// [`BlobFile::install_deletion_pause`] after the file is registered
86    /// with a tree. When `Some` and active, the [`Drop`] impl defers the
87    /// underlying `remove_file` so an in-progress checkpoint can hard-link
88    /// the file before it disappears.
89    // `once_cell::race::OnceBox` — see Table::Inner::deletion_pause
90    // for the rationale (no-std-friendly one-shot slot).
91    pub(crate) deletion_pause: once_cell::race::OnceBox<Arc<DeletionPause>>,
92
93    /// Tree-wide background file deleter. See
94    /// [`Table::install_background_deleter`](crate::Table) for the contract:
95    /// when present (and no checkpoint pause is active) the [`Drop`] impl frees
96    /// the blob file's blocks synchronously via
97    /// [`Fs::truncate_file`] and hands the
98    /// directory-entry `unlink` to this deleter, off the foreground path.
99    // std-only (the deleter spawns a thread); see Table::Inner for rationale.
100    #[cfg(feature = "std")]
101    pub(crate) background_deleter: once_cell::race::OnceBox<Arc<crate::BackgroundDeleter>>,
102}
103
104impl Inner {
105    fn global_id(&self) -> GlobalTableId {
106        GlobalTableId::from((self.tree_id, self.id))
107    }
108}
109
110impl core::fmt::Debug for Inner {
111    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
112        f.debug_struct("blob_file::Inner")
113            .field("id", &self.id)
114            .field("tree_id", &self.tree_id)
115            .field("path", &self.path)
116            .field(
117                "is_deleted",
118                &self.is_deleted.load(core::sync::atomic::Ordering::Relaxed),
119            )
120            .field("meta", &self.meta)
121            .finish_non_exhaustive()
122    }
123}
124
125impl Drop for Inner {
126    fn drop(&mut self) {
127        if self.is_deleted.load(core::sync::atomic::Ordering::Acquire) {
128            log::trace!(
129                "Cleanup deleted blob file {:?} at {}",
130                self.id,
131                self.path.display(),
132            );
133
134            // Move the accessor out and drop it FIRST so every pinned
135            // Arc<dyn FsFile> the file_accessor holds is released before
136            // we try to unlink. On Windows (and any other platform where
137            // an open handle blocks unlink) a live handle here would
138            // make remove_file fail silently, leaking the blob file's
139            // disk space — the same hazard already handled in
140            // table::Inner::drop. Eviction from the descriptor table
141            // happens through the same accessor before the drop.
142            let global_id = self.global_id();
143            let file_accessor = core::mem::replace(&mut self.file_accessor, FileAccessor::Closed);
144            file_accessor
145                .as_descriptor_table()
146                .inspect(|d| d.remove_for_blob_file(&global_id));
147            drop(file_accessor);
148
149            // If a checkpoint is active, defer the physical deletion so the
150            // file remains hard-linkable until the checkpoint releases its
151            // pause. Short-circuit on the common no-checkpoint path: skip
152            // the Arc<dyn Fs> bump and PathBuf clone unless a pause is
153            // both installed AND currently active. `try_enqueue` still
154            // re-checks `is_active()` under the queue lock to close the
155            // publish-then-release race, so the outer check is pure perf.
156            let deferred = match self.deletion_pause.get() {
157                Some(pause) if pause.is_active() => {
158                    pause.try_enqueue(Arc::clone(&self.fs), self.path.clone())
159                }
160                _ => false,
161            };
162
163            if deferred {
164                log::trace!(
165                    "Deferred deletion of blob file {:?} at {} (checkpoint active)",
166                    self.id,
167                    self.path.display(),
168                );
169                return;
170            }
171
172            // Off-foreground reclaim: free the blocks synchronously (accurate
173            // footprint scan) and hand the unlink to the background deleter.
174            // Falls through to a synchronous remove_file when none installed.
175            #[cfg(feature = "std")]
176            if let Some(deleter) = self.background_deleter.get() {
177                // Truncate only when we own the sole hard link — a checkpoint
178                // may have hard-linked this blob file, and truncating the shared
179                // inode would zero the checkpoint's copy. Otherwise skip the
180                // truncate and just unlink (data survives via the other link).
181                if self.fs.hard_link_count(&self.path).is_ok_and(|n| n <= 1)
182                    && let Err(e) = self.fs.truncate_file(&self.path)
183                {
184                    log::warn!(
185                        "Failed to truncate deleted blob file {:?} at {}: {e:?}",
186                        self.id,
187                        self.path.display(),
188                    );
189                }
190                deleter.enqueue(Arc::clone(&self.fs), self.path.clone());
191                return;
192            }
193
194            if let Err(e) = self.fs.remove_file(&self.path) {
195                log::warn!(
196                    "Failed to cleanup deleted blob file {:?} at {}: {e:?}",
197                    self.id,
198                    self.path.display(),
199                );
200            }
201        } else {
202            // Not deleted, but possibly marked for tight-space prefix reclaim:
203            // this (old) view's last Arc is dropping, so no reader can touch the
204            // relocated prefix anymore. Punch the consumed data frames
205            // `[data_start, offset)` and LEAVE the file — the restricted view (a
206            // distinct Inner) still serves the suffix. A blob file is an SFA
207            // archive, so the punch must start at the `data` section (skip the
208            // header); the TOC sits at the tail and stays intact. `offset` is an
209            // absolute data-section position (a frame boundary from the
210            // relocation scanner). Re-read the data-section start from the TOC
211            // here rather than carrying it on every blob-file Inner — the punch
212            // is a rare, tight-space-only path.
213            //
214            // Hole punching is a std-only capability (the tight-space relocation
215            // loop that arms it is itself `#[cfg(feature = "std")]`), so the punch
216            // action is gated. The atomic load is no-std-safe but pointless when
217            // nothing can arm it.
218            #[cfg(feature = "std")]
219            {
220                let off = self
221                    .punch_on_drop
222                    .load(core::sync::atomic::Ordering::Acquire);
223                // Reclaim only what this tree exclusively owns. A checkpoint
224                // hard-links blob files, and its captured SSTs still reference
225                // values in the prefix being reclaimed — punching a shared
226                // inode would zero live data inside an immutable snapshot. Same
227                // guard the delete path applies before truncating; a link-count
228                // probe that FAILS is treated as shared (fail closed), losing
229                // only reclaimable space. An ACTIVE deletion pause additionally
230                // defers the reclaim: the pause covers the checkpoint's whole
231                // copy/link pass, so standing down removes the probe-then-punch
232                // window in which the checkpoint could link this inode after
233                // the probe read 1 — mirroring the table-prefix punch.
234                //
235                // The residual window (a checkpoint whose pause lands after
236                // this check) is closed by lifetimes, not by a lock: the
237                // checkpoint captures its version UNDER the held link window
238                // and that version holds an Arc on every blob handle it links,
239                // so a capture that still sees the pre-relocation view keeps
240                // THIS Inner alive (this drop cannot run concurrently), and a
241                // capture of the post-relocation view records the restricted
242                // frontier, whose digest never covers the prefix punched here.
243                // Blocking on the mutation gate instead is not an option in a
244                // Drop impl: the checkpoint drops its captured version while
245                // holding the gate's write half, and if that drop releases the
246                // last Arc of an armed Inner, taking the read half here would
247                // self-deadlock.
248                //
249                // Deferral does not DISCARD the reclaim: the intent lives in
250                // this dropping view, so it is handed to the pause, which
251                // re-probes the link count and punches once the checkpoint's
252                // window closes.
253                if off != u64::MAX {
254                    let extent = match reclaimable_prefix(&*self.fs, &self.path, off) {
255                        Ok(extent) => extent,
256                        Err(e) => {
257                            log::warn!(
258                                "Skipping tight-space punch of blob file {:?} at {}: could not read data section: {e:?}",
259                                self.id,
260                                self.path.display(),
261                            );
262                            None
263                        }
264                    };
265                    if let Some((data_start, len)) = extent {
266                        let deferred = self.deletion_pause.get().is_some_and(|pause| {
267                            pause.is_active()
268                                && pause.try_enqueue_punch(
269                                    Arc::clone(&self.fs),
270                                    self.path.clone(),
271                                    alloc::vec![(data_start, len)],
272                                )
273                        });
274                        // A shared inode (a COMPLETED checkpoint's surviving
275                        // link), an unanswerable probe, or a failed punch does
276                        // not DISCARD the reclaim: this dropping view holds its
277                        // only record, so it is RETAINED for
278                        // `retry_pending_reclaims` — mirroring the table-prefix
279                        // punch. A bare retention, never a blocking re-probe:
280                        // this is a Drop impl (see `retain_reclaim`).
281                        if !deferred {
282                            let exclusively_owned = match self.fs.hard_link_count(&self.path) {
283                                Ok(n) => n <= 1,
284                                Err(e) if e.kind() == crate::io::ErrorKind::NotFound => {
285                                    // The file is gone: its space is already back.
286                                    return;
287                                }
288                                Err(e) => {
289                                    log::debug!(
290                                        "Retaining tight-space punch of blob file {:?} at {} for a retry: link-count probe failed: {e:?}",
291                                        self.id,
292                                        self.path.display(),
293                                    );
294                                    false
295                                }
296                            };
297                            let punch_failed = exclusively_owned
298                                && match self.fs.punch_hole(&self.path, data_start, len) {
299                                    Ok(()) => false,
300                                    Err(e) => {
301                                        log::warn!(
302                                            "Failed to punch tight-space data [{data_start}, {off}) of blob file {:?} at {}; retaining it for a retry: {e:?}",
303                                            self.id,
304                                            self.path.display(),
305                                        );
306                                        true
307                                    }
308                                };
309                            if (!exclusively_owned || punch_failed)
310                                && let Some(pause) = self.deletion_pause.get()
311                            {
312                                pause.retain_reclaim(
313                                    Arc::clone(&self.fs),
314                                    self.path.clone(),
315                                    alloc::vec![(data_start, len)],
316                                );
317                            }
318                        }
319                    }
320                }
321            }
322        }
323    }
324}
325
326/// Byte offset where a blob file's `data` section begins, read from its SFA TOC.
327/// Used by the tight-space punch so it reclaims only data frames and never the
328/// SFA header that precedes them.
329/// The extent a reclaim frees when everything below `live_up_to` is consumed:
330/// `[data section start, live_up_to)`, or `None` when nothing lies below it.
331///
332/// The two callers are the punch-on-drop of a superseded view and recovery's
333/// re-derivation of a reclaim a previous session could not finish; sharing the
334/// arithmetic keeps them from disagreeing about where the reclaimable region
335/// begins.
336///
337/// # Errors
338///
339/// Propagates the TOC read of `path`.
340#[cfg(feature = "std")]
341fn reclaimable_prefix(
342    fs: &dyn Fs,
343    path: &Path,
344    live_up_to: u64,
345) -> crate::Result<Option<(u64, u64)>> {
346    let data_start = data_section_start(fs, path)?;
347    Ok((live_up_to > data_start).then(|| (data_start, live_up_to - data_start)))
348}
349
350#[cfg(feature = "std")]
351fn data_section_start(fs: &dyn Fs, path: &Path) -> crate::Result<u64> {
352    let mut file = fs.open(path, &crate::fs::FsOpenOptions::new().read(true))?;
353    let reader = crate::sfa::Reader::from_reader(&mut file)?;
354    let data = reader
355        .toc()
356        .section(b"data")
357        .ok_or(crate::Error::InvalidHeader("BlobFile"))?;
358    Ok(data.pos())
359}
360
361/// A blob file stores large values and is part of the value log
362#[derive(Clone)]
363pub struct BlobFile(pub(crate) Arc<Inner>);
364
365impl Eq for BlobFile {}
366
367impl PartialEq for BlobFile {
368    fn eq(&self, other: &Self) -> bool {
369        self.id().eq(&other.id())
370    }
371}
372
373impl core::hash::Hash for BlobFile {
374    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
375        self.id().hash(state);
376    }
377}
378
379impl BlobFile {
380    pub(crate) fn mark_as_deleted(&self) {
381        self.0
382            .is_deleted
383            .store(true, core::sync::atomic::Ordering::Release);
384    }
385
386    /// Marks this view to punch the consumed `[data_start, offset)` data frames
387    /// when its last `Arc` drops (see [`Inner::punch_on_drop`]). `offset` is an
388    /// absolute data-section position. Set on the PRIOR view once a tight-space
389    /// relocation slice has moved its `[data_start, offset)` live entries into a
390    /// fresh compact file and that move is durably installed.
391    #[cfg(feature = "std")]
392    pub(crate) fn mark_punch_on_drop(&self, offset: u64) {
393        self.0
394            .punch_on_drop
395            .store(offset, core::sync::atomic::Ordering::Release);
396    }
397
398    /// Re-opens this blob file as a DISTINCT [`Inner`] (its own file handle and
399    /// a fresh punch-on-drop atomic) restricted to `[frontier, end)`: the
400    /// tight-space relocation loop installs this view in the new version and
401    /// arms the PRIOR view to punch everything below the frontier once its
402    /// readers drain, so a stale blob file is reclaimed in place while the
403    /// suffix keeps serving the not-yet-relocated entries — the blob analog of
404    /// [`Table::reopen_restricted`](crate::Table::reopen_restricted).
405    ///
406    /// The digest is re-computed over that LIVE SUFFIX now, while the file is
407    /// still whole — the punch is what makes a whole-file digest unusable, and
408    /// reading the suffix fresh also folds in anything the relocation just
409    /// wrote. The frontier rides on the view, so `diff` / the snapshot encoder
410    /// persist it and integrity checks hash from there.
411    ///
412    /// # Errors
413    ///
414    /// Propagates any error from re-opening the file or hashing its suffix.
415    #[cfg(feature = "std")]
416    pub(crate) fn reopen_restricted(&self, frontier: u64) -> crate::Result<Self> {
417        let checksum = crate::Checksum::from_raw(crate::repair::compute_table_checksum_from(
418            &*self.0.fs,
419            &self.0.path,
420            frontier,
421        )?);
422        super::recover_blob_file_from(
423            &self.0.path,
424            self.0.id,
425            checksum,
426            self.0.tree_id,
427            &self.0.fs,
428            frontier,
429        )
430    }
431
432    /// Installs the tree-wide deletion pause used by checkpoints.
433    /// Idempotent: a second call is a no-op.
434    pub(crate) fn install_deletion_pause(&self, pause: Arc<DeletionPause>) {
435        let _ = self.0.deletion_pause.set(Box::new(pause));
436    }
437
438    /// Installs the tree-wide background file deleter. Idempotent.
439    #[cfg(feature = "std")]
440    pub(crate) fn install_background_deleter(&self, deleter: Arc<crate::BackgroundDeleter>) {
441        let _ = self.0.background_deleter.set(Box::new(deleter));
442    }
443
444    /// Binds this freshly created blob file to the tree's shared machinery.
445    ///
446    /// **Every path that makes a new blob file reachable must call this**, for
447    /// the same reason its table counterpart exists
448    /// ([`Table::bind_to_tree`](crate::Table::bind_to_tree)): a file that
449    /// skips it looks healthy and fails silently later. Without the deletion
450    /// pause its `Drop` can unlink the file while a checkpoint is capturing —
451    /// before the checkpoint links it — and a tight-space prefix punch can
452    /// zero bytes the checkpoint has already hard-linked.
453    ///
454    /// Idempotent per sink, so re-binding is harmless.
455    pub(crate) fn bind_to_tree(&self, sinks: &crate::table::TableSinks<'_>) {
456        self.install_deletion_pause(Arc::clone(sinks.deletion_pause));
457        #[cfg(feature = "std")]
458        if let Some(deleter) = sinks.background_deleter {
459            self.install_background_deleter(Arc::clone(deleter));
460        }
461    }
462
463    /// The installed deletion pause, so tests can assert that every path
464    /// publishing a blob file binds it.
465    #[cfg(test)]
466    pub(crate) fn deletion_pause_for_test(&self) -> Option<Arc<DeletionPause>> {
467        self.0.deletion_pause.get().cloned()
468    }
469
470    /// Returns the blob file ID.
471    #[must_use]
472    pub fn id(&self) -> BlobFileId {
473        self.0.id
474    }
475
476    /// First LIVE byte of this view: `0` for a whole file, or the frontier a
477    /// tight-space relocation left after reclaiming the consumed prefix. The
478    /// recorded [`checksum`](Self::checksum) covers `[live_data_start, end)`,
479    /// so integrity checks hash from here rather than over the punched prefix.
480    #[must_use]
481    pub fn live_data_start(&self) -> u64 {
482        self.0.live_data_start
483    }
484
485    /// The extent this view's committed frontier declares consumed, or `None`
486    /// when nothing is.
487    ///
488    /// Recovery uses it to re-derive a reclaim a previous session could not
489    /// finish: the punch intent lived only in that session's queue, and the
490    /// superseded view able to re-arm it is gone after a restart, so a blob
491    /// prefix a checkpoint's link once deferred would otherwise stay allocated
492    /// for the life of the recovered file. Nothing is persisted for this; the
493    /// extent follows from `live_data_start`.
494    ///
495    /// # Errors
496    ///
497    /// Propagates the TOC read of the file.
498    #[cfg(feature = "std")]
499    pub(crate) fn committed_reclaimable_prefix(&self) -> crate::Result<Option<(u64, u64)>> {
500        reclaimable_prefix(&*self.0.fs, &self.0.path, self.0.live_data_start)
501    }
502
503    /// Returns the full blob file checksum.
504    #[must_use]
505    pub fn checksum(&self) -> Checksum {
506        self.0.checksum
507    }
508
509    /// The compression applied to this blob file's values (the descriptor a
510    /// reader uses to decode each record's on-disk bytes).
511    #[must_use]
512    pub(crate) fn compression(&self) -> crate::CompressionType {
513        self.0.meta.compression
514    }
515
516    /// The file's decoded metadata block (counters, key range, compression).
517    #[must_use]
518    pub(crate) fn meta(&self) -> &Metadata {
519        &self.0.meta
520    }
521
522    /// Returns the blob file path.
523    #[must_use]
524    pub fn path(&self) -> &Path {
525        &self.0.path
526    }
527
528    /// Returns the blob file accessor.
529    #[must_use]
530    pub(crate) fn file_accessor(&self) -> &FileAccessor {
531        &self.0.file_accessor
532    }
533
534    /// Returns the number of items in the blob file.
535    #[must_use]
536    #[expect(clippy::len_without_is_empty)]
537    pub fn len(&self) -> u64 {
538        self.0.meta.item_count
539    }
540
541    /// Physical on-disk file size in bytes, including the per-entry framing
542    /// (V4 header + key) and the metadata block / trailer — not just the
543    /// compressed payload (`meta.total_compressed_bytes`). Used as a
544    /// conservative upper bound on the transient output of a blob relocation:
545    /// the rewritten file re-emits the same framing, so the source file's
546    /// physical size bounds the output (and includes the dead blobs a relocation
547    /// drops, making it strictly conservative).
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if the blob file's size cannot be stat-ed.
552    pub(crate) fn physical_size(&self) -> crate::Result<u64> {
553        Ok(self.0.fs.metadata(&self.0.path)?.len)
554    }
555
556    /// Returns `true` if the blob file is stale (based on the given staleness threshold).
557    pub(crate) fn is_stale(&self, frag_map: &FragmentationMap, threshold: f32) -> bool {
558        frag_map.get(&self.id()).is_some_and(|x| {
559            #[expect(
560                clippy::cast_precision_loss,
561                reason = "ok to lose precision as this is an approximate calculation"
562            )]
563            let stale_bytes = x.bytes as f32;
564            #[expect(
565                clippy::cast_precision_loss,
566                reason = "ok to lose precision as this is an approximate calculation"
567            )]
568            let all_bytes = self.0.meta.total_uncompressed_bytes as f32;
569            let ratio = stale_bytes / all_bytes;
570            ratio >= threshold
571        })
572    }
573
574    /// Returns `true` if the blob file has no more incoming references, and can be safely removed from a Version.
575    pub(crate) fn is_dead(&self, frag_map: &FragmentationMap) -> bool {
576        frag_map.get(&self.id()).is_some_and(|x| {
577            let stale_bytes = x.bytes;
578            let all_bytes = self.0.meta.total_uncompressed_bytes;
579            stale_bytes == all_bytes
580        })
581    }
582}