Skip to main content

micromegas_object_cache/
foyer_backend.rs

1use std::collections::HashMap;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::{Arc, Mutex};
4use std::time::Instant;
5
6use anyhow::{Context, Result, ensure};
7use async_trait::async_trait;
8use bytes::Bytes;
9use foyer::{
10    Age, BlockEngineConfig, Code, DeviceBuilder, Event, EventListener, FsDeviceBuilder,
11    HybridCache, HybridCacheBuilder, HybridCacheProperties, Load, LruConfig,
12};
13use foyer_common::properties::Properties;
14use futures::future::{BoxFuture, FutureExt, Shared};
15use micromegas_tracing::prelude::*;
16
17use super::backend::{BackendDiskStats, FillHint, RangeCacheBackend};
18use super::metric_tags::{
19    EvictionTagTable, REASON_CLEAR, REASON_EVICT, REASON_REMOVE, REASON_REPLACE,
20};
21
22/// `blk:`-prefixed keys are the block-cache entries `probe_blocks` passes to
23/// `RangeCacheBackend::get`; `meta:`-prefixed 8-byte size lookups
24/// (`range_cache/mod.rs::size`) also flow through here but must be excluded
25/// from the tiered hit counters below, or they would pollute the block-only
26/// miss-rate derivation (`range_cache_block_request − (ram_hit + disk_hit)`).
27fn is_block_key(key: &str) -> bool {
28    key.starts_with("blk:")
29}
30
31/// RAM-tier cache value carrying the timing needed for eviction/age
32/// telemetry.
33/// - `ram_inserted_at`: when the entry (re-)entered the RAM tier. Set on
34///   `new()` and refreshed on `Code::decode` (a disk->RAM promotion is a
35///   *new* RAM residency), so RAM age always measures time resident in RAM.
36///   Not serialized.
37/// - `disk_write_ms`: wall-clock ms (epoch) when the entry was written to
38///   disk. Stamped by `Code::encode` (which records "now" -- under the
39///   default `WriteOnEviction` policy, encode runs at disk-write time) and
40///   preserved verbatim through disk reclaim (raw-byte reinsertion, no
41///   re-encode). `DISK_WRITE_NONE` for a RAM-only entry that has never been
42///   persisted.
43/// - `is_prefetch`: true only for the ephemeral phantom record created by the
44///   prefetch `put` arm. Not serialized (always `false` on decode). foyer
45///   0.22.3 fires `on_leave` *twice* for that phantom record -- `Event::Remove`
46///   synchronously during `insert`, then `Event::Evict` when the ephemeral
47///   handle is dropped (the disk-write dispatch) -- both at age ~= 0 ms. The
48///   listener uses this marker to exclude that noise from both signals.
49#[derive(Clone)]
50struct CachedBlock {
51    bytes: Bytes,
52    ram_inserted_at: Instant,
53    disk_write_ms: i64,
54    is_prefetch: bool,
55}
56
57const DISK_WRITE_NONE: i64 = i64::MIN;
58
59impl CachedBlock {
60    fn new(bytes: Bytes) -> Self {
61        Self {
62            bytes,
63            ram_inserted_at: Instant::now(),
64            disk_write_ms: DISK_WRITE_NONE,
65            is_prefetch: false,
66        }
67    }
68
69    /// Ephemeral disk-only phantom record for the prefetch path (see field
70    /// doc on `is_prefetch`).
71    fn new_prefetch(bytes: Bytes) -> Self {
72        Self {
73            is_prefetch: true,
74            ..Self::new(bytes)
75        }
76    }
77
78    /// A fresh-normalized disk->RAM promotion record: always a real (never
79    /// phantom) RAM residency starting now, carrying over only the disk
80    /// entry's `disk_write_ms` stamp. Used for both `Load::Entry` and
81    /// `Load::Piece` promotions -- see the two-step `get`'s deliberate
82    /// divergence from hybrid's own Piece-arm handling (field doc above).
83    fn new_promoted(bytes: Bytes, disk_write_ms: i64) -> Self {
84        Self {
85            bytes,
86            ram_inserted_at: Instant::now(),
87            disk_write_ms,
88            is_prefetch: false,
89        }
90    }
91}
92
93impl Code for CachedBlock {
94    fn encode(&self, writer: &mut impl std::io::Write) -> foyer::Result<()> {
95        // Stamp the disk-write instant here: encode == disk write under the
96        // default WriteOnEviction hybrid policy. Leading i64 LE, then the
97        // payload.
98        let now_ms = chrono::Utc::now().timestamp_millis();
99        now_ms.encode(writer)?;
100        self.bytes.encode(writer)
101    }
102
103    fn decode(reader: &mut impl std::io::Read) -> foyer::Result<Self> {
104        let disk_write_ms = i64::decode(reader)?;
105        let bytes = Bytes::decode(reader)?;
106        Ok(Self {
107            bytes,
108            ram_inserted_at: Instant::now(),
109            disk_write_ms,
110            is_prefetch: false,
111        })
112    }
113
114    fn estimated_size(&self) -> usize {
115        std::mem::size_of::<i64>() + self.bytes.estimated_size()
116    }
117}
118
119/// On-disk format version for the foyer disk tier. The serialized value layout
120/// (`CachedBlock`'s `Code` impl) carries no self-describing version, so a layout
121/// change would otherwise misdecode entries recovered from a persisted store on
122/// restart (see #1287, #1283). On startup the store directory is wiped iff the
123/// persisted marker does not match this constant.
124///
125/// BUMP THIS whenever `CachedBlock`'s `Code` encode/decode (or any on-disk
126/// layout foyer persists for us) changes.
127///
128/// History:
129/// - v1: `CachedBlock` = `[i64 LE disk_write_ms][length-prefixed Bytes]` (#1283).
130///   (The pre-#1283 `Bytes`-only layout was unversioned; upgrading onto a store
131///   it wrote is the crash this guard prevents.)
132pub const DISK_FORMAT_VERSION: u32 = 1;
133
134/// Marker filename holding the decimal `DISK_FORMAT_VERSION`, stored alongside
135/// foyer's own `foyer-storage-direct-fs-*` region files inside `--disk-path`.
136/// The name does not collide with foyer's prefix, so foyer's recovery ignores it.
137pub const DISK_FORMAT_MARKER: &str = "micromegas-object-cache-format-version";
138
139/// Reuses a single fixed directory across restarts, wiping its contents in
140/// place only when the persisted format marker does not match `version`. See
141/// `DISK_FORMAT_VERSION` for why this exists.
142fn prepare_disk_dir(dir: &str, version: u32) -> Result<()> {
143    let dir_path = std::path::Path::new(dir);
144    let marker = dir_path.join(DISK_FORMAT_MARKER);
145    let current = std::fs::read_to_string(&marker)
146        .ok()
147        .and_then(|s| s.trim().parse::<u32>().ok());
148    if current == Some(version) {
149        return Ok(()); // match: let foyer recover the store untouched (warm reuse)
150    }
151    // Missing marker (first boot, or a pre-versioning old-format store) or a
152    // mismatch: reclaim the space and start clean on the SAME directory.
153    if dir_path.exists() {
154        warn!(
155            "object-cache disk format {current:?} != {version}; wiping {dir} to avoid \
156             misdecoding old-format entries (#1287)"
157        );
158        imetric!("object_cache_disk_format_wiped", "count", 1_u64);
159        // Remove directory CONTENTS, not the directory itself, so a mounted
160        // volume root is preserved.
161        for entry in
162            std::fs::read_dir(dir_path).with_context(|| format!("reading disk dir {dir}"))?
163        {
164            let path = entry?.path();
165            if path.is_dir() {
166                std::fs::remove_dir_all(&path)
167            } else {
168                std::fs::remove_file(&path)
169            }
170            .with_context(|| format!("removing {}", path.display()))?;
171        }
172    } else {
173        std::fs::create_dir_all(dir_path).with_context(|| format!("creating disk dir {dir}"))?;
174    }
175    std::fs::write(&marker, version.to_string())
176        .with_context(|| format!("writing disk format marker {}", marker.display()))?;
177    Ok(())
178}
179
180/// `reason` label for a RAM-tier `on_leave` event.
181fn reason_str(reason: Event) -> &'static str {
182    match reason {
183        Event::Evict => REASON_EVICT,
184        Event::Replace => REASON_REPLACE,
185        Event::Remove => REASON_REMOVE,
186        Event::Clear => REASON_CLEAR,
187    }
188}
189
190/// RAM-tier eviction listener: emits `object_cache_ram_tier_eviction_count`
191/// (all reasons) and `object_cache_ram_tier_eviction_age_ms`
192/// (capacity-driven `Event::Evict` only -- the thrashing signal). Runs
193/// synchronously inside foyer's insert path, possibly on a foyer-internal
194/// thread; see the hot-path note on `dispatch`'s global metrics mutex in the
195/// design doc for why this is safe from any thread.
196struct RamEvictionListener {
197    tags: Arc<EvictionTagTable>,
198    /// Set immediately before `FoyerBackend::close()`'s full-tier flush, so
199    /// that flush's eviction-to-zero sweep doesn't poison these gauges with a
200    /// burst indistinguishable from real capacity thrashing. Shared with
201    /// `FoyerBackend` via `FoyerBackend::mark_shutting_down()` -- see that
202    /// method's doc.
203    shutting_down: Arc<AtomicBool>,
204}
205
206impl EventListener for RamEvictionListener {
207    type Key = String;
208    type Value = CachedBlock;
209
210    fn on_leave(&self, reason: Event, key: &String, value: &CachedBlock) {
211        if value.is_prefetch {
212            // Phantom prefetch record: foyer fires Remove (synchronously
213            // during insert) then Evict (when the ephemeral handle is
214            // dropped, i.e. the disk-write dispatch) for the *same*
215            // disk-only write, both at age ~= 0 ms -- indistinguishable from
216            // real thrashing if counted. Exclude from both signals.
217            return;
218        }
219        if self.shutting_down.load(Ordering::Relaxed) {
220            // The close-time full-tier flush evicts every entry to zero,
221            // which would otherwise emit both gauges for the *entire* RAM
222            // tier on every clean shutdown -- indistinguishable from real
223            // capacity thrashing (#1281). See `mark_shutting_down`.
224            return;
225        }
226        let t = self.tags.classify(key);
227        imetric!(
228            "object_cache_ram_tier_eviction_count",
229            "count",
230            t.count_for(reason_str(reason)),
231            1_u64
232        );
233        if reason == Event::Evict {
234            // Capacity-driven -- the thrashing signal. Replace/Remove/Clear
235            // don't speak to capacity pressure.
236            let age_ms = value.ram_inserted_at.elapsed().as_secs_f64() * 1000.0;
237            fmetric!(
238                "object_cache_ram_tier_eviction_age_ms",
239                "ms",
240                t.prefix,
241                age_ms
242            );
243        }
244    }
245}
246
247/// foyer disk-engine write-path tuning. Defaults reproduce foyer's own
248/// `BlockEngineConfig` defaults (`flushers=1`, `buffer_pool_size=16 MiB`),
249/// with the submit-queue threshold pinned to 2x the buffer pool -- which
250/// foyer's doc comment describes as its intended default but which 0.22 no
251/// longer applies automatically (its actual default is 1x, see
252/// `BlockEngineConfig::new`) -- so existing callers/tests are unaffected
253/// unless they opt into a different tuning.
254#[derive(Clone, Copy, Debug)]
255pub struct WriteTuning {
256    /// `BlockEngineConfig::with_flushers`.
257    pub flushers: usize,
258    /// `BlockEngineConfig::with_buffer_pool_size`, in bytes.
259    pub buffer_pool_bytes: usize,
260    /// `BlockEngineConfig::with_submit_queue_size_threshold`, in bytes.
261    pub submit_queue_threshold_bytes: usize,
262}
263
264impl Default for WriteTuning {
265    fn default() -> Self {
266        let buffer = 16 * 1024 * 1024;
267        Self {
268            flushers: 1,
269            buffer_pool_bytes: buffer,
270            submit_queue_threshold_bytes: buffer * 2,
271        }
272    }
273}
274
275/// One key's disk-load-and-promote task, shared between the caller that
276/// spawned it and every concurrent follower that joins it instead of
277/// spawning its own -- see `FoyerBackend::load_from_disk`.
278type SharedLoad = Shared<BoxFuture<'static, Option<Bytes>>>;
279
280pub struct FoyerBackend {
281    cache: HybridCache<String, CachedBlock>,
282    tags: Arc<EvictionTagTable>,
283    /// Per-key single-flight for step-2 disk loads, replacing foyer's own
284    /// (buggy, see #1318) inflight coalescing now that `get` no longer
285    /// routes through `HybridCache::get`/`get_or_fetch`.
286    loads: Arc<Mutex<HashMap<String, SharedLoad>>>,
287    /// Shared with `RamEvictionListener`; see `mark_shutting_down`.
288    shutting_down: Arc<AtomicBool>,
289}
290
291impl FoyerBackend {
292    pub async fn new(dir: &str, ram_bytes: usize, disk_bytes: usize) -> Result<Self> {
293        Self::new_with_shards(
294            dir,
295            ram_bytes,
296            disk_bytes,
297            8,
298            WriteTuning::default(),
299            Arc::from(Vec::new()),
300        )
301        .await
302    }
303
304    #[allow(clippy::too_many_arguments)]
305    pub async fn new_with_shards(
306        dir: &str,
307        ram_bytes: usize,
308        disk_bytes: usize,
309        shards: usize,
310        tuning: WriteTuning,
311        prefix_labels: Arc<[&'static str]>,
312    ) -> Result<Self> {
313        ensure!(shards > 0, "shards must be > 0");
314
315        prepare_disk_dir(dir, DISK_FORMAT_VERSION)?;
316
317        // Direct I/O (bypassing the page cache) matches the old `DirectFs`
318        // engine's behavior; the flag only exists on Linux.
319        #[cfg(target_os = "linux")]
320        let device = FsDeviceBuilder::new(dir)
321            .with_capacity(disk_bytes)
322            .with_direct(true)
323            .build()?;
324        #[cfg(not(target_os = "linux"))]
325        let device = FsDeviceBuilder::new(dir)
326            .with_capacity(disk_bytes)
327            .build()?;
328
329        let tags = Arc::new(EvictionTagTable::new(prefix_labels));
330        let shutting_down = Arc::new(AtomicBool::new(false));
331        let listener = Arc::new(RamEvictionListener {
332            tags: tags.clone(),
333            shutting_down: shutting_down.clone(),
334        });
335
336        let cache = HybridCacheBuilder::new()
337            .with_event_listener(listener)
338            .memory(ram_bytes)
339            .with_weighter(|_key: &String, value: &CachedBlock| value.bytes.len())
340            .with_shards(shards)
341            // Pin the RAM tier to LRU explicitly: LRU is the crate's current
342            // default eviction policy; pinning it here guards against a
343            // future foyer default change silently altering RAM-tier
344            // eviction behavior for demand fills.
345            .with_eviction_config(LruConfig::default())
346            .storage()
347            .with_engine_config(
348                BlockEngineConfig::new(device)
349                    .with_flushers(tuning.flushers)
350                    .with_buffer_pool_size(tuning.buffer_pool_bytes)
351                    .with_submit_queue_size_threshold(tuning.submit_queue_threshold_bytes),
352            )
353            .build()
354            .await?;
355        Ok(Self {
356            cache,
357            tags,
358            loads: Arc::new(Mutex::new(HashMap::new())),
359            shutting_down,
360        })
361    }
362
363    /// Marks this backend as shutting down, so `RamEvictionListener::on_leave`
364    /// skips emitting `object_cache_ram_tier_eviction_count`/`_age_ms` for the
365    /// entries `close()`'s full-tier flush is about to evict. Call
366    /// immediately before `close()` -- the only place this is needed, since a
367    /// flush that never runs (the overall shutdown deadline elapses first)
368    /// emits nothing to suppress.
369    pub fn mark_shutting_down(&self) {
370        self.shutting_down.store(true, Ordering::Relaxed);
371    }
372
373    pub async fn close(&self) -> Result<()> {
374        self.cache.close().await?;
375        Ok(())
376    }
377
378    /// Current RAM-tier byte usage. Exposed so integration tests (which
379    /// compile as a separate crate and cannot reach the private `cache`
380    /// field) can assert prefetch fills do not grow RAM-tier residency.
381    pub fn ram_usage(&self) -> usize {
382        self.cache.memory().usage()
383    }
384
385    /// Current RAM-tier entry count. Backs the trait's `ram_entries()`
386    /// method, which feeds the saturation gauge.
387    pub fn ram_entry_count(&self) -> usize {
388        self.cache.memory().entries()
389    }
390}
391
392/// Validate `value` against `expected_len` and, if it matches, promote it
393/// into the RAM tier with a fresh-normalized `CachedBlock` (see
394/// `CachedBlock::new_promoted`) carrying `age`'s hybrid promotion semantics
395/// forward -- shared by both the `Load::Entry` and `Load::Piece` arms of
396/// `FoyerBackend::load_and_promote`. Emits the disk-tier hit/age telemetry
397/// and the length-mismatch miss metric; returns the validated bytes on
398/// success.
399fn promote_if_valid(
400    cache: &HybridCache<String, CachedBlock>,
401    tags: &EvictionTagTable,
402    key: &str,
403    value: CachedBlock,
404    age: Age,
405    expected_len: u64,
406) -> Option<Bytes> {
407    if value.bytes.len() as u64 != expected_len {
408        // The short entry is left in place: the heal's put(Demand) will
409        // supersede it, and until then other probes coalesce on the origin
410        // single-flight (see the design doc's poisoned-short-prefetch note).
411        imetric!("range_cache_promotion_len_mismatch", "count", 1_u64);
412        return None;
413    }
414    if is_block_key(key) {
415        let t = tags.classify(key);
416        imetric!("object_cache_disk_tier_hit", "count", t.prefix, 1_u64);
417        // Promotion volume (#1321). This length-validated insert below is the
418        // single disk->RAM crossing, so promotion_count == disk_tier_hit by
419        // construction; it exists as the named companion and denominator to
420        // promotion_bytes (mean promoted block size = bytes / count) -- the
421        // churn signal weighed against object_cache_ram_tier_eviction_*.
422        imetric!("object_cache_promotion_count", "count", t.prefix, 1_u64);
423        imetric!(
424            "object_cache_promotion_bytes",
425            "bytes",
426            t.prefix,
427            value.bytes.len() as u64
428        );
429    }
430    if value.disk_write_ms != DISK_WRITE_NONE {
431        // See the disk-tier limitation note in the design doc: this per-read
432        // age is the observable disk-exit signal foyer 0.22 exposes, and its
433        // max/high-quantiles estimate the (unobservable) reclaim age.
434        let age_ms = (chrono::Utc::now().timestamp_millis() - value.disk_write_ms) as f64;
435        let t = tags.classify(key);
436        fmetric!(
437            "object_cache_disk_tier_read_age_ms",
438            "ms",
439            t.prefix,
440            age_ms.max(0.0)
441        );
442    }
443    let bytes = value.bytes.clone();
444    cache.memory().insert_with_properties(
445        key.to_string(),
446        CachedBlock::new_promoted(bytes.clone(), value.disk_write_ms),
447        HybridCacheProperties::default().with_age(age),
448    );
449    Some(bytes)
450}
451
452/// Step 2 of the two-step read: a direct (non-inflight) disk load, validated
453/// and promoted on a length match. Replicates `HybridCache::get`'s own
454/// `Entry`/`Piece` handling (`foyer/src/hybrid/cache.rs:660-718`) minus the
455/// broken inflight layer, with the Piece arm's deliberate divergence
456/// documented on `promote_if_valid`/`CachedBlock::new_promoted`.
457async fn load_and_promote(
458    cache: &HybridCache<String, CachedBlock>,
459    tags: &EvictionTagTable,
460    key: &str,
461    expected_len: u64,
462) -> Option<Bytes> {
463    match cache.storage().load(key).await {
464        Ok(Load::Entry {
465            value, populated, ..
466        }) => promote_if_valid(cache, tags, key, value, populated.age, expected_len),
467        Ok(Load::Piece { piece, populated }) => {
468            let value = piece.value().clone();
469            promote_if_valid(cache, tags, key, value, populated.age, expected_len)
470        }
471        // Hybrid parity: a throttled disk read is reported to the caller the
472        // same as a miss.
473        Ok(Load::Miss) | Ok(Load::Throttled) => None,
474        // A backend (disk/IO) error must not fail the read: treat it as a
475        // miss so the caller falls back to origin, but surface it as a
476        // metric + log so a degraded SSD volume is observable rather than
477        // silently inflating origin traffic.
478        Err(e) => {
479            imetric!("range_cache_backend_error", "count", 1_u64);
480            warn!("range_cache backend get error key={key}: {e}");
481            None
482        }
483    }
484}
485
486impl FoyerBackend {
487    /// Step 2 dispatch: join an already in-flight disk load for `key`, or
488    /// become its owner. The owned load is a detached `tokio::spawn`ed task
489    /// (safe here precisely because `load_and_promote`'s only write is
490    /// promotion-gated, so it never needs cancelling), so a caller dropping
491    /// this future never strands a follower still awaiting the `Shared`.
492    async fn load_from_disk(&self, key: &str, expected_len: u64) -> Option<Bytes> {
493        let fut: SharedLoad = {
494            let mut loads = self.loads.lock().expect("foyer backend loads lock");
495            if let Some(existing) = loads.get(key) {
496                imetric!("range_cache_load_coalesced", "count", 1_u64);
497                existing.clone()
498            } else {
499                let cache = self.cache.clone();
500                let tags = self.tags.clone();
501                let key_for_task = key.to_string();
502                let loads_map = self.loads.clone();
503                let handle = tokio::spawn(async move {
504                    let result = load_and_promote(&cache, &tags, &key_for_task, expected_len).await;
505                    // Always runs to completion (a detached task), so no
506                    // stale map entry survives even if every awaiting query
507                    // above is cancelled.
508                    loads_map
509                        .lock()
510                        .expect("foyer backend loads lock")
511                        .remove(&key_for_task);
512                    result
513                });
514                let shared: SharedLoad = async move {
515                    match handle.await {
516                        Ok(result) => result,
517                        Err(e) => {
518                            warn!("foyer backend disk load task failed: {e}");
519                            None
520                        }
521                    }
522                }
523                .boxed()
524                .shared();
525                loads.insert(key.to_string(), shared.clone());
526                shared
527            }
528        };
529        fut.await
530    }
531}
532
533#[async_trait]
534impl RangeCacheBackend for FoyerBackend {
535    async fn get(&self, key: &str, expected_len: u64) -> Option<Bytes> {
536        // Step 1: plain RAM lookup, no inflight (`Cache::get`,
537        // foyer-memory/src/cache.rs:763).
538        if let Some(entry) = self.cache.memory().get(key) {
539            if is_block_key(key) {
540                let t = self.tags.classify(key);
541                imetric!("object_cache_ram_tier_hit", "count", t.prefix, 1_u64);
542            }
543            return Some(entry.value().bytes.clone());
544        }
545        // Step 2: RAM miss -> single-flight direct disk load.
546        self.load_from_disk(key, expected_len).await
547    }
548
549    async fn put(&self, key: String, value: Bytes, hint: FillHint) {
550        match hint {
551            // SSD-only admission: `.force()` bypasses the disk admission
552            // picker so the block is always admitted deterministically (no
553            // silent decline). The write holds only an ephemeral RAM record
554            // that is dropped immediately (no eviction-structure residency),
555            // so a prefetch fill never retains RAM residency.
556            FillHint::Prefetch => {
557                // Copy so the phantom prefetch record does not retain its whole
558                // coalesced-GET parent buffer for the duration it lives in foyer's
559                // write pipeline (submit queue, io buffer encode, pending piece_refs) --
560                // see the demand arm's identical rationale below.
561                let owned = Bytes::copy_from_slice(&value);
562                let entry = self
563                    .cache
564                    .storage_writer(key)
565                    .force()
566                    .insert(CachedBlock::new_prefetch(owned));
567                if entry.is_none() {
568                    // Should not occur under `.force()`, which always admits.
569                    imetric!(
570                        "range_cache_prefetch_admission_unexpected_none",
571                        "count",
572                        1_u64
573                    );
574                    warn!("prefetch storage_writer().force().insert() unexpectedly returned None");
575                }
576            }
577            FillHint::Demand => {
578                // Copy so the cached block does not retain its whole coalesced-GET
579                // parent buffer; otherwise RAM-tier RSS runs up to
580                // (max_coalesced_get_bytes / block_size)x its accounted weight while the
581                // weigher (value.len()) believes the tier is under budget. One memcpy per
582                // admitted block is negligible against the origin GET.
583                let owned = Bytes::copy_from_slice(&value);
584                self.cache.insert(key, CachedBlock::new(owned));
585            }
586        }
587    }
588
589    fn disk_stats(&self) -> Option<BackendDiskStats> {
590        let stats = self.cache.statistics();
591        Some(BackendDiskStats {
592            write_bytes: stats.disk_write_bytes() as u64,
593            read_bytes: stats.disk_read_bytes() as u64,
594            write_ios: stats.disk_write_ios() as u64,
595            read_ios: stats.disk_read_ios() as u64,
596        })
597    }
598
599    fn ram_usage_bytes(&self) -> Option<usize> {
600        Some(self.ram_usage())
601    }
602
603    fn ram_entries(&self) -> Option<usize> {
604        Some(self.ram_entry_count())
605    }
606}