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}