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;