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}