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;