Skip to main content

lsm_tree/
storage_stats.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026-present, Dmitry Prudnikov
3
4//! Read-only storage introspection: how much is stored, the average shape of a
5//! stored entry, and an estimate of how many more entries fit in a byte budget.
6//!
7//! Computed from the live version's table + blob-file metadata plus one
8//! size-stat per live file (the same accounting `Tree::create_checkpoint`
9//! uses), so it never touches the data blocks. See
10//! [`crate::AbstractTree::storage_stats`].
11
12use crate::version::Version;
13#[cfg(not(feature = "std"))]
14use alloc::vec::Vec;
15
16/// Coarse storage state of a tree.
17///
18/// With storage admission gating off (no configured quota and a backend that
19/// cannot report free space) a tree reports [`Self::Healthy`] or, mid-run,
20/// [`Self::CompactionInProgress`]. Once gating is active (bounded capacity), an
21/// idle tree instead reports compaction availability:
22/// [`Self::FullCompactionAvailable`] when a full compaction has working room,
23/// [`Self::TightCompactionAvailable`] when only the opt-in tight-space mode
24/// would fit, and [`Self::ReadOnlyOutOfSpace`] when the write gate is closed
25/// (this takes precedence over a concurrent compaction).
26#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
27#[non_exhaustive]
28pub enum StorageStatus {
29    /// Normal operation: writes and a full compaction are available.
30    Healthy,
31    /// Enough free space for a normal (full) compaction.
32    FullCompactionAvailable,
33    /// Not enough space for a full compaction, but the opt-in tight-space
34    /// (incremental-reclaim) compaction mode can still run.
35    TightCompactionAvailable,
36    /// Out of space: the tree is read-only until space is freed or the quota
37    /// is raised.
38    ReadOnlyOutOfSpace,
39    /// A compaction is currently running.
40    CompactionInProgress,
41}
42
43/// A point-in-time snapshot of a tree's on-disk storage footprint and the
44/// average shape of a stored entry.
45///
46/// All byte figures are on-disk (post-compression, including any per-block
47/// overhead and blob files). Averages are over every stored entry version, so
48/// they pair with [`Self::item_count`].
49#[must_use]
50#[derive(Copy, Clone, Debug, Eq, PartialEq)]
51pub struct StorageStats {
52    /// Total on-disk bytes of all live SSTs plus blob files (including a
53    /// restricted table's live `.restrict-bound` sidecar): how much is
54    /// **occupied**. Pairs with [`Self::capacity_bytes`] / [`Self::available_bytes`]
55    /// for an "X of Y used" view in a single call.
56    pub used_bytes: u64,
57
58    /// Total bytes the tree may occupy: the tighter of a configured byte quota
59    /// (`storage_limit_bytes`) and the physical disk headroom (free space plus
60    /// what is already used), across every volume the tree writes to. `None`
61    /// when unbounded: no quota set AND the backend cannot report free space.
62    pub capacity_bytes: Option<u64>,
63
64    /// Free room left before the tree turns read-only: `capacity_bytes - used_bytes`
65    /// (saturating). `None` exactly when [`Self::capacity_bytes`] is `None`
66    /// (unbounded).
67    pub available_bytes: Option<u64>,
68
69    /// Whether a compaction can still run given the remaining free space (it
70    /// needs working room to write merged output). `true` when unbounded or
71    /// when at least [`Self::tight_compaction_bytes`] of free space remains;
72    /// `false` when the disk is too full for a compaction to make progress. The
73    /// finer full-vs-tight distinction is carried by [`Self::status`].
74    pub compaction_possible: bool,
75
76    /// Estimated free space (bytes) a FULL compaction needs for its transient
77    /// output while the inputs still exist: the largest level's on-disk size
78    /// (an upper bound on a single merge's input set). A full compaction has
79    /// room when [`Self::available_bytes`] `>=` this. Pair with `used_bytes` /
80    /// `capacity_bytes` to draw a capacity gauge: `used` → `used + tight_compaction_bytes`
81    /// → `used + full_compaction_bytes` → `capacity`.
82    pub full_compaction_bytes: u64,
83
84    /// Estimated free space (bytes) a minimal (tight) space-reclaiming
85    /// compaction needs to make forward progress: the reserved working floor.
86    /// Tight compaction has room when [`Self::available_bytes`] `>=` this.
87    pub tight_compaction_bytes: u64,
88
89    /// Number of live entries (all versions) across all live SSTs.
90    pub item_count: u64,
91
92    /// Number of live SSTs.
93    pub table_count: u64,
94
95    /// Average on-disk bytes per entry (`used_bytes / item_count`), or `0` when
96    /// the tree is empty. This is the figure
97    /// [`Self::estimated_remaining_entries`] divides a budget by.
98    pub avg_entry_on_disk_bytes: u64,
99
100    /// Average user-key byte length per entry, or `None` if any live table was
101    /// written before per-table key/value byte sums were recorded (the average
102    /// key/value split is only exact when every table carries the figures).
103    pub avg_key_bytes: Option<u64>,
104
105    /// Average value byte length per entry, or `None` under the same condition
106    /// as [`Self::avg_key_bytes`].
107    pub avg_value_bytes: Option<u64>,
108
109    /// Estimated bytes a full compaction could reclaim, from the
110    /// weak-tombstone-reclaimable entry count times the average on-disk entry
111    /// size. An estimate, not an exact figure.
112    pub reclaimable_bytes_estimate: u64,
113
114    /// Coarse storage state.
115    pub status: StorageStatus,
116}
117
118/// Approximate size of a key range, estimated from SST block-index offsets and
119/// the active memtable WITHOUT reading any data block. Returned by
120/// [`crate::AbstractTree::approximate_range_stats`].
121///
122/// Both figures are estimates from the same in-range fraction per source: each
123/// overlapping SST's data-block offsets are interpolated at the range
124/// boundaries (block granularity) and that fraction is applied to the SST's
125/// byte span and its entry count, while each memtable contributes its in-range
126/// skiplist count and the matching share of its size. Accuracy is typically
127/// within ~10-15% on roughly-uniform data; it is intended for query planning
128/// (split-point selection, cost-based join ordering), not exact accounting.
129#[must_use]
130#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
131pub struct ApproximateRangeStats {
132    /// Estimated on-disk bytes occupied by the range across all overlapping
133    /// SSTs (key + pointer + apportioned blob bytes) plus the active and sealed
134    /// memtables' in-range share. `0` for an empty range.
135    pub bytes: u64,
136
137    /// Estimated number of entry versions in the range: the sum, over each
138    /// overlapping SST, of `item_count × in-range fraction`, plus each
139    /// memtable's in-range skiplist count. `0` for an empty range.
140    pub key_count: u64,
141}
142
143/// Size and entry count of one stored segment (SST), for per-segment tiering and
144/// erasure-coding placement decisions.
145#[must_use]
146#[derive(Copy, Clone, Debug, Eq, PartialEq)]
147pub struct SegmentStats {
148    /// Identifier of the segment's SST within its tree.
149    pub table_id: crate::TableId,
150    /// LSM level the segment lives in (`0` is the newest / smallest level).
151    pub level: usize,
152    /// Physical on-disk bytes of the segment's SST file.
153    pub used_bytes: u64,
154    /// Number of entry versions stored in the segment.
155    pub item_count: u64,
156    /// Cumulative point reads that consulted this segment's data since it was
157    /// created: only reads that pass the segment's seqno-range and bloom gates
158    /// count (a bloom miss is not counted), so this tracks data hotness rather
159    /// than raw probe frequency. A monotonic counter, not a rate: derive a
160    /// read-rate / EMA from the delta between successive polls. `0` when never
161    /// read.
162    pub reads: u64,
163    /// Unix seconds of the segment's most recent data-consulting read, or `0` if
164    /// never read (or on a no-std build, which keeps no clock).
165    pub last_access_secs: u64,
166}
167
168/// Per-LSM-level size + entry aggregates with the contributing segments, for
169/// tiering and erasure-coding placement (which level / segment is large enough
170/// to demote, EC-encode, or migrate).
171///
172/// Cheap to read: derived from version metadata plus one file-size stat per
173/// segment, never a data-block scan. The per-level totals reconcile with the
174/// tree-level [`StorageStats`]: summed across levels they equal the SST portion
175/// of [`StorageStats::used_bytes`] and [`StorageStats::item_count`] (blob files
176/// are tracked separately).
177#[must_use]
178#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct LevelStats {
180    /// LSM level index (`0` is the newest / smallest level).
181    pub level: usize,
182    /// Number of segments (SSTs) in the level.
183    pub segment_count: usize,
184    /// Physical on-disk bytes summed across the level's segments.
185    pub used_bytes: u64,
186    /// Entry versions summed across the level's segments.
187    pub item_count: u64,
188    /// Cumulative point-read probes summed across the level's segments.
189    pub reads: u64,
190    /// Most recent point-read probe across the level's segments, in unix
191    /// seconds, or `0` if none was ever read.
192    pub last_access_secs: u64,
193    /// Per-segment breakdown, in level (run / table) order.
194    pub segments: Vec<SegmentStats>,
195}
196
197/// Approximate cardinality and selectivity of a key range, for cost-based query
198/// planning (join ordering, scan-vs-seek).
199///
200/// Both figures derive from the per-data-block zone map (per-block row counts +
201/// key ranges) when present, falling back to the byte-fraction estimate of
202/// [`ApproximateRangeStats`] otherwise. They are estimates at block granularity,
203/// never exact.
204#[must_use]
205#[derive(Copy, Clone, Debug, Default, PartialEq)]
206pub struct RangeCardinality {
207    /// Estimated number of rows (entry versions) the range covers: the sum of
208    /// the per-block row counts of every data block whose key range overlaps the
209    /// query range, plus each memtable's in-range count. `0` for an empty range.
210    pub rows: u64,
211
212    /// Estimated fraction of the tree's rows the range selects, in `0.0..=1.0`:
213    /// `rows / total_rows`. Monotonic in predicate tightness (a narrower range
214    /// never yields a larger selectivity). `0.0` when the tree is empty.
215    pub selectivity: f64,
216}
217
218/// Grouped, object-safe read-only storage-statistics surface.
219///
220/// A coherent view over a tree's non-query statistics: on-disk footprint
221/// ([`storage_stats`](Self::storage_stats)), per-level / per-segment sizing
222/// ([`level_segment_stats`](Self::level_segment_stats)), compaction debt
223/// ([`compaction_debt`](Self::compaction_debt)), and block-cache health
224/// (`cache_stats`, behind the `metrics` feature). A planner / tiering / capacity consumer
225/// bounds on `T: StorageStatistics` (or `&dyn StorageStatistics`) and a test can
226/// supply a mock. Every [`AbstractTree`](crate::AbstractTree) implements it via a
227/// blanket impl (`impl<T: AbstractTree + ?Sized> StorageStatistics for T`).
228///
229/// The per-query range estimators
230/// ([`approximate_range_stats`](crate::AbstractTree::approximate_range_stats),
231/// [`approximate_range_cardinality`](crate::AbstractTree::approximate_range_cardinality))
232/// are generic over the range type and so not object-safe; they stay on
233/// [`AbstractTree`](crate::AbstractTree) rather than joining this trait.
234pub trait StorageStatistics {
235    /// On-disk footprint and average entry shape: used / capacity / available
236    /// bytes, item & table counts, average entry size, reclaimable-bytes
237    /// estimate, and a coarse [`StorageStatus`]. See
238    /// [`StorageStats::estimated_remaining_entries`] for a budget projection.
239    ///
240    /// # Examples
241    ///
242    /// ```
243    /// # use lsm_tree::Error as TreeError;
244    /// use lsm_tree::{AbstractTree, Config, StorageStatistics};
245    ///
246    /// let folder = tempfile::tempdir()?;
247    /// let tree = Config::new(&folder, Default::default(), Default::default()).open()?;
248    /// for i in 0..100u32 {
249    ///     tree.insert(format!("k{i:04}"), "v", 0);
250    /// }
251    /// tree.flush_active_memtable(0)?;
252    ///
253    /// // Both traits are in scope, so disambiguate the shared method name.
254    /// let stats = StorageStatistics::storage_stats(&tree)?;
255    /// assert_eq!(stats.item_count, 100);
256    /// // Roughly how many more average-shaped entries fit in another 1 MiB.
257    /// let _headroom = stats.estimated_remaining_entries(1024 * 1024);
258    /// #
259    /// # Ok::<(), TreeError>(())
260    /// ```
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if a live file's size cannot be stat-ed.
265    fn storage_stats(&self) -> crate::Result<StorageStats>;
266
267    /// Per-LSM-level and per-segment size + entry-count stats, for tiering and
268    /// erasure-coding placement decisions (which level / segment is large enough
269    /// to demote, EC-encode, or migrate).
270    ///
271    /// Cheap: derived from the live version's metadata plus one file-size stat
272    /// per segment (no data-block scan). The per-level totals reconcile with
273    /// [`storage_stats`](Self::storage_stats): summed across levels they equal
274    /// the SST portion of [`StorageStats::used_bytes`] and
275    /// [`StorageStats::item_count`].
276    ///
277    /// # Examples
278    ///
279    /// ```
280    /// # use lsm_tree::Error as TreeError;
281    /// use lsm_tree::{AbstractTree, Config, StorageStatistics};
282    ///
283    /// let folder = tempfile::tempdir()?;
284    /// let tree = Config::new(&folder, Default::default(), Default::default()).open()?;
285    /// for i in 0..100u32 {
286    ///     tree.insert(format!("k{i:04}"), "v", 0);
287    /// }
288    /// tree.flush_active_memtable(0)?;
289    ///
290    /// // Both traits are in scope, so disambiguate the shared method names.
291    /// let levels = StorageStatistics::level_segment_stats(&tree)?;
292    /// let total: u64 = levels.iter().map(|l| l.item_count).sum();
293    /// assert_eq!(total, StorageStatistics::storage_stats(&tree)?.item_count);
294    /// #
295    /// # Ok::<(), TreeError>(())
296    /// ```
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if a segment's file size cannot be stat-ed.
301    fn level_segment_stats(&self) -> crate::Result<Vec<LevelStats>>;
302
303    /// Estimated bytes pending compaction under `strategy`: on-disk data above
304    /// its level's target that must eventually be rewritten downward (a `RocksDB`
305    /// `estimate-pending-compaction-bytes` analog), a compaction-debt signal for a
306    /// scheduler / tiering consumer.
307    ///
308    /// The strategy is a caller argument because the engine does not own a
309    /// configured compaction strategy (it is injected per compaction run); a
310    /// `&dyn` keeps this object-safe. Returns `0` for strategies without a
311    /// size-target notion of debt (FIFO, drop-range), or when the tree is at or
312    /// below its target shape. See
313    /// [`CompactionStrategy::pending_compaction_bytes`](crate::compaction::CompactionStrategy::pending_compaction_bytes).
314    fn compaction_debt(&self, strategy: &dyn crate::compaction::CompactionStrategy) -> u64;
315
316    /// A point-in-time [`CacheStats`](crate::CacheStats) snapshot of block-cache
317    /// effectiveness (cumulative hit / miss counts and rate) and occupancy
318    /// (current size against capacity).
319    ///
320    /// The stable, owned observability view over the block cache, so a consumer
321    /// reads cache health without holding the mutable
322    /// [`metrics`](crate::AbstractTree::metrics) handle. Counts are cumulative
323    /// since process start; derive a rate over an interval from the delta between
324    /// two polls.
325    #[cfg(feature = "metrics")]
326    fn cache_stats(&self) -> crate::CacheStats;
327}
328
329/// Every [`AbstractTree`](crate::AbstractTree) is a [`StorageStatistics`] by
330/// delegating to its own inherent stats methods, so a `Tree` / `BlobTree` can be
331/// used directly as `&dyn StorageStatistics`. The logic lives once on
332/// `AbstractTree`; this is a thin object-safe re-exposure for the grouped /
333/// mockable surface (a test mock implements `StorageStatistics` directly without
334/// being an `AbstractTree`). When both traits are in scope, disambiguate a bare
335/// `tree.storage_stats()` with `StorageStatistics::storage_stats(&tree)`.
336impl<T: crate::AbstractTree + ?Sized> StorageStatistics for T {
337    fn storage_stats(&self) -> crate::Result<StorageStats> {
338        crate::AbstractTree::storage_stats(self)
339    }
340
341    fn level_segment_stats(&self) -> crate::Result<Vec<LevelStats>> {
342        crate::AbstractTree::level_segment_stats(self)
343    }
344
345    fn compaction_debt(&self, strategy: &dyn crate::compaction::CompactionStrategy) -> u64 {
346        crate::AbstractTree::compaction_debt(self, strategy)
347    }
348
349    #[cfg(feature = "metrics")]
350    fn cache_stats(&self) -> crate::CacheStats {
351        crate::AbstractTree::cache_stats(self)
352    }
353}
354
355impl StorageStats {
356    /// Approximately how many more average-shaped entries fit in `budget_bytes`,
357    /// using [`Self::avg_entry_on_disk_bytes`].
358    ///
359    /// Returns `0` when the average entry size is unknown (an empty tree), since
360    /// there is no basis for the estimate.
361    #[must_use]
362    pub fn estimated_remaining_entries(&self, budget_bytes: u64) -> u64 {
363        if self.avg_entry_on_disk_bytes == 0 {
364            0
365        } else {
366            budget_bytes / self.avg_entry_on_disk_bytes
367        }
368    }
369}
370
371/// Sums the true physical on-disk size of every live table and blob file in
372/// `version` (one metadata stat per file).
373///
374/// This is the same physical basis [`compute_storage_stats`] reports as
375/// `used_bytes` and that `Tree::create_checkpoint` totals, so the storage
376/// admission gate agrees with both. It deliberately does NOT use
377/// `Metadata::file_size` (undercounts by the meta block / footer) or
378/// `disk_space()` (metadata `Level::size`, which also omits blob files).
379///
380/// # Errors
381///
382/// Returns an error if a live table or blob file's size cannot be stat-ed.
383pub(crate) fn compute_used_bytes(version: &Version) -> crate::Result<u64> {
384    // Sum of on-disk file sizes, bounded by the filesystem capacity → cannot
385    // overflow u64; plain arithmetic.
386    let mut used_bytes = 0u64;
387    for table in version.iter_tables() {
388        used_bytes += table_on_disk_bytes(table)?;
389    }
390    for blob in version.blob_files.iter() {
391        used_bytes += blob_on_disk_bytes(blob)?;
392    }
393    Ok(used_bytes)
394}
395
396/// The physical bytes a live blob file occupies.
397///
398/// Blob files are punched in place by the same tight-space reclaim as SSTs, so
399/// they are charged what they still occupy, not their logical length.
400///
401/// This is deliberately a DIFFERENT measure from `CheckpointInfo::total_bytes`,
402/// which counts logical lengths because that is what restoring a snapshot
403/// costs. The two agree for intact files and differ by exactly the punched
404/// holes once a reclaim has run.
405///
406/// # Errors
407///
408/// Propagates the stat failures of the blob file.
409pub(crate) fn blob_on_disk_bytes(blob: &crate::vlog::BlobFile) -> crate::Result<u64> {
410    Ok(crate::file::on_disk_bytes(&*blob.0.fs, &blob.0.path)?)
411}
412
413/// The physical bytes a live table occupies: the SST file plus, for a
414/// tight-space-RESTRICTED table, its `.restrict-bound` sidecar — a live
415/// companion file a checkpoint links and totals too, so both surfaces cover the
416/// same SET of files. A restricted view whose sidecar is missing on disk (a
417/// geometry-derived restriction after a repair) counts the SST alone.
418///
419/// The two surfaces measure that set differently on purpose: this one is
420/// physical (a punched prefix must leave the quota), while
421/// `CheckpointInfo::total_bytes` is logical (that is what a restore costs).
422pub(crate) fn table_on_disk_bytes(table: &crate::table::Table) -> crate::Result<u64> {
423    // Physical bytes: charging the logical length would keep a tight-space
424    // compaction's freed prefix on the quota forever, so under
425    // `storage_limit_bytes` the headroom would never recover and the tree would
426    // stay read-only despite the compaction having succeeded.
427    #[cfg_attr(not(feature = "std"), expect(unused_mut, reason = "no sidecar arm"))]
428    let mut bytes = crate::file::on_disk_bytes(&*table.fs, &table.path)?;
429    // Restrictions are created only by the std-only tight-space / repair
430    // paths, so the sidecar probe is std-gated with them.
431    #[cfg(feature = "std")]
432    if table.restrict_lower_bound().is_some() {
433        match table
434            .fs
435            .metadata(&crate::restrict_bound::sidecar_path(&table.path))
436        {
437            Ok(m) => bytes += m.len,
438            Err(e) if e.kind() == crate::io::ErrorKind::NotFound => {}
439            Err(e) => return Err(e.into()),
440        }
441    }
442    Ok(bytes)
443}
444
445/// The transient-output bound a full compaction's space check uses: the largest
446/// level's live size (the `full_compaction_bytes` gauge figure), an upper bound
447/// on a single merge's input set. `0` for an empty tree.
448///
449/// Live, not `Level::size`: a tight-space-RESTRICTED table's `file_size` still
450/// describes the punched original, and charging that superseded prefix to the
451/// output would report the tree as tight — and the gate would stall an ordinary
452/// merge — while the real output fits.
453///
454/// This is the DEMAND. The destination VOLUME is a separate concern: a full
455/// compaction writes its output to the last configured level
456/// (`level_count - 1`), not to whichever level is currently largest, so callers
457/// pass the last level as the destination to the per-volume space check (the two
458/// differ only under tiered routing, where they can be different filesystems).
459///
460/// # Errors
461///
462/// Propagates a restricted table's punch-offset lookup.
463pub(crate) fn full_compaction_demand_bytes(version: &Version) -> crate::Result<u64> {
464    let mut largest = 0u64;
465    for level in version.iter_levels() {
466        // A level's live size: a sum of on-disk byte counts, bounded by the
467        // filesystem capacity, so it cannot overflow u64.
468        let mut size = 0u64;
469        for run in level.iter() {
470            for table in run.iter() {
471                size += table.live_file_size()?;
472            }
473        }
474        largest = largest.max(size);
475    }
476    Ok(largest)
477}
478
479/// Computes [`StorageStats`] from a live version's table + blob-file metadata.
480///
481/// `is_compacting` selects [`StorageStatus::CompactionInProgress`] vs
482/// [`StorageStatus::Healthy`]; the caller supplies it because compaction state
483/// is engine-internal.
484///
485/// `value_bytes_are_user_values` must be `false` for a KV-separated
486/// (`BlobTree`) tree: there the SST records a small indirection pointer per
487/// large value, not the user value, so the per-table value-byte sum measures
488/// pointers and the value average would misreport. When `false`,
489/// [`StorageStats::avg_value_bytes`] is forced to `None`. Key bytes are never
490/// separated, so [`StorageStats::avg_key_bytes`] stays exact either way.
491///
492/// `used_bytes` is the true on-disk footprint of every live table and blob file
493/// (one stat per file), not the writer's `Metadata::file_size` or
494/// `crate::version::Version::blob_files`' compressed-payload sum: those
495/// undercount the physical file by the meta block / footer / blob trailer. It
496/// covers the same files `Tree::create_checkpoint` totals, but measures them
497/// physically rather than logically, so the two differ by the holes a
498/// tight-space reclaim punched and agree everywhere else.
499///
500/// # Errors
501///
502/// Returns an error if a live table or blob file's size cannot be stat-ed.
503pub(crate) fn compute_storage_stats(
504    version: &Version,
505    is_compacting: bool,
506    value_bytes_are_user_values: bool,
507) -> crate::Result<StorageStats> {
508    let mut used_bytes = 0u64;
509    let mut item_count = 0u64;
510    let mut table_count = 0u64;
511    let mut reclaimable_entries = 0u64;
512    let mut sum_key = 0u64;
513    let mut sum_value = 0u64;
514    // The key/value split is only exact when EVERY live table records the byte
515    // sums; a single legacy table without them makes the average unrepresentable.
516    let mut all_have_shape = true;
517
518    // Every running total below is a sum of on-disk byte sizes or live item
519    // counts; both are bounded by the filesystem capacity / the live entry count
520    // and cannot overflow u64, so plain arithmetic is correct (a debug-overflow
521    // would itself signal a corrupt metadata read).
522    for table in version.iter_tables() {
523        let m = &table.metadata;
524        // Physical file size, NOT m.file_size (which undercounts — see above);
525        // a restricted table's live sidecar counts too (same basis as the
526        // checkpoint total, see `table_on_disk_bytes`).
527        let on_disk = table_on_disk_bytes(table)?;
528        used_bytes += on_disk;
529        // A restricted view's metadata still describes the whole original SST;
530        // its consumed prefix belongs to the output that superseded it, and
531        // both live in this version while a slice is in flight. Count what this
532        // view serves, and scale the per-entry aggregates by the same share so
533        // the averages stay consistent with the count.
534        let live_items = table.live_item_count()?;
535        let share = |total: u64| -> u64 {
536            if live_items == m.item_count || m.item_count == 0 {
537                return total;
538            }
539            u64::try_from(u128::from(total) * u128::from(live_items) / u128::from(m.item_count))
540                .unwrap_or(total)
541        };
542        item_count += live_items;
543        table_count += 1;
544        reclaimable_entries += share(m.weak_tombstone_reclaimable);
545        match (
546            m.sum_user_key_bytes.map(share),
547            m.sum_value_bytes.map(share),
548        ) {
549            (Some(k), Some(v)) => {
550                sum_key += k;
551                sum_value += v;
552            }
553            _ => all_have_shape = false,
554        }
555    }
556
557    // Physical blob-file size (metadata + trailer included), NOT
558    // BlobFileList::on_disk_size() which sums only the compressed payload.
559    for blob in version.blob_files.iter() {
560        used_bytes += blob_on_disk_bytes(blob)?;
561    }
562
563    let avg_entry_on_disk_bytes = if item_count == 0 {
564        0
565    } else {
566        used_bytes / item_count
567    };
568
569    let have_shape = all_have_shape && item_count > 0;
570    let avg_key_bytes = have_shape.then(|| sum_key / item_count);
571    // Value bytes are only meaningful when not KV-separated (see param doc).
572    let avg_value_bytes =
573        (have_shape && value_bytes_are_user_values).then(|| sum_value / item_count);
574
575    // reclaimable_entries ≤ item_count and avg_entry_on_disk_bytes = used / item_count,
576    // so the product is ≤ used_bytes (bounded by disk capacity): plain multiply.
577    let reclaimable_bytes_estimate = reclaimable_entries * avg_entry_on_disk_bytes;
578
579    // A full compaction's transient output is bounded by its input set; the
580    // largest single merge is bounded by the largest level's on-disk size, so
581    // that is the free space a full compaction needs.
582    let full_compaction_bytes = full_compaction_demand_bytes(version)?;
583    // A minimal (tight) space-reclaiming merge needs only the reserved working
584    // floor to make forward progress.
585    let tight_compaction_bytes = crate::tree::MIN_RESERVED_HEADROOM;
586
587    let status = if is_compacting {
588        StorageStatus::CompactionInProgress
589    } else {
590        StorageStatus::Healthy
591    };
592
593    Ok(StorageStats {
594        used_bytes,
595        // Capacity is disk-aware (quota + free-space probe) and lives at the
596        // tree layer; this version-only computation leaves it unbounded. The
597        // caller (`Tree::storage_stats`) fills the real figures.
598        capacity_bytes: None,
599        available_bytes: None,
600        compaction_possible: true,
601        full_compaction_bytes,
602        tight_compaction_bytes,
603        item_count,
604        table_count,
605        avg_entry_on_disk_bytes,
606        avg_key_bytes,
607        avg_value_bytes,
608        reclaimable_bytes_estimate,
609        status,
610    })
611}
612
613/// Computes per-LSM-level and per-segment size + entry stats from a version.
614///
615/// Cost is O(levels x segments) plus one file-size stat per segment (the same
616/// stat [`compute_storage_stats`] already performs); it never reads a data block.
617///
618/// # Errors
619///
620/// Returns an error if a segment's file size cannot be stat-ed.
621pub(crate) fn compute_level_segment_stats(version: &Version) -> crate::Result<Vec<LevelStats>> {
622    use core::sync::atomic::Ordering::Relaxed;
623    let mut levels = Vec::with_capacity(version.level_count());
624    for (level, run_group) in version.iter_levels().enumerate() {
625        let mut segments = Vec::new();
626        let mut used_bytes = 0u64;
627        let mut item_count = 0u64;
628        let mut reads = 0u64;
629        let mut last_access_secs = 0u64;
630        for run in run_group.iter() {
631            for table in run.iter() {
632                // Physical file size, NOT m.file_size (which undercounts), to
633                // reconcile with the tree-level `used_bytes` — including a
634                // restricted table's live restriction sidecar (the same basis
635                // as `table_on_disk_bytes`), so summing the levels matches
636                // the documented SST portion of the tree total.
637                let on_disk = table_on_disk_bytes(table)?;
638                // What this VIEW serves, on the same basis as the tree total: a
639                // restricted table's metadata still counts the prefix the
640                // superseding output owns.
641                let items = table.live_item_count()?;
642                let seg_reads = table.read_count.load(Relaxed);
643                let seg_access = table.last_access_secs.load(Relaxed);
644                used_bytes += on_disk;
645                item_count += items;
646                reads = reads.saturating_add(seg_reads);
647                last_access_secs = last_access_secs.max(seg_access);
648                segments.push(SegmentStats {
649                    table_id: table.metadata.id,
650                    level,
651                    used_bytes: on_disk,
652                    item_count: items,
653                    reads: seg_reads,
654                    last_access_secs: seg_access,
655                });
656            }
657        }
658        levels.push(LevelStats {
659            level,
660            segment_count: segments.len(),
661            used_bytes,
662            item_count,
663            reads,
664            last_access_secs,
665            segments,
666        });
667    }
668    Ok(levels)
669}
670
671#[cfg(test)]
672mod tests;