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. On startup the store directory is wiped iff the persisted marker
123/// 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]`. The
130///   unversioned predecessor layout was `Bytes`-only; 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; safe from any thread because the metrics macros (`imetric!`,
195/// `fmetric!`) serialize through dispatch's own global mutex.
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. See `mark_shutting_down`.
224            return;
225        }
226        let prefix = self.tags.classify(key);
227        imetric!(
228            "object_cache_ram_tier_eviction_count",
229            "count",
230            prefix = static prefix,
231            reason = static reason_str(reason),
232            1_u64
233        );
234        if reason == Event::Evict {
235            // Capacity-driven -- the thrashing signal. Replace/Remove/Clear
236            // don't speak to capacity pressure.
237            let age_ms = value.ram_inserted_at.elapsed().as_secs_f64() * 1000.0;
238            fmetric!(
239                "object_cache_ram_tier_eviction_age_ms",
240                "ms",
241                prefix = static prefix,
242                age_ms
243            );
244        }
245    }
246}
247
248/// foyer disk-engine write-path tuning. Defaults reproduce foyer's own
249/// `BlockEngineConfig` defaults (`flushers=1`, `buffer_pool_size=16 MiB`),
250/// with the submit-queue threshold pinned to 2x the buffer pool -- which
251/// foyer's doc comment describes as its intended default but which 0.22 no
252/// longer applies automatically (its actual default is 1x, see
253/// `BlockEngineConfig::new`) -- so existing callers/tests are unaffected
254/// unless they opt into a different tuning.
255#[derive(Clone, Copy, Debug)]
256pub struct WriteTuning {
257    /// `BlockEngineConfig::with_flushers`.
258    pub flushers: usize,
259    /// `BlockEngineConfig::with_buffer_pool_size`, in bytes.
260    pub buffer_pool_bytes: usize,
261    /// `BlockEngineConfig::with_submit_queue_size_threshold`, in bytes.
262    pub submit_queue_threshold_bytes: usize,
263}
264
265impl Default for WriteTuning {
266    fn default() -> Self {
267        let buffer = 16 * 1024 * 1024;
268        Self {
269            flushers: 1,
270            buffer_pool_bytes: buffer,
271            submit_queue_threshold_bytes: buffer * 2,
272        }
273    }
274}
275
276/// One key's disk-load-and-promote task, shared between the caller that
277/// spawned it and every concurrent follower that joins it instead of
278/// spawning its own -- see `FoyerBackend::load_from_disk`.
279type SharedLoad = Shared<BoxFuture<'static, Option<Bytes>>>;
280
281pub struct FoyerBackend {
282    cache: HybridCache<String, CachedBlock>,
283    tags: Arc<EvictionTagTable>,
284    /// Per-key single-flight for step-2 disk loads. `get` does not route
285    /// through `HybridCache::get`/`get_or_fetch`, so it cannot rely on
286    /// foyer's own inflight coalescing, which is unreliable anyway.
287    loads: Arc<Mutex<HashMap<String, SharedLoad>>>,
288    /// Shared with `RamEvictionListener`; see `mark_shutting_down`.
289    shutting_down: Arc<AtomicBool>,
290}
291
292impl FoyerBackend {
293    pub async fn new(dir: &str, ram_bytes: usize, disk_bytes: usize) -> Result<Self> {
294        Self::new_with_shards(
295            dir,
296            ram_bytes,
297            disk_bytes,
298            8,
299            WriteTuning::default(),
300            Arc::from(Vec::new()),
301        )
302        .await
303    }
304
305    #[allow(clippy::too_many_arguments)]
306    pub async fn new_with_shards(
307        dir: &str,
308        ram_bytes: usize,
309        disk_bytes: usize,
310        shards: usize,
311        tuning: WriteTuning,
312        prefix_labels: Arc<[&'static str]>,
313    ) -> Result<Self> {
314        ensure!(shards > 0, "shards must be > 0");
315
316        prepare_disk_dir(dir, DISK_FORMAT_VERSION)?;
317
318        // Direct I/O (bypassing the page cache) matches the old `DirectFs`
319        // engine's behavior; the flag only exists on Linux.
320        #[cfg(target_os = "linux")]
321        let device = FsDeviceBuilder::new(dir)
322            .with_capacity(disk_bytes)
323            .with_direct(true)
324            .build()?;
325        #[cfg(not(target_os = "linux"))]
326        let device = FsDeviceBuilder::new(dir)
327            .with_capacity(disk_bytes)
328            .build()?;
329
330        let tags = Arc::new(EvictionTagTable::new(prefix_labels));
331        let shutting_down = Arc::new(AtomicBool::new(false));
332        let listener = Arc::new(RamEvictionListener {
333            tags: tags.clone(),
334            shutting_down: shutting_down.clone(),
335        });
336
337        let cache = HybridCacheBuilder::new()
338            .with_event_listener(listener)
339            .memory(ram_bytes)
340            .with_weighter(|_key: &String, value: &CachedBlock| value.bytes.len())
341            .with_shards(shards)
342            // Pin the RAM tier to LRU explicitly: LRU is the crate's current
343            // default eviction policy; pinning it here guards against a
344            // future foyer default change silently altering RAM-tier
345            // eviction behavior for demand fills.
346            .with_eviction_config(LruConfig::default())
347            .storage()
348            .with_engine_config(
349                BlockEngineConfig::new(device)
350                    .with_flushers(tuning.flushers)
351                    .with_buffer_pool_size(tuning.buffer_pool_bytes)
352                    .with_submit_queue_size_threshold(tuning.submit_queue_threshold_bytes),
353            )
354            .build()
355            .await?;
356        Ok(Self {
357            cache,
358            tags,
359            loads: Arc::new(Mutex::new(HashMap::new())),
360            shutting_down,
361        })
362    }
363
364    /// Marks this backend as shutting down, so `RamEvictionListener::on_leave`
365    /// skips emitting `object_cache_ram_tier_eviction_count`/`_age_ms` for the
366    /// entries `close()`'s full-tier flush is about to evict. Call
367    /// immediately before `close()` -- the only place this is needed, since a
368    /// flush that never runs (the overall shutdown deadline elapses first)
369    /// emits nothing to suppress.
370    pub fn mark_shutting_down(&self) {
371        self.shutting_down.store(true, Ordering::Relaxed);
372    }
373
374    pub async fn close(&self) -> Result<()> {
375        self.cache.close().await?;
376        Ok(())
377    }
378
379    /// Current RAM-tier byte usage. Exposed so integration tests (which
380    /// compile as a separate crate and cannot reach the private `cache`
381    /// field) can assert prefetch fills do not grow RAM-tier residency.
382    pub fn ram_usage(&self) -> usize {
383        self.cache.memory().usage()
384    }
385
386    /// Current RAM-tier entry count. Backs the trait's `ram_entries()`
387    /// method, which feeds the saturation gauge.
388    pub fn ram_entry_count(&self) -> usize {
389        self.cache.memory().entries()
390    }
391}
392
393/// Validate `value` against `expected_len` and, if it matches, promote it
394/// into the RAM tier with a fresh-normalized `CachedBlock` (see
395/// `CachedBlock::new_promoted`) carrying `age`'s hybrid promotion semantics
396/// forward -- shared by both the `Load::Entry` and `Load::Piece` arms of
397/// `FoyerBackend::load_and_promote`. Emits the disk-tier hit/age telemetry
398/// and the length-mismatch miss metric; returns the validated bytes on
399/// success.
400fn promote_if_valid(
401    cache: &HybridCache<String, CachedBlock>,
402    tags: &EvictionTagTable,
403    key: &str,
404    value: CachedBlock,
405    age: Age,
406    expected_len: u64,
407) -> Option<Bytes> {
408    if value.bytes.len() as u64 != expected_len {
409        // The short entry is left in place: the heal's put(Demand) will
410        // supersede it, and until then other probes coalesce on the origin
411        // single-flight.
412        imetric!("range_cache_promotion_len_mismatch", "count", 1_u64);
413        return None;
414    }
415    if is_block_key(key) {
416        let prefix = tags.classify(key);
417        imetric!("object_cache_disk_tier_hit", "count", prefix = static prefix, 1_u64);
418        // Promotion volume. This length-validated insert below is the
419        // single disk->RAM crossing, so promotion_count == disk_tier_hit by
420        // construction; it exists as the named companion and denominator to
421        // promotion_bytes (mean promoted block size = bytes / count) -- the
422        // churn signal weighed against object_cache_ram_tier_eviction_*.
423        imetric!("object_cache_promotion_count", "count", prefix = static prefix, 1_u64);
424        imetric!(
425            "object_cache_promotion_bytes",
426            "bytes",
427            prefix = static prefix,
428            value.bytes.len() as u64
429        );
430    }
431    if value.disk_write_ms != DISK_WRITE_NONE {
432        // This per-read age is the observable disk-exit signal foyer 0.22
433        // exposes, and its max/high-quantiles estimate the (unobservable)
434        // reclaim age.
435        let age_ms = (chrono::Utc::now().timestamp_millis() - value.disk_write_ms) as f64;
436        let prefix = tags.classify(key);
437        fmetric!(
438            "object_cache_disk_tier_read_age_ms",
439            "ms",
440            prefix = static prefix,
441            age_ms.max(0.0)
442        );
443    }
444    let bytes = value.bytes.clone();
445    cache.memory().insert_with_properties(
446        key.to_string(),
447        CachedBlock::new_promoted(bytes.clone(), value.disk_write_ms),
448        HybridCacheProperties::default().with_age(age),
449    );
450    Some(bytes)
451}
452
453/// Step 2 of the two-step read: a direct (non-inflight) disk load, validated
454/// and promoted on a length match. Replicates `HybridCache::get`'s own
455/// `Entry`/`Piece` handling (`foyer/src/hybrid/cache.rs:660-718`) minus the
456/// broken inflight layer, with the Piece arm's deliberate divergence
457/// documented on `promote_if_valid`/`CachedBlock::new_promoted`.
458async fn load_and_promote(
459    cache: &HybridCache<String, CachedBlock>,
460    tags: &EvictionTagTable,
461    key: &str,
462    expected_len: u64,
463) -> Option<Bytes> {
464    match cache.storage().load(key).await {
465        Ok(Load::Entry {
466            value, populated, ..
467        }) => promote_if_valid(cache, tags, key, value, populated.age, expected_len),
468        Ok(Load::Piece { piece, populated }) => {
469            let value = piece.value().clone();
470            promote_if_valid(cache, tags, key, value, populated.age, expected_len)
471        }
472        // Hybrid parity: a throttled disk read is reported to the caller the
473        // same as a miss.
474        Ok(Load::Miss) | Ok(Load::Throttled) => None,
475        // A backend (disk/IO) error must not fail the read: treat it as a
476        // miss so the caller falls back to origin, but surface it as a
477        // metric + log so a degraded SSD volume is observable rather than
478        // silently inflating origin traffic.
479        Err(e) => {
480            imetric!("range_cache_backend_error", "count", 1_u64);
481            warn!("range_cache backend get error key={key}: {e}");
482            None
483        }
484    }
485}
486
487impl FoyerBackend {
488    /// Step 2 dispatch: join an already in-flight disk load for `key`, or
489    /// become its owner. The owned load is a detached `tokio::spawn`ed task
490    /// (safe here precisely because `load_and_promote`'s only write is
491    /// promotion-gated, so it never needs cancelling), so a caller dropping
492    /// this future never strands a follower still awaiting the `Shared`.
493    async fn load_from_disk(&self, key: &str, expected_len: u64) -> Option<Bytes> {
494        let fut: SharedLoad = {
495            let mut loads = self.loads.lock().expect("foyer backend loads lock");
496            if let Some(existing) = loads.get(key) {
497                imetric!("range_cache_load_coalesced", "count", 1_u64);
498                existing.clone()
499            } else {
500                let cache = self.cache.clone();
501                let tags = self.tags.clone();
502                let key_for_task = key.to_string();
503                let loads_map = self.loads.clone();
504                let handle = tokio::spawn(async move {
505                    let result = load_and_promote(&cache, &tags, &key_for_task, expected_len).await;
506                    // Always runs to completion (a detached task), so no
507                    // stale map entry survives even if every awaiting query
508                    // above is cancelled.
509                    loads_map
510                        .lock()
511                        .expect("foyer backend loads lock")
512                        .remove(&key_for_task);
513                    result
514                });
515                let shared: SharedLoad = async move {
516                    match handle.await {
517                        Ok(result) => result,
518                        Err(e) => {
519                            warn!("foyer backend disk load task failed: {e}");
520                            None
521                        }
522                    }
523                }
524                .boxed()
525                .shared();
526                loads.insert(key.to_string(), shared.clone());
527                shared
528            }
529        };
530        fut.await
531    }
532}
533
534#[async_trait]
535impl RangeCacheBackend for FoyerBackend {
536    async fn get(&self, key: &str, expected_len: u64) -> Option<Bytes> {
537        // Step 1: plain RAM lookup, no inflight (`Cache::get`,
538        // foyer-memory/src/cache.rs:763).
539        if let Some(entry) = self.cache.memory().get(key) {
540            if is_block_key(key) {
541                let prefix = self.tags.classify(key);
542                imetric!("object_cache_ram_tier_hit", "count", prefix = static prefix, 1_u64);
543            }
544            return Some(entry.value().bytes.clone());
545        }
546        // Step 2: RAM miss -> single-flight direct disk load.
547        self.load_from_disk(key, expected_len).await
548    }
549
550    async fn put(&self, key: String, value: Bytes, hint: FillHint) {
551        match hint {
552            // SSD-only admission: `.force()` bypasses the disk admission
553            // picker so the block is always admitted deterministically (no
554            // silent decline). The write holds only an ephemeral RAM record
555            // that is dropped immediately (no eviction-structure residency),
556            // so a prefetch fill never retains RAM residency. `value` is
557            // already an owned per-block copy (see `RangeCacheBackend::put`'s
558            // doc), so it's stored as-is rather than copied again.
559            FillHint::Prefetch => {
560                let entry = self
561                    .cache
562                    .storage_writer(key)
563                    .force()
564                    .insert(CachedBlock::new_prefetch(value));
565                if entry.is_none() {
566                    // Should not occur under `.force()`, which always admits.
567                    imetric!(
568                        "range_cache_prefetch_admission_unexpected_none",
569                        "count",
570                        1_u64
571                    );
572                    warn!("prefetch storage_writer().force().insert() unexpectedly returned None");
573                }
574            }
575            FillHint::Demand => {
576                self.cache.insert(key, CachedBlock::new(value));
577            }
578        }
579    }
580
581    fn disk_stats(&self) -> Option<BackendDiskStats> {
582        let stats = self.cache.statistics();
583        Some(BackendDiskStats {
584            write_bytes: stats.disk_write_bytes() as u64,
585            read_bytes: stats.disk_read_bytes() as u64,
586            write_ios: stats.disk_write_ios() as u64,
587            read_ios: stats.disk_read_ios() as u64,
588        })
589    }
590
591    fn ram_usage_bytes(&self) -> Option<usize> {
592        Some(self.ram_usage())
593    }
594
595    fn ram_entries(&self) -> Option<usize> {
596        Some(self.ram_entry_count())
597    }
598}