Skip to main content

lsm_tree/
metrics.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
4
5use core::sync::atomic::AtomicUsize;
6use core::sync::atomic::Ordering::Relaxed;
7// `core` has no 64-bit atomics on targets without them (thumbv7em); the
8// portable one falls back to a lock there and is the native one elsewhere.
9use portable_atomic::AtomicU64;
10
11/// Runtime metrics
12///
13/// Are not stored durably, so metrics will reset after a restart/crash.
14#[derive(Debug, Default)]
15pub struct Metrics {
16    /// Number of times a table file was opened using `fopen()`
17    pub(crate) table_file_opened_uncached: AtomicUsize,
18
19    /// Number of times a table file was retrieved from descriptor cache
20    pub(crate) table_file_opened_cached: AtomicUsize,
21
22    /// Number of index blocks that were actually read from disk
23    pub(crate) index_block_load_io: AtomicUsize,
24
25    /// Number of filter blocks that were actually read from disk
26    pub(crate) filter_block_load_io: AtomicUsize,
27
28    /// Number of blocks that were actually read from disk
29    pub(crate) data_block_load_io: AtomicUsize,
30
31    /// Number of blocks that were read from block cache
32    pub(crate) index_block_load_cached: AtomicUsize,
33
34    /// Number of blocks that were read from block cache
35    pub(crate) filter_block_load_cached: AtomicUsize,
36
37    /// Number of blocks that were read from block cache
38    pub(crate) data_block_load_cached: AtomicUsize,
39
40    /// Block misses served by another read's concurrent load of the same
41    /// block, waited for instead of read again: neither a cache hit nor a
42    /// read of the file.
43    pub(crate) block_load_shared: AtomicUsize,
44
45    /// Separated-value misses served by another read's concurrent load of
46    /// the same record, on the same terms as `block_load_shared`.
47    pub(crate) blob_load_shared: AtomicUsize,
48
49    /// Number of range tombstone blocks that were actually read from disk
50    pub(crate) range_tombstone_block_load_io: AtomicUsize,
51
52    /// Number of range tombstone blocks that were read from block cache
53    pub(crate) range_tombstone_block_load_cached: AtomicUsize,
54
55    /// Number of filter queries that were performed
56    pub(crate) filter_queries: AtomicUsize,
57
58    /// Number of IOs that were skipped due to filter
59    pub(crate) io_skipped_by_filter: AtomicUsize,
60
61    /// Number of segments skipped during prefix scans via
62    /// [`Tree::create_prefix`](crate::Tree::create_prefix) where the per-table prefix bloom filter
63    /// returned `Ok(false)`. Counted in both single-table and
64    /// multi-table run paths of `TreeIter::create_range`.
65    ///
66    /// Note: `BlobTree` prefix scans do not currently record this metric.
67    pub(crate) prefix_bloom_skips: AtomicUsize,
68
69    /// Payload bytes produced by the block transform — what decompression,
70    /// decryption and Page-ECC verification turned the bytes read into.
71    ///
72    /// Counted at the same site as `*_io_requested` and only on the same
73    /// uncached path, because a block served from the cache is already
74    /// decoded and no transform runs for it.
75    ///
76    /// This is the counter that tells a PHYSICAL projection from a cosmetic
77    /// one. A projection that returns two columns of a wide record but still
78    /// loads and decompresses the whole block leaves this figure unchanged
79    /// while the returned batch shrinks; one that reads only the pages it
80    /// needs moves it. Read alone cannot show that — a 4 KiB compressed block
81    /// is 4 KiB read however much it expands to.
82    pub(crate) block_bytes_decoded: AtomicU64,
83
84    /// Bytes moved by a gather: an operation that builds a new buffer whose
85    /// contents already existed in another one.
86    ///
87    /// The named set, so a new path cannot win by not being instrumented:
88    /// column-batch accumulation, batch filtering, row gathering by index,
89    /// row-value reconstruction from sub-columns, the prefix copies and the
90    /// synthesized block of a large zstd block's partial decode, what
91    /// decoding a columnar block copies out of it (validity bitmaps, and
92    /// columns a narrow projection detaches), the effective seqnos written
93    /// over a bulk-ingested segment's local ones, the key and value a point
94    /// read detaches into the row cache, and the key a resolved blob is
95    /// cached under, wherever a read performs them
96    /// (single-segment and merged columnar scans, row iteration and point
97    /// reads, range reads of a partially decoded block). It does NOT count a
98    /// block transform's output (that is `block_bytes_decoded`), a write
99    /// path's serialisation, the input decoding of compaction, repair and
100    /// salvage (maintenance, not reads), or a move that transfers ownership without
101    /// duplicating bytes.
102    ///
103    /// A view into a decoded buffer is a view whatever its representation: a
104    /// short slice stored inline in the handle is not charged, because building
105    /// that inline copy costs no more than building the handle itself.
106    ///
107    /// The quantity this exists to expose is quadratic accumulation and
108    /// repeated re-gather: bytes copied per input byte should be a small
109    /// constant, and a path that re-materialises its working set several
110    /// times shows up here and nowhere else.
111    pub(crate) bytes_copied: AtomicU64,
112
113    /// Number of data block bytes that were requested from OS or disk.
114    ///
115    /// Definition: bytes REQUESTED FROM THE `Fs` TRAIT, which is the block's
116    /// on-disk size (`handle.size()`), not device I/O — the OS page cache,
117    /// readahead and request coalescing all sit below this line and are not
118    /// visible to it. Counted only when the block was not served from the
119    /// block cache, so a fully cached read reports zero bytes read, which is
120    /// the honest answer to "how much did this read ask the filesystem for".
121    pub(crate) data_block_io_requested: AtomicU64,
122
123    /// Blob record bytes requested from the `Fs` trait: the on-disk span of
124    /// the records a read or a prefetch asked for, gaps merged into a
125    /// coalesced read included.
126    ///
127    /// Separate from the block counters because a blob is not a block, and
128    /// summed into [`Metrics::bytes_read`] because it IS a read of the
129    /// filesystem. A key-value-separated tree keeps most of its bytes here, so
130    /// leaving them out would let a change that moves work into the blob path
131    /// report an improvement by moving it out of sight.
132    ///
133    /// Counted on the uncached path only, like every other read counter: a
134    /// value served from the blob cache asks the filesystem for nothing.
135    pub(crate) blob_bytes_io_requested: AtomicU64,
136
137    /// Read requests issued to the `Fs` trait for blob records: one per
138    /// single-value read and one per coalesced read-ahead span. Counted on the
139    /// same terms as `blob_bytes_io_requested`.
140    pub(crate) blob_read_io: AtomicUsize,
141
142    /// The share of `blob_bytes_io_requested` read ahead of being asked for:
143    /// the coalesced spans a scan or a projected scan reads at once, gaps
144    /// between their records included.
145    pub(crate) blob_bytes_prefetched: AtomicU64,
146
147    /// Bytes of the batches a projected columnar scan handed to its caller:
148    /// the values it materialised, per row it returned.
149    pub(crate) bytes_materialized: AtomicU64,
150
151    /// Of the payload pages a projected scan read for its chosen rows only,
152    /// the bytes of those rows' cells, as decoded.
153    pub(crate) payload_bytes_useful: AtomicU64,
154
155    /// Of the same pages, the bytes of the other rows' cells: what a page
156    /// holding a chosen row brings along.
157    pub(crate) payload_bytes_incidental: AtomicU64,
158
159    /// Blob value bytes produced after decompression, decryption and
160    /// validation — the blob-side twin of `block_bytes_decoded`, summed into
161    /// [`Metrics::bytes_decoded`] for the same reason.
162    pub(crate) blob_bytes_decoded: AtomicU64,
163
164    /// Blob record bytes a relocating compaction copied into a new blob file,
165    /// on disk: the payload garbage collection moves to reclaim the space of
166    /// the files it rewrites. Charged when the rewritten files are installed,
167    /// so a relocation that fails before install moves nothing.
168    pub(crate) blob_bytes_relocated: AtomicU64,
169
170    /// Columnar row groups compactions copied into their output whole, as
171    /// they lay on disk, instead of encoding their rows again. Charged when
172    /// the outputs are installed.
173    pub(crate) compaction_groups_carried: AtomicU64,
174
175    /// Columnar row groups compactions rebuilt around pages they copied from
176    /// an input's group, encoding only the row pages the merge changed.
177    /// Charged when the outputs are installed.
178    pub(crate) compaction_groups_partly_carried: AtomicU64,
179
180    /// Row-major data blocks compactions copied into their output whole, as
181    /// they lay on disk, instead of encoding their rows again. Charged when
182    /// the outputs are installed.
183    pub(crate) compaction_blocks_carried: AtomicU64,
184
185    /// The on-disk bytes copied instead of encoded: whole groups, pages and
186    /// data blocks.
187    pub(crate) compaction_bytes_carried: AtomicU64,
188
189    /// What the tree took in from its caller.
190    accepted: AcceptedCounters,
191
192    /// The work compactions were estimated to cost before they ran.
193    compaction_work_estimated: WorkCounters,
194
195    /// The work compactions did: what they read, and what they installed.
196    compaction_work_measured: WorkCounters,
197
198    /// Number of index block bytes that were requested from OS or disk
199    pub(crate) index_block_io_requested: AtomicU64,
200
201    /// Number of filter block bytes that were requested from OS or disk
202    pub(crate) filter_block_io_requested: AtomicU64,
203
204    /// Number of range tombstone block bytes that were requested from OS or disk
205    pub(crate) range_tombstone_block_io_requested: AtomicU64,
206
207    /// Number of SSTs flagged for a healing recompaction after a read recovered
208    /// a block from Page-ECC parity and confirmed the fault persistent (counted
209    /// only when `auto_heal` is enabled). Each SST is counted once per pending
210    /// schedule.
211    pub(crate) ecc_auto_heal_scheduled: AtomicUsize,
212
213    /// On-read blocks healed by the SEC-DED single-bit fast path (one corrected
214    /// bit flip). Counted on every primary read that observes the recovery
215    /// (point/range loads, partial-decode, patrol scrub); the persistence
216    /// confirming re-read does NOT re-count. A non-zero, growing value is a
217    /// scrapeable latent-bit-rot signal.
218    pub(crate) ecc_secded_corrected: AtomicUsize,
219
220    /// On-read blocks recovered from Reed-Solomon shard parity (the general
221    /// multi-byte path). Same counting discipline as
222    /// [`Self::ecc_secded_corrected`]; the two are disjoint by recovery
223    /// mechanism and sum to the total on-read ECC recoveries.
224    pub(crate) ecc_shard_recovered: AtomicUsize,
225}
226
227/// What a tree took in from its caller, before any encoding: the payload the
228/// bytes it writes are measured against.
229///
230/// Every value, deletion and record is counted as it arrives, so an overwrite
231/// or a retried write is counted each time. That is why the bytes a flush
232/// writes are no substitute for it.
233#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
234#[non_exhaustive]
235pub struct AcceptedWrites {
236    /// Values written, merge operands included.
237    pub values: u64,
238    /// Point deletions.
239    pub deletions: u64,
240    /// Range deletions.
241    pub range_deletions: u64,
242    /// Records an ingestion took in.
243    pub ingested: u64,
244    /// The bytes of their keys, values, operands and range bounds.
245    pub payload_bytes: u64,
246}
247
248#[derive(Debug, Default)]
249struct AcceptedCounters {
250    values: AtomicU64,
251    deletions: AtomicU64,
252    range_deletions: AtomicU64,
253    ingested: AtomicU64,
254    payload_bytes: AtomicU64,
255}
256
257/// One cumulative counter per kind of compaction work.
258#[derive(Debug, Default)]
259struct WorkCounters {
260    moved: AtomicU64,
261    carried: AtomicU64,
262    reprotected: AtomicU64,
263    reencoded: AtomicU64,
264    read: AtomicU64,
265    written: AtomicU64,
266}
267
268impl WorkCounters {
269    fn add(&self, work: &crate::compaction::Work<u64>) {
270        self.moved.fetch_add(work.moved, Relaxed);
271        self.carried.fetch_add(work.carried, Relaxed);
272        self.reprotected.fetch_add(work.reprotected, Relaxed);
273        self.reencoded.fetch_add(work.reencoded, Relaxed);
274        self.read.fetch_add(work.read, Relaxed);
275        self.written.fetch_add(work.written, Relaxed);
276    }
277
278    fn get(&self) -> crate::compaction::Work<u64> {
279        crate::compaction::Work {
280            moved: self.moved.load(Relaxed),
281            carried: self.carried.load(Relaxed),
282            reprotected: self.reprotected.load(Relaxed),
283            reencoded: self.reencoded.load(Relaxed),
284            read: self.read.load(Relaxed),
285            written: self.written.load(Relaxed),
286        }
287    }
288}
289
290/// A point-in-time snapshot of block-cache effectiveness and occupancy.
291///
292/// Derived from [`Metrics`] (the cumulative hit / miss counters) plus the live
293/// block cache's current size and capacity, so an observability consumer gets a
294/// stable owned value instead of reaching into the mutable `&Arc<Metrics>`.
295/// Counts are cumulative since process start (they reset on restart, like all of
296/// [`Metrics`]); derive a rate over an interval from the delta between two polls.
297// No `PartialEq`: `hit_rate` is an `f64`, so equality would inherit float
298// comparison semantics. Compare the integer fields explicitly instead.
299#[must_use]
300#[derive(Copy, Clone, Debug)]
301pub struct CacheStats {
302    /// Cumulative block reads served from the block cache (all block types).
303    pub hits: u64,
304    /// Cumulative block reads that missed the cache and hit disk (all block types).
305    pub misses: u64,
306    /// Hit rate in `0.0..=1.0` (`hits / (hits + misses)`); `1.0` when no block
307    /// has been loaded yet (nothing has missed).
308    pub hit_rate: f64,
309    /// Current weighted bytes resident in the block cache.
310    pub size_bytes: u64,
311    /// Configured maximum bytes the block cache may hold.
312    pub capacity_bytes: u64,
313}
314
315#[expect(
316    clippy::cast_precision_loss,
317    reason = "metrics can accept precision loss"
318)]
319impl Metrics {
320    /// Builds a [`CacheStats`] snapshot from the cumulative cache counters and
321    /// the caller-supplied live cache `size_bytes` / `capacity_bytes` (the block
322    /// cache owns its occupancy, [`Metrics`] owns the hit / miss tallies).
323    pub fn cache_stats(&self, size_bytes: u64, capacity_bytes: u64) -> CacheStats {
324        // Read the counters once so hits / misses / hit_rate are a single
325        // consistent snapshot (block_cache_hit_rate would re-read the atomics).
326        let hits = self.block_load_cached_count() as u64;
327        let misses = self.block_load_io_count() as u64;
328        let total = hits + misses;
329        let hit_rate = if total == 0 {
330            1.0
331        } else {
332            hits as f64 / total as f64
333        };
334        CacheStats {
335            hits,
336            misses,
337            hit_rate,
338            size_bytes,
339            capacity_bytes,
340        }
341    }
342
343    /// Returns the cache hit rate for file descriptors in percent (0.0 - 1.0).
344    pub fn table_file_cache_hit_rate(&self) -> f64 {
345        let uncached = self.table_file_opened_uncached.load(Relaxed) as f64;
346        let cached = self.table_file_opened_cached.load(Relaxed) as f64;
347
348        if cached + uncached == 0.0 {
349            1.0
350        } else {
351            cached / (cached + uncached)
352        }
353    }
354
355    /// Times a read took a table file from the descriptor cache.
356    pub fn table_file_cached_count(&self) -> usize {
357        self.table_file_opened_cached.load(Relaxed)
358    }
359
360    /// Times a read had to open a table file, missing the descriptor cache.
361    pub fn table_file_opened_count(&self) -> usize {
362        self.table_file_opened_uncached.load(Relaxed)
363    }
364
365    /// Data block bytes requested from the `Fs` trait by reads callers made.
366    ///
367    /// Charged when each read is issued, so a read that then fails its
368    /// checksum, decryption or decompression is counted; maintenance and
369    /// monitoring reads are not. See [`Self::bytes_read`].
370    pub fn data_block_io(&self) -> u64 {
371        self.data_block_io_requested.load(Relaxed)
372    }
373
374    /// Index block bytes requested from the `Fs` trait, on the same terms as
375    /// [`Self::data_block_io`].
376    pub fn index_block_io(&self) -> u64 {
377        self.index_block_io_requested.load(Relaxed)
378    }
379
380    /// Filter block bytes requested from the `Fs` trait, on the same terms as
381    /// [`Self::data_block_io`].
382    pub fn filter_block_io(&self) -> u64 {
383        self.filter_block_io_requested.load(Relaxed)
384    }
385
386    /// Range tombstone block bytes requested from the `Fs` trait, on the same
387    /// terms as [`Self::data_block_io`].
388    pub fn range_tombstone_block_io(&self) -> u64 {
389        self.range_tombstone_block_io_requested.load(Relaxed)
390    }
391
392    /// Block bytes requested from the `Fs` trait over every block role, on the
393    /// same terms as [`Self::data_block_io`].
394    pub fn block_io(&self) -> u64 {
395        self.data_block_io_requested.load(Relaxed)
396            + self.index_block_io_requested.load(Relaxed)
397            + self.filter_block_io_requested.load(Relaxed)
398            + self.range_tombstone_block_io_requested.load(Relaxed)
399    }
400
401    /// Payload bytes the block transform produced — see
402    /// [`Self::bytes_read`] for the figure this is paired with.
403    ///
404    /// Read and decoded are reported together or not at all: each alone is
405    /// misleading. Read without decoded hides a projection that loads and
406    /// decompresses everything it then discards; decoded without read hides a
407    /// change that decodes the same amount from far more I/O.
408    pub fn bytes_decoded(&self) -> u64 {
409        self.block_bytes_decoded.load(Relaxed) + self.blob_bytes_decoded.load(Relaxed)
410    }
411
412    /// Bytes requested from the `Fs` trait: every block role, plus the blob
413    /// records a key-value-separated tree resolves.
414    ///
415    /// Wider than [`Self::block_io`] on purpose. A separated value's bytes
416    /// leave the filesystem through the blob path rather than through a block,
417    /// and a figure that omitted them would report a tree that reads gigabytes
418    /// as reading only its indirections.
419    ///
420    /// Reported with [`Self::bytes_decoded`] and [`Self::bytes_copied`]; the
421    /// three are one family.
422    pub fn bytes_read(&self) -> u64 {
423        self.block_io() + self.blob_bytes_io_requested.load(Relaxed)
424    }
425
426    /// Bytes requested from the `Fs` trait for separated values alone.
427    ///
428    /// The blob-only share of [`Self::bytes_read`], so a scan can be asked
429    /// whether it paid for the blobs of rows it then discarded.
430    pub fn blob_bytes_read(&self) -> u64 {
431        self.blob_bytes_io_requested.load(Relaxed)
432    }
433
434    /// Read requests issued for separated values: one per value read on its
435    /// own, one per coalesced read-ahead span.
436    ///
437    /// The count that blob locality moves. Values a scan finds in one file
438    /// next to each other coalesce into few requests; the same values spread
439    /// across interleaved files need a request per file per stretch.
440    pub fn blob_read_count(&self) -> usize {
441        self.blob_read_io.load(Relaxed)
442    }
443
444    /// Block misses that waited for another read's load of the same block
445    /// and took its result, so the block was read and decoded once for both.
446    /// Counted apart from cache hits and from file reads.
447    pub fn block_load_shared_count(&self) -> usize {
448        self.block_load_shared.load(Relaxed)
449    }
450
451    /// Separated-value misses served by another read's concurrent load of the
452    /// same record, on the same terms as [`Self::block_load_shared_count`].
453    pub fn blob_load_shared_count(&self) -> usize {
454        self.blob_load_shared.load(Relaxed)
455    }
456
457    /// The share of [`Self::blob_bytes_read`] read ahead: the coalesced spans
458    /// a scan reads before it asks for each value in them, the gaps it reads
459    /// to merge two records into one request included.
460    pub fn blob_bytes_prefetched(&self) -> u64 {
461        self.blob_bytes_prefetched.load(Relaxed)
462    }
463
464    /// Bytes of the batches projected columnar scans handed out: what they
465    /// materialised for the rows they returned, and nothing for a row they
466    /// dropped.
467    pub fn bytes_materialized(&self) -> u64 {
468        self.bytes_materialized.load(Relaxed)
469    }
470
471    /// Of the payload pages projected scans read for their chosen rows only,
472    /// the decoded bytes of those rows' cells: the useful part of what was
473    /// read, beside [`Self::payload_bytes_incidental`].
474    pub fn payload_bytes_useful(&self) -> u64 {
475        self.payload_bytes_useful.load(Relaxed)
476    }
477
478    /// Of the same pages, the decoded bytes of the rows not chosen: a page
479    /// holding one chosen row is read whole, and this is what came with it.
480    /// It reports where the chosen rows sit, one per page or together, apart
481    /// from how many there are.
482    pub fn payload_bytes_incidental(&self) -> u64 {
483        self.payload_bytes_incidental.load(Relaxed)
484    }
485
486    /// Records what a projected scan handed out.
487    #[cfg(feature = "columnar")]
488    #[inline]
489    pub(crate) fn record_materialized(&self, bytes: usize) {
490        self.bytes_materialized.fetch_add(bytes as u64, Relaxed);
491    }
492
493    /// Records the chosen rows' cells taken from payload pages read for them.
494    #[cfg(feature = "columnar")]
495    #[inline]
496    pub(crate) fn record_payload_useful(&self, bytes: u64) {
497        self.payload_bytes_useful.fetch_add(bytes, Relaxed);
498    }
499
500    /// Records what payload pages read for chosen rows held besides their
501    /// cells, once the pages are done with.
502    #[cfg(feature = "columnar")]
503    #[inline]
504    pub(crate) fn record_payload_incidental(&self, bytes: u64) {
505        self.payload_bytes_incidental.fetch_add(bytes, Relaxed);
506    }
507
508    /// On-disk blob bytes relocating compactions copied into new blob files.
509    ///
510    /// The work garbage collection does to reclaim the space of the files it
511    /// rewrites: divided by the bytes it reclaimed, the cost of each byte
512    /// freed, which placement moves by deciding which values share a file.
513    pub fn blob_bytes_relocated(&self) -> u64 {
514        self.blob_bytes_relocated.load(Relaxed)
515    }
516
517    /// Columnar row groups compactions copied into their output as they lay
518    /// on disk, because the merge left every row of them as it was.
519    ///
520    /// Each one is a group whose pages were neither decoded for writing nor
521    /// encoded and compressed again.
522    pub fn compaction_groups_carried(&self) -> u64 {
523        self.compaction_groups_carried.load(Relaxed)
524    }
525
526    /// Columnar row groups compactions rebuilt around the pages of an
527    /// input's group that the merge left as they were: only the row pages it
528    /// changed were encoded again.
529    pub fn compaction_groups_partly_carried(&self) -> u64 {
530        self.compaction_groups_partly_carried.load(Relaxed)
531    }
532
533    /// Row-major data blocks compactions copied into their output as they lay
534    /// on disk, because the merge left every row of them as it was.
535    ///
536    /// Each one is a block neither encoded nor compressed again.
537    pub fn compaction_blocks_carried(&self) -> u64 {
538        self.compaction_blocks_carried.load(Relaxed)
539    }
540
541    /// On-disk bytes compactions copied instead of encoding them again: the
542    /// row groups of [`Self::compaction_groups_carried`], the pages of
543    /// [`Self::compaction_groups_partly_carried`] and the data blocks of
544    /// [`Self::compaction_blocks_carried`].
545    pub fn compaction_bytes_carried(&self) -> u64 {
546        self.compaction_bytes_carried.load(Relaxed)
547    }
548
549    /// What the tree took in from its caller since it was opened.
550    ///
551    /// The denominator of host write amplification: divide the bytes its
552    /// backend counted (see [`crate::fs::CountingFs`]) by
553    /// [`AcceptedWrites::payload_bytes`] over the same interval, when the
554    /// tree is the only writer on that backend.
555    ///
556    /// # Examples
557    ///
558    /// ```
559    /// use lsm_tree::{AbstractTree, Config, SequenceNumberCounter};
560    /// use lsm_tree::fs::MemFs;
561    ///
562    /// let tree = Config::new(
563    ///     "/db",
564    ///     SequenceNumberCounter::default(),
565    ///     SequenceNumberCounter::default(),
566    /// )
567    /// .with_fs(MemFs::new())
568    /// .open()?;
569    /// tree.insert("key", "value", 0);
570    /// tree.remove("key", 1);
571    ///
572    /// let accepted = tree.metrics().accepted_writes();
573    /// assert_eq!((accepted.values, accepted.deletions), (1, 1));
574    /// assert_eq!(accepted.payload_bytes, 8 + 3);
575    /// # Ok::<(), lsm_tree::Error>(())
576    /// ```
577    pub fn accepted_writes(&self) -> AcceptedWrites {
578        let accepted = &self.accepted;
579        AcceptedWrites {
580            values: accepted.values.load(Relaxed),
581            deletions: accepted.deletions.load(Relaxed),
582            range_deletions: accepted.range_deletions.load(Relaxed),
583            ingested: accepted.ingested.load(Relaxed),
584            payload_bytes: accepted.payload_bytes.load(Relaxed),
585        }
586    }
587
588    /// Records a value of `payload` bytes, key included, taken in.
589    pub(crate) fn accepted_value(&self, payload: usize) {
590        self.accepted.values.fetch_add(1, Relaxed);
591        self.accepted
592            .payload_bytes
593            .fetch_add(payload as u64, Relaxed);
594    }
595
596    /// Records a point deletion of a `key` bytes long key taken in.
597    pub(crate) fn accepted_deletion(&self, key: usize) {
598        self.accepted.deletions.fetch_add(1, Relaxed);
599        self.accepted.payload_bytes.fetch_add(key as u64, Relaxed);
600    }
601
602    /// Records a range deletion of `bounds` bytes of bounds taken in.
603    pub(crate) fn accepted_range_deletion(&self, bounds: usize) {
604        self.accepted.range_deletions.fetch_add(1, Relaxed);
605        self.accepted
606            .payload_bytes
607            .fetch_add(bounds as u64, Relaxed);
608    }
609
610    /// Records `records` ingested records of `payload` bytes taken in.
611    pub(crate) fn accepted_ingestion(&self, records: u64, payload: u64) {
612        self.accepted.ingested.fetch_add(records, Relaxed);
613        self.accepted.payload_bytes.fetch_add(payload, Relaxed);
614    }
615
616    /// The work compactions were estimated to cost before they ran, summed
617    /// over every compaction, by kind of work.
618    ///
619    /// Read beside [`Self::compaction_work_measured`]: the difference of each
620    /// figure is the estimator's error over the same compactions.
621    pub fn compaction_work_estimated(&self) -> crate::compaction::Work<u64> {
622        self.compaction_work_estimated.get()
623    }
624
625    /// The work compactions did, summed over every compaction, by kind of
626    /// work: the bytes they read, and the bytes of the outputs they
627    /// installed. Its `written` is the compaction bytes written, the on-disk
628    /// bytes of the tables they wrote and installed, carried groups included,
629    /// and of the blob files they wrote: divided by the bytes the tree took
630    /// in, the write amplification compaction adds.
631    pub fn compaction_work_measured(&self) -> crate::compaction::Work<u64> {
632        self.compaction_work_measured.get()
633    }
634
635    /// Charges one compaction's estimate and measured work.
636    pub(crate) fn charge_compaction_work(&self, work: &crate::compaction::WorkReport) {
637        self.compaction_work_estimated.add(&work.estimate.expected);
638        self.compaction_work_measured.add(&work.measured);
639    }
640
641    /// Bytes moved by a gather — accumulation, filtering, row gathering and
642    /// row-value reconstruction. The exact set is on the field.
643    ///
644    /// Interpreted per input byte: a path that materialises its working set
645    /// once sits near a small constant, and one that re-gathers repeatedly
646    /// grows with the number of passes rather than with the data.
647    pub fn bytes_copied(&self) -> u64 {
648        self.bytes_copied.load(Relaxed)
649    }
650
651    /// Charges one gather: `bytes` is the size of the buffer it built. Every
652    /// site in the named set calls this, so the set and its instrumentation
653    /// cannot drift apart one path at a time.
654    #[inline]
655    pub(crate) fn record_gather(&self, bytes: usize) {
656        self.bytes_copied.fetch_add(bytes as u64, Relaxed);
657    }
658
659    /// Number of data blocks that were accessed.
660    pub fn data_block_load_count(&self) -> usize {
661        self.data_block_load_cached.load(Relaxed) + self.data_block_load_io.load(Relaxed)
662    }
663
664    /// Number of index blocks that were accessed.
665    pub fn index_block_load_count(&self) -> usize {
666        self.index_block_load_cached.load(Relaxed) + self.index_block_load_io.load(Relaxed)
667    }
668
669    /// Number of filter blocks that were accessed.
670    pub fn filter_block_load_count(&self) -> usize {
671        self.filter_block_load_cached.load(Relaxed) + self.filter_block_load_io.load(Relaxed)
672    }
673
674    /// Number of range tombstone blocks that were accessed.
675    pub fn range_tombstone_block_load_count(&self) -> usize {
676        self.range_tombstone_block_load_cached.load(Relaxed)
677            + self.range_tombstone_block_load_io.load(Relaxed)
678    }
679
680    /// Number of SSTs scheduled for a healing recompaction after a persistent
681    /// ECC correction on read (`auto_heal` enabled).
682    pub fn ecc_auto_heal_scheduled_count(&self) -> usize {
683        self.ecc_auto_heal_scheduled.load(Relaxed)
684    }
685
686    /// On-read blocks healed by the SEC-DED single-bit fast path.
687    pub fn ecc_secded_corrected_count(&self) -> usize {
688        self.ecc_secded_corrected.load(Relaxed)
689    }
690
691    /// On-read blocks recovered from Reed-Solomon shard parity.
692    pub fn ecc_shard_recovered_count(&self) -> usize {
693        self.ecc_shard_recovered.load(Relaxed)
694    }
695
696    /// Total on-read ECC recoveries across both mechanisms (SEC-DED + RS shard).
697    /// A scrapeable latent-bit-rot signal: growth here means the medium is
698    /// returning faulty bytes that parity is silently repairing.
699    pub fn ecc_recovered_count(&self) -> usize {
700        self.ecc_secded_corrected_count() + self.ecc_shard_recovered_count()
701    }
702
703    /// Records one on-read ECC recovery, attributing it to the mechanism that
704    /// did the repair. Called from the primary read paths (`load_block`, the
705    /// partial-decode path, patrol scrub); the persistence-confirming re-read
706    /// must NOT call this, to avoid double-counting a single fault.
707    pub(crate) fn record_ecc_recovery(&self, kind: crate::table::block::EccRecoveryKind) {
708        use crate::table::block::EccRecoveryKind;
709        match kind {
710            EccRecoveryKind::Secded => &self.ecc_secded_corrected,
711            EccRecoveryKind::Shard => &self.ecc_shard_recovered,
712        }
713        .fetch_add(1, Relaxed);
714    }
715
716    /// Number of blocks that were loaded from disk or OS page cache.
717    pub fn block_load_io_count(&self) -> usize {
718        self.data_block_load_io.load(Relaxed)
719            + self.index_block_load_io.load(Relaxed)
720            + self.filter_block_load_io.load(Relaxed)
721            + self.range_tombstone_block_load_io.load(Relaxed)
722    }
723
724    /// Number of data blocks that were served from block cache.
725    pub fn data_block_load_cached_count(&self) -> usize {
726        self.data_block_load_cached.load(Relaxed)
727    }
728
729    /// Number of index blocks that were served from block cache.
730    pub fn index_block_load_cached_count(&self) -> usize {
731        self.index_block_load_cached.load(Relaxed)
732    }
733
734    /// Number of filter blocks that were served from block cache.
735    pub fn filter_block_load_cached_count(&self) -> usize {
736        self.filter_block_load_cached.load(Relaxed)
737    }
738
739    /// Number of range tombstone blocks that were served from block cache.
740    pub fn range_tombstone_block_load_cached_count(&self) -> usize {
741        self.range_tombstone_block_load_cached.load(Relaxed)
742    }
743
744    /// Number of blocks that were served from block cache.
745    pub fn block_load_cached_count(&self) -> usize {
746        self.data_block_load_cached.load(Relaxed)
747            + self.index_block_load_cached.load(Relaxed)
748            + self.filter_block_load_cached.load(Relaxed)
749            + self.range_tombstone_block_load_cached.load(Relaxed)
750    }
751
752    /// Number of blocks that were accessed.
753    pub fn block_loads(&self) -> usize {
754        self.block_load_io_count() + self.block_load_cached_count()
755    }
756
757    /// Data block cache efficiency in percent (0.0 - 1.0).
758    pub fn data_block_cache_hit_rate(&self) -> f64 {
759        let queries = self.data_block_load_count() as f64;
760        let hits = self.data_block_load_cached_count() as f64;
761
762        if queries == 0.0 { 1.0 } else { hits / queries }
763    }
764
765    /// Filter block cache efficiency in percent (0.0 - 1.0).
766    pub fn filter_block_cache_hit_rate(&self) -> f64 {
767        let queries = self.filter_block_load_count() as f64;
768        let hits = self.filter_block_load_cached_count() as f64;
769
770        if queries == 0.0 { 1.0 } else { hits / queries }
771    }
772
773    /// Index block cache efficiency in percent (0.0 - 1.0).
774    pub fn index_block_cache_hit_rate(&self) -> f64 {
775        let queries = self.index_block_load_count() as f64;
776        let hits = self.index_block_load_cached_count() as f64;
777
778        if queries == 0.0 { 1.0 } else { hits / queries }
779    }
780
781    /// Range tombstone block cache efficiency in percent (0.0 - 1.0).
782    pub fn range_tombstone_block_cache_hit_rate(&self) -> f64 {
783        let queries = self.range_tombstone_block_load_count() as f64;
784        let hits = self.range_tombstone_block_load_cached_count() as f64;
785
786        if queries == 0.0 { 1.0 } else { hits / queries }
787    }
788
789    /// Block cache efficiency in percent (0.0 - 1.0).
790    pub fn block_cache_hit_rate(&self) -> f64 {
791        let queries = self.block_loads() as f64;
792        let hits = self.block_load_cached_count() as f64;
793
794        if queries == 0.0 { 1.0 } else { hits / queries }
795    }
796
797    /// Filter efficiency in percent (0.0 - 1.0).
798    ///
799    /// Represents the ratio of I/O operations avoided due to filter.
800    pub fn filter_efficiency(&self) -> f64 {
801        let queries = self.filter_queries.load(Relaxed) as f64;
802        let io_skipped = self.io_skipped_by_filter.load(Relaxed) as f64;
803
804        if queries == 0.0 {
805            1.0
806        } else {
807            io_skipped / queries
808        }
809    }
810
811    /// Number of filter queries performed.
812    pub fn filter_queries(&self) -> usize {
813        self.filter_queries.load(Relaxed)
814    }
815
816    /// Number of I/O operations skipped by filter.
817    pub fn io_skipped_by_filter(&self) -> usize {
818        self.io_skipped_by_filter.load(Relaxed)
819    }
820
821    /// Number of segments skipped during [`Tree::create_prefix`](crate::Tree::create_prefix) scans
822    /// by prefix bloom filters (single-table and multi-table run paths).
823    ///
824    /// Note: `BlobTree` prefix scans do not currently record this metric.
825    pub fn prefix_bloom_skips(&self) -> usize {
826        self.prefix_bloom_skips.load(Relaxed)
827    }
828}
829
830#[cfg(test)]
831mod tests;