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}