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. The blob reference figures also
10//! read each table's blob-link section, once: the table keeps it after. See
11//! [`crate::AbstractTree::storage_stats`].
12
13use crate::version::Version;
14#[cfg(not(feature = "std"))]
15use alloc::vec::Vec;
16
17/// Coarse storage state of a tree.
18///
19/// With storage admission gating off (no configured quota and a backend that
20/// cannot report free space) a tree reports [`Self::Healthy`] or, mid-run,
21/// [`Self::CompactionInProgress`]. Once gating is active (bounded capacity), an
22/// idle tree instead reports compaction availability:
23/// [`Self::FullCompactionAvailable`] when a full compaction has working room,
24/// [`Self::TightCompactionAvailable`] when only the opt-in tight-space mode
25/// would fit, and [`Self::ReadOnlyOutOfSpace`] when the write gate is closed
26/// (this takes precedence over a concurrent compaction).
27#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
28#[non_exhaustive]
29pub enum StorageStatus {
30    /// Normal operation: writes and a full compaction are available.
31    Healthy,
32    /// Enough free space for a normal (full) compaction.
33    FullCompactionAvailable,
34    /// Not enough space for a full compaction, but the opt-in tight-space
35    /// (incremental-reclaim) compaction mode can still run.
36    TightCompactionAvailable,
37    /// Out of space: the tree is read-only until space is freed or the quota
38    /// is raised.
39    ReadOnlyOutOfSpace,
40    /// A compaction is currently running.
41    CompactionInProgress,
42}
43
44/// A point-in-time view of the memory a tree's filters take, from
45/// [`crate::AbstractTree::filter_memory`].
46///
47/// Two figures, because they differ: every live filter is serialised on disk,
48/// but only a pinned filter or a filter block the block cache holds is in
49/// memory. A [`FilterAdvisor`](crate::config::FilterAdvisor) budget bounds
50/// the serialised bytes.
51#[must_use]
52#[derive(Copy, Clone, Debug, Eq, PartialEq)]
53pub struct FilterMemory {
54    /// On-disk bytes of the live tables' filter sections, what
55    /// [`crate::AbstractTree::filter_size`] reports.
56    pub serialised_bytes: u64,
57
58    /// Filter bytes in memory now: pinned filters, plus filter blocks the
59    /// block cache holds, at their on-disk size.
60    pub resident_bytes: u64,
61
62    /// The budget a filter advisor allocates against, when the tree has one.
63    pub budget_bytes: Option<u64>,
64
65    /// Whether the live filters exceed that budget: `serialised_bytes` past
66    /// `budget_bytes`. Existing filters shrink only when their tables are
67    /// rewritten, and a rewrite with no narrower width left writes past the
68    /// budget, so the tree can stay here for a while after the budget is
69    /// lowered or the keys outgrow it.
70    pub over_budget: bool,
71}
72
73/// A point-in-time snapshot of a tree's on-disk storage footprint and the
74/// average shape of a stored entry.
75///
76/// All byte figures are on-disk (post-compression, including any per-block
77/// overhead and blob files). Averages are over every stored entry version, so
78/// they pair with [`Self::item_count`].
79#[must_use]
80#[derive(Copy, Clone, Debug, Eq, PartialEq)]
81pub struct StorageStats {
82    /// Total on-disk bytes of all live SSTs plus blob files (including a
83    /// restricted table's live `.restrict-bound` sidecar): how much is
84    /// **occupied**. Pairs with [`Self::capacity_bytes`] / [`Self::available_bytes`]
85    /// for an "X of Y used" view in a single call.
86    pub used_bytes: u64,
87
88    /// Total bytes the tree may occupy: the tighter of a configured byte quota
89    /// (`storage_limit_bytes`) and the physical disk headroom (free space plus
90    /// what is already used), across every volume the tree writes to. `None`
91    /// when unbounded: no quota set AND the backend cannot report free space.
92    pub capacity_bytes: Option<u64>,
93
94    /// Free room left before the tree turns read-only: `capacity_bytes - used_bytes`
95    /// (saturating). `None` exactly when [`Self::capacity_bytes`] is `None`
96    /// (unbounded).
97    pub available_bytes: Option<u64>,
98
99    /// Whether a compaction can still run given the remaining free space (it
100    /// needs working room to write merged output). `true` when unbounded or
101    /// when at least [`Self::tight_compaction_bytes`] of free space remains;
102    /// `false` when the disk is too full for a compaction to make progress. The
103    /// finer full-vs-tight distinction is carried by [`Self::status`].
104    pub compaction_possible: bool,
105
106    /// Estimated free space (bytes) a FULL compaction needs for its transient
107    /// output while the inputs still exist: the largest level's on-disk size
108    /// (an upper bound on a single merge's input set). A full compaction has
109    /// room when [`Self::available_bytes`] `>=` this. Pair with `used_bytes` /
110    /// `capacity_bytes` to draw a capacity gauge: `used` → `used + tight_compaction_bytes`
111    /// → `used + full_compaction_bytes` → `capacity`.
112    pub full_compaction_bytes: u64,
113
114    /// Estimated free space (bytes) a minimal (tight) space-reclaiming
115    /// compaction needs to make forward progress: the reserved working floor.
116    /// Tight compaction has room when [`Self::available_bytes`] `>=` this.
117    pub tight_compaction_bytes: u64,
118
119    /// Number of live entries (all versions) across all live SSTs.
120    pub item_count: u64,
121
122    /// Number of live SSTs.
123    pub table_count: u64,
124
125    /// Average on-disk bytes per entry (`used_bytes / item_count`), or `0` when
126    /// the tree is empty. This is the figure
127    /// [`Self::estimated_remaining_entries`] divides a budget by.
128    pub avg_entry_on_disk_bytes: u64,
129
130    /// Average user-key byte length per entry, or `None` if any live table was
131    /// written before per-table key/value byte sums were recorded (the average
132    /// key/value split is only exact when every table carries the figures).
133    pub avg_key_bytes: Option<u64>,
134
135    /// Average value byte length per entry, or `None` under the same condition
136    /// as [`Self::avg_key_bytes`].
137    pub avg_value_bytes: Option<u64>,
138
139    /// Estimated bytes a full compaction could reclaim, from the
140    /// weak-tombstone-reclaimable entry count times the average on-disk entry
141    /// size. An estimate, not an exact figure.
142    pub reclaimable_bytes_estimate: u64,
143
144    /// Coarse storage state.
145    pub status: StorageStatus,
146
147    /// Blob files the tree's tables reference, and how many of them a scan
148    /// interleaves where their key spans overlap most, across every level. All
149    /// zero for a tree that does not separate values.
150    pub blob_references: BlobReferenceStats,
151}
152
153/// How scattered the blob values behind a set of tables are: a table, a level,
154/// or the whole tree.
155///
156/// The two figures answer different questions and must not be read as one.
157/// [`Self::count`] is how many blob files are referenced at all; thirty-two
158/// files each holding the values of one consecutive key range give a count of
159/// 32 while a scan still reads one file at a time. [`Self::depth`] is how many
160/// of those files a scan has to interleave where their key spans overlap most.
161#[must_use]
162#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
163pub struct BlobReferenceStats {
164    /// Distinct blob files referenced (fan-out). A statistic, not a measure
165    /// of locality.
166    pub count: u64,
167    /// The largest number of distinct blob files whose referenced key spans
168    /// cover one key: an estimate of how many files a range scan through
169    /// that key interleaves.
170    ///
171    /// An estimate from key spans alone. A span runs from the first to the
172    /// last key that references its file, so keys inside it that point
173    /// elsewhere still count as covered; a restricted table still reports the
174    /// spans of the whole table it was cut from. The figure cannot see the
175    /// block cache, how reads coalesce, or that two logical reads may land in
176    /// one physical one: it is what a locality trigger can act on, and the
177    /// blob-file reads of a scan are what show whether acting paid off.
178    pub depth: u64,
179}
180
181/// Blob reference count and depth over the given tables' links. The tables
182/// must share one comparator (one tree's).
183///
184/// # Errors
185///
186/// When a table's `linked_blob_files` section cannot be read or parsed.
187pub(crate) fn blob_reference_stats<'a>(
188    tables: impl IntoIterator<Item = &'a crate::table::Table>,
189) -> crate::Result<BlobReferenceStats> {
190    let mut comparator = None;
191    let mut spans = Vec::new();
192    collect_spans(tables, &mut comparator, &mut spans)?;
193    Ok(comparator.map_or_else(BlobReferenceStats::default, |cmp| {
194        span_stats(&mut spans, cmp.as_ref())
195    }))
196}
197
198/// Per blob file the tables reference, the depth where its key spans overlap
199/// most: the largest number of distinct files covering one key inside its
200/// spans. A file whose figure stays within a depth limit does not take part in
201/// any region that exceeds it. Ordered by file id; empty for tables without
202/// blob links.
203///
204/// # Errors
205///
206/// When a table's `linked_blob_files` section cannot be read or parsed.
207pub(crate) fn blob_file_depths<'a>(
208    tables: impl IntoIterator<Item = &'a crate::table::Table>,
209) -> crate::Result<Vec<(crate::vlog::BlobFileId, u64)>> {
210    let mut comparator = None;
211    let mut spans = Vec::new();
212    collect_spans(tables, &mut comparator, &mut spans)?;
213    Ok(comparator.map_or_else(Vec::new, |cmp| span_depths(&mut spans, cmp.as_ref())))
214}
215
216/// [`blob_file_depths`] over `(blob file, first key, last key)` spans ordered
217/// by `cmp`. Sorts `spans` in place.
218fn span_depths(
219    spans: &mut [Span<'_>],
220    cmp: &dyn crate::comparator::UserComparator,
221) -> Vec<(crate::vlog::BlobFileId, u64)> {
222    let (merged, _) = merge_spans(spans, cmp);
223
224    // The open count after each event, and where each span starts and ends in
225    // that sequence: a span's figure is the largest open count between its
226    // start and its end. Answered by a range-maximum table, so a deeply
227    // interleaved merge costs O(n log n) rather than a pass over every open
228    // span at every start.
229    let events = sweep_events(&merged, cmp);
230    let mut open_after = Vec::with_capacity(events.len());
231    let mut bounds = alloc::vec![(0usize, 0usize); merged.len()];
232    let mut open = 0u64;
233    for (position, &(_, is_end, index)) in events.iter().enumerate() {
234        if let Some(bound) = bounds.get_mut(index) {
235            if is_end {
236                open -= 1;
237                bound.1 = position;
238            } else {
239                open += 1;
240                bound.0 = position;
241            }
242        }
243        open_after.push(open);
244    }
245    let range_max = RangeMax::new(open_after);
246
247    // `merged` is sorted by file, so one pass folds a file's spans together.
248    // A span's end event sorts after its start, so its open counts are those
249    // from its start up to just before its end.
250    let mut per_file: Vec<(crate::vlog::BlobFileId, u64)> = Vec::new();
251    for ((id, _, _), (start, end)) in merged.iter().zip(bounds) {
252        debug_assert!(end > start, "a span ends after it starts");
253        let depth = range_max.max(start, end - 1);
254        match per_file.last_mut() {
255            Some((last, value)) if last == id => *value = (*value).max(depth),
256            _ => per_file.push((*id, depth)),
257        }
258    }
259    per_file
260}
261
262/// Maximum over any range of a fixed sequence in O(1), after an
263/// O(n log n) build: `levels[k][i]` is the maximum of `2^k` values from `i`.
264struct RangeMax {
265    levels: Vec<Vec<u64>>,
266}
267
268impl RangeMax {
269    fn new(values: Vec<u64>) -> Self {
270        let mut levels = alloc::vec![values];
271        let mut width = 1;
272        // Level `k` holds `n - 2^k + 1` maxima, so level `k + 1` exists while
273        // `n >= 2^(k + 1)`: while the current level is longer than its width.
274        while let Some(previous) = levels.last()
275            && previous.len() > width
276        {
277            let next: Vec<u64> = previous
278                .iter()
279                .zip(previous.iter().skip(width))
280                .map(|(a, b)| (*a).max(*b))
281                .collect();
282            levels.push(next);
283            width *= 2;
284        }
285        Self { levels }
286    }
287
288    /// The maximum of the values at `from..=to`, `from <= to`.
289    fn max(&self, from: usize, to: usize) -> u64 {
290        debug_assert!(from <= to, "an empty range has no maximum");
291        let len = to - from + 1;
292        let level = (usize::BITS - 1 - len.leading_zeros()) as usize;
293        let Some(values) = self.levels.get(level) else {
294            return 0;
295        };
296        // `2^level <= len`, so the second window starts at or after `from`.
297        let right = to + 1 - (1 << level);
298        let left = values.get(from).copied().unwrap_or(0);
299        left.max(values.get(right).copied().unwrap_or(0))
300    }
301}
302
303/// Chooses which of `ranked` to relocate along with `fixed`, best first while
304/// their cost fits `budget`. A file is chosen only when its spans in `tables`
305/// overlap those of another relocated file of its group, so relocating it
306/// merges it with something: either one already relocated, or the cheapest
307/// file it overlaps, chosen with it when both fit.
308///
309/// `fixed` is `(file, group)`, sorted by file id, relocated whatever the
310/// choice. `ranked` is `(file, group, cost)`, best first, disjoint from
311/// `fixed`. Returns whether each of `ranked` is chosen, and the cost spent.
312///
313/// # Errors
314///
315/// When a table's `linked_blob_files` section cannot be read or parsed.
316pub(crate) fn pick_overlapping_blob_files<'a>(
317    tables: impl IntoIterator<Item = &'a crate::table::Table>,
318    fixed: &[(crate::vlog::BlobFileId, usize)],
319    ranked: &[(crate::vlog::BlobFileId, usize, u64)],
320    budget: u64,
321) -> crate::Result<(Vec<bool>, u64)> {
322    let mut comparator = None;
323    let mut spans = Vec::new();
324    collect_spans(tables, &mut comparator, &mut spans)?;
325    Ok(match comparator {
326        Some(cmp) => pick_overlapping(&mut spans, fixed, ranked, budget, cmp.as_ref()),
327        None => (alloc::vec![false; ranked.len()], 0),
328    })
329}
330
331/// How a relocation plan takes a file: relocated anyway, in a group, or a
332/// candidate at a rank.
333#[derive(Clone, Copy)]
334enum Role {
335    Fixed(usize),
336    Ranked(usize),
337}
338
339/// [`pick_overlapping_blob_files`] over `(blob file, first key, last key)`
340/// spans ordered by `cmp`. Drops the spans of other files and sorts the rest.
341fn pick_overlapping(
342    spans: &mut Vec<Span<'_>>,
343    fixed: &[(crate::vlog::BlobFileId, usize)],
344    ranked: &[(crate::vlog::BlobFileId, usize, u64)],
345    budget: u64,
346    cmp: &dyn crate::comparator::UserComparator,
347) -> (Vec<bool>, u64) {
348    let mut roles: Vec<(crate::vlog::BlobFileId, Role)> = fixed
349        .iter()
350        .map(|&(id, group)| (id, Role::Fixed(group)))
351        .chain(
352            ranked
353                .iter()
354                .enumerate()
355                .map(|(rank, &(id, _, _))| (id, Role::Ranked(rank))),
356        )
357        .collect();
358    roles.sort_unstable_by_key(|&(id, _)| id);
359    let role_of = |id: crate::vlog::BlobFileId| {
360        roles
361            .binary_search_by_key(&id, |&(file, _)| file)
362            .ok()
363            .and_then(|at| roles.get(at))
364            .map(|&(_, role)| role)
365    };
366    spans.retain(|&(id, _, _)| role_of(id).is_some());
367    let (merged, _) = merge_spans(spans, cmp);
368    let events = sweep_events(&merged, cmp);
369    let positions = events.len();
370
371    // Where each span starts and ends in the sweep. Where spans meet at one
372    // key, the start sorts first, so two spans overlap exactly when each
373    // starts before the other ends. A file's merged spans never overlap each
374    // other.
375    let mut bounds = alloc::vec![(0usize, 0usize); merged.len()];
376    for (position, &(_, is_end, index)) in events.iter().enumerate() {
377        if let Some(bound) = bounds.get_mut(index) {
378            if is_end {
379                bound.1 = position;
380            } else {
381                bound.0 = position;
382            }
383        }
384    }
385    let group_and_rank = |index: usize| {
386        let role = merged.get(index).and_then(|&(id, _, _)| role_of(id))?;
387        Some(match role {
388            Role::Fixed(group) => (group, None),
389            Role::Ranked(rank) => (ranked.get(rank)?.1, Some(rank)),
390        })
391    };
392    let groups = fixed
393        .iter()
394        .map(|&(_, group)| group)
395        .chain(ranked.iter().map(|&(_, group, _)| group))
396        .max()
397        .map_or(0, |group| group + 1);
398    let mut spans_of: Vec<Vec<(usize, usize)>> = alloc::vec![Vec::new(); ranked.len()];
399    for (index, &bound) in bounds.iter().enumerate() {
400        if let Some((_, Some(rank))) = group_and_rank(index)
401            && let Some(own) = spans_of.get_mut(rank)
402        {
403            own.push(bound);
404        }
405    }
406
407    // The cheapest candidate of another file each candidate overlaps, in one
408    // sweep: when a span ends, the spans of its group that started before
409    // that and end after its start are the ones it overlaps. Each span enters
410    // its group's index as it starts, keyed by its end; positions count down
411    // so the spans ending after a start form a prefix.
412    let mut partner: Vec<Option<(u64, usize)>> = alloc::vec![None; ranked.len()];
413    let mut started: Vec<PrefixCheapest> = (0..groups)
414        .map(|_| PrefixCheapest::new(positions))
415        .collect();
416    for &(_, is_end, index) in &events {
417        let Some((group, Some(rank))) = group_and_rank(index) else {
418            continue;
419        };
420        let (Some(&(start, end)), Some(&(_, _, cost)), Some(index_of_group)) =
421            (bounds.get(index), ranked.get(rank), started.get_mut(group))
422        else {
423            continue;
424        };
425        if is_end {
426            let found = index_of_group
427                .before(positions - 1 - start)
428                .best_except(rank);
429            if let (Some(found), Some(best)) = (found, partner.get_mut(rank))
430                && best.is_none_or(|best| found < best)
431            {
432                *best = Some(found);
433            }
434        } else {
435            index_of_group.add(positions - 1 - end, (cost, rank));
436        }
437    }
438
439    // Best first: a candidate joins when a relocated span of its group
440    // overlaps its own, which the relocated spans' largest end among those
441    // starting before its end tells; otherwise it joins together with its
442    // cheapest partner when both fit.
443    let mut relocated: Vec<PrefixMax> = (0..groups).map(|_| PrefixMax::new(positions)).collect();
444    for (index, &(start, end)) in bounds.iter().enumerate() {
445        if let Some((group, None)) = group_and_rank(index)
446            && let Some(tree) = relocated.get_mut(group)
447        {
448            tree.raise(start, end);
449        }
450    }
451    let mut chosen = alloc::vec![false; ranked.len()];
452    let mut spent = 0u64;
453    for (rank, &(_, group, cost)) in ranked.iter().enumerate() {
454        // Costs are sums of on-disk sizes, bounded by filesystem capacity.
455        if chosen.get(rank) != Some(&false) || spent + cost > budget {
456            continue;
457        }
458        let own = spans_of.get(rank).map_or(&[][..], Vec::as_slice);
459        let joins = relocated
460            .get(group)
461            .is_some_and(|tree| own.iter().any(|&(start, end)| tree.max_before(end) > start));
462        let with = if joins {
463            None
464        } else {
465            match partner.get(rank).copied().flatten() {
466                Some((partner_cost, partner_rank)) if spent + cost + partner_cost <= budget => {
467                    Some(partner_rank)
468                }
469                _ => continue,
470            }
471        };
472        for pick in core::iter::once(rank).chain(with) {
473            let (Some(flag), Some(&(_, _, pick_cost)), Some(tree)) = (
474                chosen.get_mut(pick),
475                ranked.get(pick),
476                relocated.get_mut(group),
477            ) else {
478                continue;
479            };
480            *flag = true;
481            spent += pick_cost;
482            for &(start, end) in spans_of.get(pick).map_or(&[][..], Vec::as_slice) {
483                tree.raise(start, end);
484            }
485        }
486    }
487    (chosen, spent)
488}
489
490/// Largest value over any prefix of positions, values only ever raised.
491struct PrefixMax(Vec<usize>);
492
493impl PrefixMax {
494    fn new(positions: usize) -> Self {
495        Self(alloc::vec![0; positions])
496    }
497
498    /// Raises position `at` to at least `value`.
499    fn raise(&mut self, at: usize, value: usize) {
500        let mut node = at + 1;
501        while let Some(slot) = self.0.get_mut(node - 1) {
502            *slot = (*slot).max(value);
503            node += node.isolate_lowest_one();
504        }
505    }
506
507    /// Largest value at positions `..before`; zero when there is none.
508    fn max_before(&self, before: usize) -> usize {
509        let mut node = before;
510        let mut max = 0;
511        while node > 0 {
512            if let Some(&value) = self.0.get(node - 1) {
513                max = max.max(value);
514            }
515            node &= node - 1;
516        }
517        max
518    }
519}
520
521/// The two cheapest `(cost, rank)` offers of distinct ranks.
522#[derive(Clone, Copy, Default)]
523struct TwoCheapest([Option<(u64, usize)>; 2]);
524
525impl TwoCheapest {
526    fn add(&mut self, offer: (u64, usize)) {
527        let [first, second] = self.0;
528        let mut all = [first, second, Some(offer)];
529        all.sort_unstable_by_key(|offer| offer.unwrap_or((u64::MAX, usize::MAX)));
530        let mut kept = [None; 2];
531        let mut slots = kept.iter_mut();
532        let mut first_rank = None;
533        for offer in all.into_iter().flatten() {
534            if first_rank == Some(offer.1) {
535                continue;
536            }
537            let Some(slot) = slots.next() else {
538                break;
539            };
540            first_rank.get_or_insert(offer.1);
541            *slot = Some(offer);
542        }
543        self.0 = kept;
544    }
545
546    /// The cheapest offer of a rank other than `rank`.
547    fn best_except(&self, rank: usize) -> Option<(u64, usize)> {
548        self.0.into_iter().flatten().find(|offer| offer.1 != rank)
549    }
550}
551
552/// [`TwoCheapest`] over any prefix of positions, offers only ever added.
553struct PrefixCheapest(Vec<TwoCheapest>);
554
555impl PrefixCheapest {
556    fn new(positions: usize) -> Self {
557        Self(alloc::vec![TwoCheapest::default(); positions])
558    }
559
560    fn add(&mut self, at: usize, offer: (u64, usize)) {
561        let mut node = at + 1;
562        while let Some(slot) = self.0.get_mut(node - 1) {
563            slot.add(offer);
564            node += node.isolate_lowest_one();
565        }
566    }
567
568    /// The offers at positions `..before`.
569    fn before(&self, before: usize) -> TwoCheapest {
570        let mut node = before;
571        let mut out = TwoCheapest::default();
572        while node > 0 {
573            if let Some(slot) = self.0.get(node - 1) {
574                for offer in slot.0.into_iter().flatten() {
575                    out.add(offer);
576                }
577            }
578            node &= node - 1;
579        }
580        out
581    }
582}
583
584/// A `(blob file, first key, last key)` span referenced from a table.
585type Span<'k> = (
586    crate::vlog::BlobFileId,
587    &'k crate::UserKey,
588    &'k crate::UserKey,
589);
590
591/// Appends every blob-link span of `tables` and remembers their comparator.
592fn collect_spans<'a>(
593    tables: impl IntoIterator<Item = &'a crate::table::Table>,
594    comparator: &mut Option<crate::comparator::SharedComparator>,
595    spans: &mut Vec<Span<'a>>,
596) -> crate::Result<()> {
597    for table in tables {
598        comparator.get_or_insert_with(|| table.comparator.clone());
599        spans.extend(
600            table
601                .blob_links()?
602                .iter()
603                .map(|link| (link.blob_file_id, &link.first_key, &link.last_key)),
604        );
605    }
606    Ok(())
607}
608
609/// [`BlobReferenceStats`] of `(blob file, first key, last key)` spans ordered
610/// by `cmp`. Spans of one file (from different tables) are merged where they
611/// overlap, so the sweep counts distinct files, not references. Sorts `spans`
612/// in place.
613fn span_stats(
614    spans: &mut [Span<'_>],
615    cmp: &dyn crate::comparator::UserComparator,
616) -> BlobReferenceStats {
617    let (merged, count) = merge_spans(spans, cmp);
618    let (mut open, mut depth) = (0u64, 0u64);
619    for (_, is_end, _) in sweep_events(&merged, cmp) {
620        if is_end {
621            open -= 1;
622        } else {
623            open += 1;
624            depth = depth.max(open);
625        }
626    }
627    BlobReferenceStats { count, depth }
628}
629
630/// Merges the overlapping spans of each file, sorted by file then first key,
631/// and counts the distinct files. Sorts `spans` in place.
632fn merge_spans<'k>(
633    spans: &mut [Span<'k>],
634    cmp: &dyn crate::comparator::UserComparator,
635) -> (Vec<Span<'k>>, u64) {
636    use core::cmp::Ordering;
637
638    // A span is closed at both ends. A recorded span is always ordered; one
639    // that is not is taken by its bounds rather than as an empty span.
640    for span in spans.iter_mut() {
641        if cmp.compare(span.1, span.2) == Ordering::Greater {
642            core::mem::swap(&mut span.1, &mut span.2);
643        }
644    }
645    spans.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp.compare(a.1, b.1)));
646
647    // Sorted by file, so each change of file is one more distinct file.
648    let mut merged: Vec<Span<'k>> = Vec::with_capacity(spans.len());
649    let mut count = 0u64;
650    for &span in spans.iter() {
651        match merged.last_mut() {
652            Some((id, _, last))
653                if *id == span.0 && cmp.compare(span.1, last) != Ordering::Greater =>
654            {
655                if cmp.compare(span.2, last) == Ordering::Greater {
656                    *last = span.2;
657                }
658            }
659            last => {
660                if last.is_none_or(|(id, _, _)| *id != span.0) {
661                    count += 1;
662                }
663                merged.push(span);
664            }
665        }
666    }
667    (merged, count)
668}
669
670/// The start and end events of `merged`, as `(key, is end, span index)` in
671/// key order. Starts come before ends at an equal key: spans that meet at one
672/// key overlap.
673fn sweep_events<'k>(
674    merged: &[Span<'k>],
675    cmp: &dyn crate::comparator::UserComparator,
676) -> Vec<(&'k crate::UserKey, bool, usize)> {
677    let mut events: Vec<(&'k crate::UserKey, bool, usize)> = merged
678        .iter()
679        .enumerate()
680        .flat_map(|(index, &(_, first, last))| [(first, false, index), (last, true, index)])
681        .collect();
682    events.sort_by(|a, b| cmp.compare(a.0, b.0).then(a.1.cmp(&b.1)));
683    events
684}
685
686/// Approximate size of a key range, estimated from SST block-index offsets and
687/// the active memtable WITHOUT reading any data block. Returned by
688/// [`crate::AbstractTree::approximate_range_stats`].
689///
690/// Both figures are estimates from the same in-range fraction per source: each
691/// overlapping SST's data-block offsets are interpolated at the range
692/// boundaries (block granularity) and that fraction is applied to the SST's
693/// byte span and its entry count, while each memtable contributes its in-range
694/// skiplist count and the matching share of its size. Accuracy is typically
695/// within ~10-15% on roughly-uniform data; it is intended for query planning
696/// (split-point selection, cost-based join ordering), not exact accounting.
697#[must_use]
698#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
699pub struct ApproximateRangeStats {
700    /// Estimated on-disk bytes occupied by the range across all overlapping
701    /// SSTs (key + pointer + apportioned blob bytes) plus the active and sealed
702    /// memtables' in-range share. `0` for an empty range.
703    pub bytes: u64,
704
705    /// Estimated number of entry versions in the range: the sum, over each
706    /// overlapping SST, of `item_count × in-range fraction`, plus each
707    /// memtable's in-range skiplist count. `0` for an empty range.
708    pub key_count: u64,
709}
710
711/// Size and entry count of one stored segment (SST), for per-segment tiering and
712/// erasure-coding placement decisions.
713#[must_use]
714#[derive(Copy, Clone, Debug, Eq, PartialEq)]
715pub struct SegmentStats {
716    /// Identifier of the segment's SST within its tree.
717    pub table_id: crate::TableId,
718    /// LSM level the segment lives in (`0` is the newest / smallest level).
719    pub level: usize,
720    /// Physical on-disk bytes of the segment's SST file.
721    pub used_bytes: u64,
722    /// Number of entry versions stored in the segment.
723    pub item_count: u64,
724    /// Cumulative point reads that consulted this segment's data since it was
725    /// created: only reads that pass the segment's seqno-range and bloom gates
726    /// count (a bloom miss is not counted), so this tracks data hotness rather
727    /// than raw probe frequency. A monotonic counter, not a rate: derive a
728    /// read-rate / EMA from the delta between successive polls. `0` when never
729    /// read.
730    pub reads: u64,
731    /// Unix seconds of the segment's most recent data-consulting read, or `0` if
732    /// never read (or on a no-std build, which keeps no clock).
733    pub last_access_secs: u64,
734    /// Blob files the segment references, and how many of them a scan through
735    /// it interleaves where their key spans overlap most.
736    pub blob_references: BlobReferenceStats,
737}
738
739/// Per-LSM-level size + entry aggregates with the contributing segments, for
740/// tiering and erasure-coding placement (which level / segment is large enough
741/// to demote, EC-encode, or migrate).
742///
743/// Cheap to read: derived from version metadata plus one file-size stat per
744/// segment, never a data-block scan (the blob reference figures read each
745/// segment's blob-link section once). The per-level totals reconcile with the
746/// tree-level [`StorageStats`]: summed across levels they equal the SST portion
747/// of [`StorageStats::used_bytes`] and [`StorageStats::item_count`] (blob files
748/// are tracked separately).
749#[must_use]
750#[derive(Clone, Debug, Eq, PartialEq)]
751pub struct LevelStats {
752    /// LSM level index (`0` is the newest / smallest level).
753    pub level: usize,
754    /// Number of segments (SSTs) in the level.
755    pub segment_count: usize,
756    /// Physical on-disk bytes summed across the level's segments.
757    pub used_bytes: u64,
758    /// Entry versions summed across the level's segments.
759    pub item_count: u64,
760    /// Cumulative point-read probes summed across the level's segments.
761    pub reads: u64,
762    /// Most recent point-read probe across the level's segments, in unix
763    /// seconds, or `0` if none was ever read.
764    pub last_access_secs: u64,
765    /// Blob files the level's segments reference, and how many of them a scan
766    /// through the level interleaves where their key spans overlap most.
767    pub blob_references: BlobReferenceStats,
768    /// Per-segment breakdown, in level (run / table) order.
769    pub segments: Vec<SegmentStats>,
770}
771
772/// Approximate cardinality and selectivity of a key range, for cost-based query
773/// planning (join ordering, scan-vs-seek).
774///
775/// Both figures derive from the per-data-block zone map (per-block row counts +
776/// key ranges) when present, falling back to the byte-fraction estimate of
777/// [`ApproximateRangeStats`] otherwise. They are estimates at block granularity,
778/// never exact.
779#[must_use]
780#[derive(Copy, Clone, Debug, Default, PartialEq)]
781pub struct RangeCardinality {
782    /// Estimated number of rows (entry versions) the range covers: the sum of
783    /// the per-block row counts of every data block whose key range overlaps the
784    /// query range, plus each memtable's in-range count. `0` for an empty range.
785    pub rows: u64,
786
787    /// Estimated fraction of the tree's rows the range selects, in `0.0..=1.0`:
788    /// `rows / total_rows`. Monotonic in predicate tightness (a narrower range
789    /// never yields a larger selectivity). `0.0` when the tree is empty.
790    pub selectivity: f64,
791}
792
793/// Grouped, object-safe read-only storage-statistics surface.
794///
795/// A coherent view over a tree's non-query statistics: on-disk footprint
796/// ([`storage_stats`](Self::storage_stats)), per-level / per-segment sizing
797/// ([`level_segment_stats`](Self::level_segment_stats)), compaction debt
798/// ([`compaction_debt`](Self::compaction_debt)), and block-cache health
799/// (`cache_stats`, behind the `metrics` feature). A planner / tiering / capacity consumer
800/// bounds on `T: StorageStatistics` (or `&dyn StorageStatistics`) and a test can
801/// supply a mock. Every [`AbstractTree`](crate::AbstractTree) implements it via a
802/// blanket impl (`impl<T: AbstractTree + ?Sized> StorageStatistics for T`).
803///
804/// The per-query range estimators
805/// ([`approximate_range_stats`](crate::AbstractTree::approximate_range_stats),
806/// [`approximate_range_cardinality`](crate::AbstractTree::approximate_range_cardinality))
807/// are generic over the range type and so not object-safe; they stay on
808/// [`AbstractTree`](crate::AbstractTree) rather than joining this trait.
809pub trait StorageStatistics {
810    /// On-disk footprint and average entry shape: used / capacity / available
811    /// bytes, item & table counts, average entry size, reclaimable-bytes
812    /// estimate, and a coarse [`StorageStatus`]. See
813    /// [`StorageStats::estimated_remaining_entries`] for a budget projection.
814    ///
815    /// # Examples
816    ///
817    /// ```
818    /// # use lsm_tree::Error as TreeError;
819    /// use lsm_tree::{AbstractTree, Config, StorageStatistics};
820    ///
821    /// let folder = tempfile::tempdir()?;
822    /// let tree = Config::new(&folder, Default::default(), Default::default()).open()?;
823    /// for i in 0..100u32 {
824    ///     tree.insert(format!("k{i:04}"), "v", 0);
825    /// }
826    /// tree.flush_active_memtable(0)?;
827    ///
828    /// // Both traits are in scope, so disambiguate the shared method name.
829    /// let stats = StorageStatistics::storage_stats(&tree)?;
830    /// assert_eq!(stats.item_count, 100);
831    /// // Roughly how many more average-shaped entries fit in another 1 MiB.
832    /// let _headroom = stats.estimated_remaining_entries(1024 * 1024);
833    /// #
834    /// # Ok::<(), TreeError>(())
835    /// ```
836    ///
837    /// # Errors
838    ///
839    /// Returns an error if a live file's size cannot be stat-ed, or a table's
840    /// blob-link section cannot be read or parsed.
841    fn storage_stats(&self) -> crate::Result<StorageStats>;
842
843    /// Per-LSM-level and per-segment size + entry-count stats, for tiering and
844    /// erasure-coding placement decisions (which level / segment is large enough
845    /// to demote, EC-encode, or migrate).
846    ///
847    /// Cheap: derived from the live version's metadata plus one file-size stat
848    /// per segment (no data-block scan); in a tree that separates values, each
849    /// segment's blob-link section is also read once, then kept. The per-level
850    /// totals reconcile with
851    /// [`storage_stats`](Self::storage_stats): summed across levels they equal
852    /// the SST portion of [`StorageStats::used_bytes`] and
853    /// [`StorageStats::item_count`].
854    ///
855    /// # Examples
856    ///
857    /// ```
858    /// # use lsm_tree::Error as TreeError;
859    /// use lsm_tree::{AbstractTree, Config, StorageStatistics};
860    ///
861    /// let folder = tempfile::tempdir()?;
862    /// let tree = Config::new(&folder, Default::default(), Default::default()).open()?;
863    /// for i in 0..100u32 {
864    ///     tree.insert(format!("k{i:04}"), "v", 0);
865    /// }
866    /// tree.flush_active_memtable(0)?;
867    ///
868    /// // Both traits are in scope, so disambiguate the shared method names.
869    /// let levels = StorageStatistics::level_segment_stats(&tree)?;
870    /// let total: u64 = levels.iter().map(|l| l.item_count).sum();
871    /// assert_eq!(total, StorageStatistics::storage_stats(&tree)?.item_count);
872    /// #
873    /// # Ok::<(), TreeError>(())
874    /// ```
875    ///
876    /// # Errors
877    ///
878    /// Returns an error if a segment's file size cannot be stat-ed, or its
879    /// blob-link section cannot be read or parsed.
880    fn level_segment_stats(&self) -> crate::Result<Vec<LevelStats>>;
881
882    /// Estimated bytes pending compaction under `strategy`: on-disk data above
883    /// its level's target that must eventually be rewritten downward (a `RocksDB`
884    /// `estimate-pending-compaction-bytes` analog), a compaction-debt signal for a
885    /// scheduler / tiering consumer.
886    ///
887    /// The strategy is a caller argument because the engine does not own a
888    /// configured compaction strategy (it is injected per compaction run); a
889    /// `&dyn` keeps this object-safe. Returns `0` for strategies without a
890    /// size-target notion of debt (FIFO), or when the tree is at or
891    /// below its target shape. See
892    /// [`CompactionStrategy::pending_compaction_bytes`](crate::compaction::CompactionStrategy::pending_compaction_bytes).
893    fn compaction_debt(&self, strategy: &dyn crate::compaction::CompactionStrategy) -> u64;
894
895    /// A point-in-time [`CacheStats`](crate::CacheStats) snapshot of block-cache
896    /// effectiveness (cumulative hit / miss counts and rate) and occupancy
897    /// (current size against capacity).
898    ///
899    /// The stable, owned observability view over the block cache, so a consumer
900    /// reads cache health without holding the mutable
901    /// [`metrics`](crate::AbstractTree::metrics) handle. Counts are cumulative
902    /// since process start; derive a rate over an interval from the delta between
903    /// two polls.
904    #[cfg(feature = "metrics")]
905    fn cache_stats(&self) -> crate::CacheStats;
906}
907
908/// Every [`AbstractTree`](crate::AbstractTree) is a [`StorageStatistics`] by
909/// delegating to its own inherent stats methods, so a `Tree` / `BlobTree` can be
910/// used directly as `&dyn StorageStatistics`. The logic lives once on
911/// `AbstractTree`; this is a thin object-safe re-exposure for the grouped /
912/// mockable surface (a test mock implements `StorageStatistics` directly without
913/// being an `AbstractTree`). When both traits are in scope, disambiguate a bare
914/// `tree.storage_stats()` with `StorageStatistics::storage_stats(&tree)`.
915impl<T: crate::AbstractTree + ?Sized> StorageStatistics for T {
916    fn storage_stats(&self) -> crate::Result<StorageStats> {
917        crate::AbstractTree::storage_stats(self)
918    }
919
920    fn level_segment_stats(&self) -> crate::Result<Vec<LevelStats>> {
921        crate::AbstractTree::level_segment_stats(self)
922    }
923
924    fn compaction_debt(&self, strategy: &dyn crate::compaction::CompactionStrategy) -> u64 {
925        crate::AbstractTree::compaction_debt(self, strategy)
926    }
927
928    #[cfg(feature = "metrics")]
929    fn cache_stats(&self) -> crate::CacheStats {
930        crate::AbstractTree::cache_stats(self)
931    }
932}
933
934impl StorageStats {
935    /// Approximately how many more average-shaped entries fit in `budget_bytes`,
936    /// using [`Self::avg_entry_on_disk_bytes`].
937    ///
938    /// Returns `0` when the average entry size is unknown (an empty tree), since
939    /// there is no basis for the estimate.
940    #[must_use]
941    pub fn estimated_remaining_entries(&self, budget_bytes: u64) -> u64 {
942        budget_bytes
943            .checked_div(self.avg_entry_on_disk_bytes)
944            .unwrap_or(0)
945    }
946}
947
948/// Sums the true physical on-disk size of every live table and blob file in
949/// `version` (one metadata stat per file).
950///
951/// This is the same physical basis [`compute_storage_stats`] reports as
952/// `used_bytes` and that `Tree::create_checkpoint` totals, so the storage
953/// admission gate agrees with both. It deliberately does NOT use
954/// `Metadata::file_size` (undercounts by the meta block / footer) or
955/// `disk_space()` (metadata `Level::size`, which also omits blob files).
956///
957/// # Errors
958///
959/// Returns an error if a live table or blob file's size cannot be stat-ed.
960pub(crate) fn compute_used_bytes(version: &Version) -> crate::Result<u64> {
961    // Sum of on-disk file sizes, bounded by the filesystem capacity → cannot
962    // overflow u64; plain arithmetic.
963    let mut used_bytes = 0u64;
964    for table in version.iter_tables() {
965        used_bytes += table_on_disk_bytes(table)?;
966    }
967    for blob in version.blob_files.iter() {
968        used_bytes += blob_on_disk_bytes(blob)?;
969    }
970    Ok(used_bytes)
971}
972
973/// [`compute_used_bytes`] of every file of `version` but `tables` and
974/// `blobs`: the footprint of what a tight-space pass neither reads nor
975/// writes, whose growth while the pass runs is others' (a flush, another
976/// compaction) and comes out of the quota the pass was cut for.
977///
978/// # Errors
979///
980/// Propagates the stat failures of the tables and blob files.
981#[cfg(feature = "std")]
982pub(crate) fn compute_used_bytes_but(
983    version: &Version,
984    tables: &crate::HashSet<crate::TableId>,
985    blobs: &crate::HashSet<crate::vlog::BlobFileId>,
986) -> crate::Result<u64> {
987    // Bounded by the filesystem capacity, as `compute_used_bytes` is.
988    let mut used_bytes = 0u64;
989    for table in version.iter_tables() {
990        if !tables.contains(&table.id()) {
991            used_bytes += table_on_disk_bytes(table)?;
992        }
993    }
994    for blob in version.blob_files.iter() {
995        if !blobs.contains(&blob.id()) {
996            used_bytes += blob_on_disk_bytes(blob)?;
997        }
998    }
999    Ok(used_bytes)
1000}
1001
1002/// The physical bytes a live blob file occupies.
1003///
1004/// Blob files are punched in place by the same tight-space reclaim as SSTs, so
1005/// they are charged what they still occupy, not their logical length.
1006///
1007/// This is deliberately a DIFFERENT measure from `CheckpointInfo::total_bytes`,
1008/// which counts logical lengths because that is what restoring a snapshot
1009/// costs. The two agree for intact files and differ by exactly the punched
1010/// holes once a reclaim has run.
1011///
1012/// # Errors
1013///
1014/// Propagates the stat failures of the blob file.
1015pub(crate) fn blob_on_disk_bytes(blob: &crate::vlog::BlobFile) -> crate::Result<u64> {
1016    Ok(crate::file::on_disk_bytes(&*blob.0.fs, &blob.0.path)?)
1017}
1018
1019/// The physical bytes a live table occupies: the SST file plus, for a
1020/// tight-space-RESTRICTED table, its `.restrict-bound` sidecar — a live
1021/// companion file a checkpoint links and totals too, so both surfaces cover the
1022/// same SET of files. A restricted view whose sidecar is missing on disk (a
1023/// geometry-derived restriction after a repair) counts the SST alone.
1024///
1025/// The two surfaces measure that set differently on purpose: this one is
1026/// physical (a punched prefix must leave the quota), while
1027/// `CheckpointInfo::total_bytes` is logical (that is what a restore costs).
1028pub(crate) fn table_on_disk_bytes(table: &crate::table::Table) -> crate::Result<u64> {
1029    // Physical bytes: charging the logical length would keep a tight-space
1030    // compaction's freed prefix on the quota forever, so under
1031    // `storage_limit_bytes` the headroom would never recover and the tree would
1032    // stay read-only despite the compaction having succeeded.
1033    #[cfg_attr(not(feature = "std"), expect(unused_mut, reason = "no sidecar arm"))]
1034    let mut bytes = crate::file::on_disk_bytes(&*table.fs, &table.path)?;
1035    // Restrictions are created only by the std-only tight-space / repair
1036    // paths, so the sidecar probe is std-gated with them.
1037    #[cfg(feature = "std")]
1038    if table.restrict_lower_bound().is_some() {
1039        match table
1040            .fs
1041            .metadata(&crate::restrict_bound::sidecar_path(&table.path))
1042        {
1043            Ok(m) => bytes += m.len,
1044            Err(e) if e.kind() == crate::io::ErrorKind::NotFound => {}
1045            Err(e) => return Err(e.into()),
1046        }
1047    }
1048    Ok(bytes)
1049}
1050
1051/// The transient-output bound a full compaction's space check uses: the largest
1052/// level's live size (the `full_compaction_bytes` gauge figure), an upper bound
1053/// on a single merge's input set. `0` for an empty tree.
1054///
1055/// Live, not `Level::size`: a tight-space-RESTRICTED table's `file_size` still
1056/// describes the punched original, and charging that superseded prefix to the
1057/// output would report the tree as tight — and the gate would stall an ordinary
1058/// merge — while the real output fits.
1059///
1060/// This is the DEMAND. The destination VOLUME is a separate concern: a full
1061/// compaction writes its output to the last configured level
1062/// (`level_count - 1`), not to whichever level is currently largest, so callers
1063/// pass the last level as the destination to the per-volume space check (the two
1064/// differ only under tiered routing, where they can be different filesystems).
1065///
1066/// # Errors
1067///
1068/// Propagates a restricted table's punch-offset lookup.
1069pub(crate) fn full_compaction_demand_bytes(version: &Version) -> crate::Result<u64> {
1070    let mut largest = 0u64;
1071    for level in version.iter_levels() {
1072        // A level's live size: a sum of on-disk byte counts, bounded by the
1073        // filesystem capacity, so it cannot overflow u64.
1074        let mut size = 0u64;
1075        for run in level.iter() {
1076            for table in run.iter() {
1077                size += table.live_file_size()?;
1078            }
1079        }
1080        largest = largest.max(size);
1081    }
1082    Ok(largest)
1083}
1084
1085/// Computes [`StorageStats`] from a live version's table + blob-file metadata.
1086///
1087/// `is_compacting` selects [`StorageStatus::CompactionInProgress`] vs
1088/// [`StorageStatus::Healthy`]; the caller supplies it because compaction state
1089/// is engine-internal.
1090///
1091/// `value_bytes_are_user_values` must be `false` for a KV-separated
1092/// (`BlobTree`) tree: there the SST records a small indirection pointer per
1093/// large value, not the user value, so the per-table value-byte sum measures
1094/// pointers and the value average would misreport. When `false`,
1095/// [`StorageStats::avg_value_bytes`] is forced to `None`. Key bytes are never
1096/// separated, so [`StorageStats::avg_key_bytes`] stays exact either way.
1097///
1098/// `used_bytes` is the true on-disk footprint of every live table and blob file
1099/// (one stat per file), not the writer's `Metadata::file_size` or
1100/// `crate::version::Version::blob_files`' compressed-payload sum: those
1101/// undercount the physical file by the meta block / footer / blob trailer. It
1102/// covers the same files `Tree::create_checkpoint` totals, but measures them
1103/// physically rather than logically, so the two differ by the holes a
1104/// tight-space reclaim punched and agree everywhere else.
1105///
1106/// # Errors
1107///
1108/// Returns an error if a live table or blob file's size cannot be stat-ed, or a
1109/// table's blob-link section cannot be read or parsed.
1110pub(crate) fn compute_storage_stats(
1111    version: &Version,
1112    is_compacting: bool,
1113    value_bytes_are_user_values: bool,
1114) -> crate::Result<StorageStats> {
1115    let mut used_bytes = 0u64;
1116    let mut item_count = 0u64;
1117    let mut table_count = 0u64;
1118    let mut reclaimable_entries = 0u64;
1119    let mut sum_key = 0u64;
1120    let mut sum_value = 0u64;
1121    // The key/value split is only exact when EVERY live table records the byte
1122    // sums; a single legacy table without them makes the average unrepresentable.
1123    let mut all_have_shape = true;
1124
1125    // Every running total below is a sum of on-disk byte sizes or live item
1126    // counts; both are bounded by the filesystem capacity / the live entry count
1127    // and cannot overflow u64, so plain arithmetic is correct (a debug-overflow
1128    // would itself signal a corrupt metadata read).
1129    for table in version.iter_tables() {
1130        let m = &table.metadata;
1131        // Physical file size, NOT m.file_size (which undercounts — see above);
1132        // a restricted table's live sidecar counts too (same basis as the
1133        // checkpoint total, see `table_on_disk_bytes`).
1134        let on_disk = table_on_disk_bytes(table)?;
1135        used_bytes += on_disk;
1136        // A restricted view's metadata still describes the whole original SST;
1137        // its consumed prefix belongs to the output that superseded it, and
1138        // both live in this version while a slice is in flight. Count what this
1139        // view serves, and scale the per-entry aggregates by the same share so
1140        // the averages stay consistent with the count. A report, not a read:
1141        // polling it must not move the read counters or the block cache.
1142        let live_items = table.live_item_count(crate::table::util::ReadCharge::Untraced)?;
1143        let share = |total: u64| -> u64 {
1144            if live_items == m.item_count || m.item_count == 0 {
1145                return total;
1146            }
1147            u64::try_from(u128::from(total) * u128::from(live_items) / u128::from(m.item_count))
1148                .unwrap_or(total)
1149        };
1150        item_count += live_items;
1151        table_count += 1;
1152        reclaimable_entries += share(m.weak_tombstone_reclaimable);
1153        match (
1154            m.sum_user_key_bytes.map(share),
1155            m.sum_value_bytes.map(share),
1156        ) {
1157            (Some(k), Some(v)) => {
1158                sum_key += k;
1159                sum_value += v;
1160            }
1161            _ => all_have_shape = false,
1162        }
1163    }
1164
1165    // Physical blob-file size (metadata + trailer included), NOT
1166    // BlobFileList::on_disk_size() which sums only the compressed payload.
1167    for blob in version.blob_files.iter() {
1168        used_bytes += blob_on_disk_bytes(blob)?;
1169    }
1170
1171    // An empty tree has no average entry.
1172    let avg_entry_on_disk_bytes = used_bytes.checked_div(item_count).unwrap_or(0);
1173
1174    let have_shape = all_have_shape && item_count > 0;
1175    let avg_key_bytes = have_shape.then(|| sum_key / item_count);
1176    // Value bytes are only meaningful when not KV-separated (see param doc).
1177    let avg_value_bytes =
1178        (have_shape && value_bytes_are_user_values).then(|| sum_value / item_count);
1179
1180    // reclaimable_entries ≤ item_count and avg_entry_on_disk_bytes = used / item_count,
1181    // so the product is ≤ used_bytes (bounded by disk capacity): plain multiply.
1182    let reclaimable_bytes_estimate = reclaimable_entries * avg_entry_on_disk_bytes;
1183
1184    // A full compaction's transient output is bounded by its input set; the
1185    // largest single merge is bounded by the largest level's on-disk size, so
1186    // that is the free space a full compaction needs.
1187    let full_compaction_bytes = full_compaction_demand_bytes(version)?;
1188    // A minimal (tight) space-reclaiming merge needs only the reserved working
1189    // floor to make forward progress.
1190    let tight_compaction_bytes = crate::tree::MIN_RESERVED_HEADROOM;
1191
1192    let status = if is_compacting {
1193        StorageStatus::CompactionInProgress
1194    } else {
1195        StorageStatus::Healthy
1196    };
1197
1198    Ok(StorageStats {
1199        used_bytes,
1200        // Capacity is disk-aware (quota + free-space probe) and lives at the
1201        // tree layer; this version-only computation leaves it unbounded. The
1202        // caller (`Tree::storage_stats`) fills the real figures.
1203        capacity_bytes: None,
1204        available_bytes: None,
1205        compaction_possible: true,
1206        full_compaction_bytes,
1207        tight_compaction_bytes,
1208        item_count,
1209        table_count,
1210        avg_entry_on_disk_bytes,
1211        avg_key_bytes,
1212        avg_value_bytes,
1213        reclaimable_bytes_estimate,
1214        status,
1215        blob_references: blob_reference_stats(version.iter_tables())?,
1216    })
1217}
1218
1219/// Computes per-LSM-level and per-segment size + entry stats from a version.
1220///
1221/// Cost is O(levels x segments) plus one file-size stat per segment (the same
1222/// stat [`compute_storage_stats`] already performs), and the first time per
1223/// segment a read of its blob-link section; it never reads a data block.
1224///
1225/// # Errors
1226///
1227/// Returns an error if a segment's file size cannot be stat-ed or its blob-link
1228/// section cannot be read.
1229pub(crate) fn compute_level_segment_stats(version: &Version) -> crate::Result<Vec<LevelStats>> {
1230    use core::sync::atomic::Ordering::Relaxed;
1231    let mut levels = Vec::with_capacity(version.level_count());
1232    for (level, run_group) in version.iter_levels().enumerate() {
1233        let mut segments = Vec::new();
1234        let mut used_bytes = 0u64;
1235        let mut item_count = 0u64;
1236        let mut reads = 0u64;
1237        let mut last_access_secs = 0u64;
1238        // Each table's spans are collected once: they give its own figures,
1239        // then join the level's, which the level sweep needs all together.
1240        let mut comparator = None;
1241        let mut level_spans = Vec::new();
1242        let mut table_spans = Vec::new();
1243        for run in run_group.iter() {
1244            for table in run.iter() {
1245                collect_spans([table], &mut comparator, &mut table_spans)?;
1246                let blob_references = comparator
1247                    .as_ref()
1248                    .map_or_else(BlobReferenceStats::default, |cmp| {
1249                        span_stats(&mut table_spans, cmp.as_ref())
1250                    });
1251                level_spans.append(&mut table_spans);
1252                // Physical file size, NOT m.file_size (which undercounts), to
1253                // reconcile with the tree-level `used_bytes` — including a
1254                // restricted table's live restriction sidecar (the same basis
1255                // as `table_on_disk_bytes`), so summing the levels matches
1256                // the documented SST portion of the tree total.
1257                let on_disk = table_on_disk_bytes(table)?;
1258                // What this VIEW serves, on the same basis as the tree total: a
1259                // restricted table's metadata still counts the prefix the
1260                // superseding output owns.
1261                let items = table.live_item_count(crate::table::util::ReadCharge::Untraced)?;
1262                let seg_reads = table.read_count.load(Relaxed);
1263                let seg_access = table.last_access_secs.load(Relaxed);
1264                used_bytes += on_disk;
1265                item_count += items;
1266                reads = reads.saturating_add(seg_reads);
1267                last_access_secs = last_access_secs.max(seg_access);
1268                segments.push(SegmentStats {
1269                    table_id: table.metadata.id,
1270                    level,
1271                    used_bytes: on_disk,
1272                    item_count: items,
1273                    reads: seg_reads,
1274                    last_access_secs: seg_access,
1275                    blob_references,
1276                });
1277            }
1278        }
1279        levels.push(LevelStats {
1280            level,
1281            segment_count: segments.len(),
1282            used_bytes,
1283            item_count,
1284            reads,
1285            last_access_secs,
1286            blob_references: comparator.map_or_else(BlobReferenceStats::default, |cmp| {
1287                span_stats(&mut level_spans, cmp.as_ref())
1288            }),
1289            segments,
1290        });
1291    }
1292    Ok(levels)
1293}
1294
1295#[cfg(test)]
1296mod tests;