Skip to content

Commit 43ac44d

Browse files
committed
Add try get for metata cache for data fetch calls
Signed-off-by: Vishwas Garg <vishwasgarg14@gmail.com>
1 parent a824d05 commit 43ac44d

3 files changed

Lines changed: 276 additions & 4 deletions

File tree

sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/foyer_cache.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,35 @@ impl FoyerCache {
731731
let raw = if let Some(pos) = key.find(SEPARATOR) { &key[..pos] } else { key };
732732
raw.trim_start_matches('/')
733733
}
734+
735+
/// Stats-aware peek: returns `Some(bytes)` on hit and updates `hit_count` +
736+
/// `hit_bytes`; returns `None` on miss WITHOUT bumping `miss_count` or
737+
/// `miss_bytes`. Designed for the metadata-tier shortcut probe in
738+
/// [`crate::tiered_block_cache::TieredBlockCache::get`] — a miss there means
739+
/// "this key wasn't a metadata entry to begin with" (column-chunk reads
740+
/// dominate normal traffic), not a real cache failure, so counting it would
741+
/// pollute the metadata-tier miss stats.
742+
///
743+
/// `active_in_bytes` accounting is preserved (it's a backpressure metric for
744+
/// concurrent in-flight reads, not a hit/miss metric, and a shortcut probe
745+
/// still reads from disk).
746+
pub fn try_get<'a>(&'a self, key: &'a CacheKey)
747+
-> std::pin::Pin<Box<dyn std::future::Future<Output = Option<Bytes>> + Send + 'a>>
748+
{
749+
Box::pin(async {
750+
let range_len = key.range_len() as i64;
751+
let _active_guard = ActiveBytesGuard::new(&self.stats.active_in_bytes, range_len);
752+
match self.inner.get(&key.as_str().to_string()).await {
753+
Ok(Some(e)) => {
754+
let size = e.value().len() as i64;
755+
self.stats.hit_count.fetch_add(1, Ordering::Relaxed);
756+
self.stats.hit_bytes.fetch_add(size, Ordering::Relaxed);
757+
Some(Bytes::copy_from_slice(e.value()))
758+
}
759+
_ => None, // intentionally NO miss tracking — see doc above
760+
}
761+
})
762+
}
734763
}
735764

736765
impl BlockCache for FoyerCache {

sandbox/plugins/block-cache-foyer/src/main/rust/src/tests.rs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2771,3 +2771,239 @@ fn tiered_cache_key_alignment_warmup_matches_query() {
27712771
assert!(block_on(tiered.get(&range_cache_key("seg0/_0.parquet", 7_500_000, 9_000_000))).is_none());
27722772
}
27732773

2774+
2775+
2776+
// ── try_get tests (FoyerCache) ──────────────────────────────────────────────
2777+
2778+
/// `try_get` HIT path: returns `Some(bytes)` AND bumps `hit_count` + `hit_bytes`,
2779+
/// matching the regular `get` HIT contract.
2780+
#[test]
2781+
fn try_get_hit_updates_hit_stats() {
2782+
let (cache, _dir) = test_cache();
2783+
2784+
let key = range_cache_key("seg0/_0.parquet", 100, 132);
2785+
let payload = Bytes::from_static(b"32_bytes_of_metadata____________");
2786+
assert_eq!(payload.len(), 32);
2787+
cache.put(&key, payload.clone());
2788+
2789+
let hits_before = cache.stats.hit_count.load(Ordering::Relaxed);
2790+
let hit_bytes_before = cache.stats.hit_bytes.load(Ordering::Relaxed);
2791+
let misses_before = cache.stats.miss_count.load(Ordering::Relaxed);
2792+
let miss_bytes_before = cache.stats.miss_bytes.load(Ordering::Relaxed);
2793+
2794+
let result = block_on(cache.try_get(&key));
2795+
assert_eq!(result.as_deref(), Some(payload.as_ref()));
2796+
2797+
assert_eq!(cache.stats.hit_count.load(Ordering::Relaxed), hits_before + 1, "hit_count must bump on hit");
2798+
assert_eq!(cache.stats.hit_bytes.load(Ordering::Relaxed), hit_bytes_before + 32, "hit_bytes must bump by entry size");
2799+
assert_eq!(cache.stats.miss_count.load(Ordering::Relaxed), misses_before, "miss_count must NOT bump on hit");
2800+
assert_eq!(cache.stats.miss_bytes.load(Ordering::Relaxed), miss_bytes_before, "miss_bytes must NOT bump on hit");
2801+
}
2802+
2803+
/// `try_get` MISS path: returns `None` and leaves `miss_count` + `miss_bytes` UNCHANGED.
2804+
/// This is the whole point of the method — a miss on the metadata-tier shortcut probe
2805+
/// is not a meaningful cache-failure signal and must not pollute miss stats.
2806+
#[test]
2807+
fn try_get_miss_does_not_update_miss_stats() {
2808+
let (cache, _dir) = test_cache();
2809+
2810+
let absent_key = range_cache_key("seg0/_0.parquet", 0, 1024);
2811+
2812+
let hits_before = cache.stats.hit_count.load(Ordering::Relaxed);
2813+
let hit_bytes_before = cache.stats.hit_bytes.load(Ordering::Relaxed);
2814+
let misses_before = cache.stats.miss_count.load(Ordering::Relaxed);
2815+
let miss_bytes_before = cache.stats.miss_bytes.load(Ordering::Relaxed);
2816+
2817+
let result = block_on(cache.try_get(&absent_key));
2818+
assert!(result.is_none(), "try_get on absent key returns None");
2819+
2820+
assert_eq!(cache.stats.hit_count.load(Ordering::Relaxed), hits_before, "hit_count unchanged on miss");
2821+
assert_eq!(cache.stats.hit_bytes.load(Ordering::Relaxed), hit_bytes_before, "hit_bytes unchanged on miss");
2822+
assert_eq!(cache.stats.miss_count.load(Ordering::Relaxed), misses_before, "miss_count must NOT bump");
2823+
assert_eq!(cache.stats.miss_bytes.load(Ordering::Relaxed), miss_bytes_before, "miss_bytes must NOT bump");
2824+
}
2825+
2826+
/// Direct comparison: `get` vs `try_get` on the same absent key. `get` bumps miss
2827+
/// stats; `try_get` leaves them alone. Makes the contract difference obvious.
2828+
#[test]
2829+
fn try_get_vs_get_on_miss_diverge_on_miss_stats() {
2830+
let (cache, _dir) = test_cache();
2831+
2832+
let key_for_get = range_cache_key("seg0/_0.parquet", 0, 1024);
2833+
let key_for_try_get = range_cache_key("seg0/_0.parquet", 2048, 3072);
2834+
2835+
let misses_before = cache.stats.miss_count.load(Ordering::Relaxed);
2836+
2837+
// `get` on absent key bumps miss_count.
2838+
assert!(block_on(cache.get(&key_for_get)).is_none());
2839+
let after_get = cache.stats.miss_count.load(Ordering::Relaxed);
2840+
assert_eq!(after_get, misses_before + 1, "get on miss bumps miss_count");
2841+
2842+
// `try_get` on absent key leaves miss_count unchanged.
2843+
assert!(block_on(cache.try_get(&key_for_try_get)).is_none());
2844+
let after_try_get = cache.stats.miss_count.load(Ordering::Relaxed);
2845+
assert_eq!(after_try_get, after_get, "try_get on miss must NOT bump miss_count");
2846+
}
2847+
2848+
// ── TieredBlockCache miss-stat isolation tests ──────────────────────────────
2849+
2850+
/// CORE FIX: a normal data-tier read (column chunk) hits the data cache and must
2851+
/// NOT pollute the metadata-tier miss stats. Before the `try_get` switch, every
2852+
/// data read bumped `metadata_cache.miss_count` because the tiered get probed
2853+
/// metadata first via `get` (which counts misses).
2854+
#[test]
2855+
fn tiered_get_data_hit_does_not_bump_metadata_miss() {
2856+
let (tiered, _data_dir, _meta_dir) = tiered_test_cache();
2857+
2858+
let col_chunk_key = range_cache_key("seg0/_0.parquet", 0, 4096);
2859+
tiered.put(&col_chunk_key, Bytes::from(vec![0xAB; 4096]));
2860+
2861+
let meta_misses_before = tiered.metadata_cache().stats.miss_count.load(Ordering::Relaxed);
2862+
let meta_miss_bytes_before = tiered.metadata_cache().stats.miss_bytes.load(Ordering::Relaxed);
2863+
let data_hits_before = tiered.data_cache().stats.hit_count.load(Ordering::Relaxed);
2864+
2865+
let result = block_on(tiered.get(&col_chunk_key));
2866+
assert_eq!(result.map(|b| b.len()), Some(4096));
2867+
2868+
// Data tier hit count goes up.
2869+
assert_eq!(
2870+
tiered.data_cache().stats.hit_count.load(Ordering::Relaxed),
2871+
data_hits_before + 1,
2872+
"data tier should record the hit"
2873+
);
2874+
// Metadata tier miss stats stay UNCHANGED — that's the fix.
2875+
assert_eq!(
2876+
tiered.metadata_cache().stats.miss_count.load(Ordering::Relaxed),
2877+
meta_misses_before,
2878+
"metadata tier miss_count must NOT bump on data-tier hit"
2879+
);
2880+
assert_eq!(
2881+
tiered.metadata_cache().stats.miss_bytes.load(Ordering::Relaxed),
2882+
meta_miss_bytes_before,
2883+
"metadata tier miss_bytes must NOT bump on data-tier hit"
2884+
);
2885+
}
2886+
2887+
/// When BOTH tiers miss (cold key), only the data tier records the miss. The
2888+
/// metadata tier's miss stats remain at zero — every column-chunk-shaped key
2889+
/// would otherwise inflate them with non-meaningful misses.
2890+
#[test]
2891+
fn tiered_get_full_miss_only_bumps_data_miss() {
2892+
let (tiered, _data_dir, _meta_dir) = tiered_test_cache();
2893+
2894+
let cold_key = range_cache_key("seg0/_0.parquet", 5_000_000, 6_000_000);
2895+
2896+
let meta_misses_before = tiered.metadata_cache().stats.miss_count.load(Ordering::Relaxed);
2897+
let data_misses_before = tiered.data_cache().stats.miss_count.load(Ordering::Relaxed);
2898+
2899+
let result = block_on(tiered.get(&cold_key));
2900+
assert!(result.is_none(), "cold key must miss both tiers");
2901+
2902+
// Data tier records the real miss.
2903+
assert_eq!(
2904+
tiered.data_cache().stats.miss_count.load(Ordering::Relaxed),
2905+
data_misses_before + 1,
2906+
"data tier should record the miss"
2907+
);
2908+
// Metadata tier miss stats stay at zero.
2909+
assert_eq!(
2910+
tiered.metadata_cache().stats.miss_count.load(Ordering::Relaxed),
2911+
meta_misses_before,
2912+
"metadata tier miss_count must NOT bump on full miss"
2913+
);
2914+
}
2915+
2916+
/// Sanity check: a metadata HIT (warmup-promoted entry) still bumps
2917+
/// `metadata_cache.hit_count` and `hit_bytes`. The fix must only suppress the
2918+
/// MISS path; the HIT path is unaffected.
2919+
#[test]
2920+
fn tiered_get_metadata_hit_still_bumps_metadata_hit_stats() {
2921+
let (tiered, _data_dir, _meta_dir) = tiered_test_cache();
2922+
2923+
let footer_key = range_cache_key("seg0/_0.parquet", 9_000_000, 10_000_000);
2924+
let payload = Bytes::from(vec![0xCD; 1_000_000]);
2925+
tiered.put_metadata(&footer_key, payload.clone());
2926+
2927+
let meta_hits_before = tiered.metadata_cache().stats.hit_count.load(Ordering::Relaxed);
2928+
let meta_hit_bytes_before = tiered.metadata_cache().stats.hit_bytes.load(Ordering::Relaxed);
2929+
let meta_misses_before = tiered.metadata_cache().stats.miss_count.load(Ordering::Relaxed);
2930+
let data_hits_before = tiered.data_cache().stats.hit_count.load(Ordering::Relaxed);
2931+
2932+
let result = block_on(tiered.get(&footer_key));
2933+
assert_eq!(result.map(|b| b.len()), Some(1_000_000));
2934+
2935+
// Metadata tier hit stats bump.
2936+
assert_eq!(
2937+
tiered.metadata_cache().stats.hit_count.load(Ordering::Relaxed),
2938+
meta_hits_before + 1,
2939+
"metadata hit_count must bump on metadata HIT"
2940+
);
2941+
assert_eq!(
2942+
tiered.metadata_cache().stats.hit_bytes.load(Ordering::Relaxed),
2943+
meta_hit_bytes_before + 1_000_000,
2944+
"metadata hit_bytes must bump by entry size on HIT"
2945+
);
2946+
// Metadata miss + data tier counters untouched.
2947+
assert_eq!(
2948+
tiered.metadata_cache().stats.miss_count.load(Ordering::Relaxed),
2949+
meta_misses_before,
2950+
"metadata miss_count must stay unchanged"
2951+
);
2952+
assert_eq!(
2953+
tiered.data_cache().stats.hit_count.load(Ordering::Relaxed),
2954+
data_hits_before,
2955+
"data tier untouched when metadata hits"
2956+
);
2957+
}
2958+
2959+
/// Realistic mixed workload: warmup populates metadata for 1 footer, query reads
2960+
/// 1 metadata range + 5 column-chunk ranges (4 data-tier hits + 1 cold miss).
2961+
/// After the workload:
2962+
/// metadata: hits=1, misses=0 (the fix — was 5 with the old `get`)
2963+
/// data: hits=4, misses=1
2964+
#[test]
2965+
fn tiered_get_mixed_workload_metadata_misses_stay_zero() {
2966+
let (tiered, _data_dir, _meta_dir) = tiered_test_cache();
2967+
2968+
let path = "seg0/_0.parquet";
2969+
2970+
// ── Warmup: 1 footer goes to metadata tier ───────────────────────────
2971+
let footer_key = range_cache_key(path, 9_000_000, 10_000_000);
2972+
tiered.put_metadata(&footer_key, Bytes::from(vec![0xCD; 1_000_000]));
2973+
2974+
// ── Pre-populate data tier: 4 column chunks ─────────────────────────
2975+
let chunk_keys: Vec<_> = (0..4)
2976+
.map(|i| range_cache_key(path, i * 100_000, (i + 1) * 100_000))
2977+
.collect();
2978+
for k in &chunk_keys {
2979+
tiered.put(k, Bytes::from(vec![0x01; 100_000]));
2980+
}
2981+
2982+
let cold_chunk_key = range_cache_key(path, 8_000_000, 8_100_000);
2983+
2984+
// ── Query: 1 metadata read + 4 data hits + 1 data miss ──────────────
2985+
assert!(block_on(tiered.get(&footer_key)).is_some(),
2986+
"footer should hit metadata tier");
2987+
for k in &chunk_keys {
2988+
assert!(block_on(tiered.get(k)).is_some(),
2989+
"chunk should hit data tier");
2990+
}
2991+
assert!(block_on(tiered.get(&cold_chunk_key)).is_none(),
2992+
"cold chunk should miss both tiers");
2993+
2994+
// ── Assert clean metadata-tier stats ─────────────────────────────────
2995+
let m = tiered.metadata_cache();
2996+
assert_eq!(m.stats.hit_count.load(Ordering::Relaxed), 1,
2997+
"metadata tier should record exactly 1 hit");
2998+
assert_eq!(m.stats.miss_count.load(Ordering::Relaxed), 0,
2999+
"metadata tier miss_count must be 0 — no pollution from data reads");
3000+
assert_eq!(m.stats.miss_bytes.load(Ordering::Relaxed), 0,
3001+
"metadata tier miss_bytes must be 0");
3002+
3003+
// ── Data-tier stats look exactly like a normal cache ────────────────
3004+
let d = tiered.data_cache();
3005+
assert_eq!(d.stats.hit_count.load(Ordering::Relaxed), 4,
3006+
"data tier should record 4 hits");
3007+
assert_eq!(d.stats.miss_count.load(Ordering::Relaxed), 1,
3008+
"data tier should record 1 real miss (the cold chunk)");
3009+
}

sandbox/plugins/block-cache-foyer/src/main/rust/src/tiered_block_cache.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,19 @@ impl BlockCache for TieredBlockCache {
153153
-> std::pin::Pin<Box<dyn std::future::Future<Output = Option<Bytes>> + Send + 'a>>
154154
{
155155
Box::pin(async move {
156-
// Metadata cache first — small SSD, fast probe, never evicts.
157-
// On warm restart, Foyer recovers these from disk — instant hit.
158-
if let Some(bytes) = self.metadata_cache.get(key).await {
156+
// Metadata cache first — small SSD, fast probe, never evicts. On warm
157+
// restart, Foyer recovers these from disk so the probe is an instant hit.
158+
//
159+
// Use `try_get` (not `get`) for the metadata-tier probe so a miss does
160+
// NOT bump `metadata_cache.miss_count`. Column-chunk reads dominate
161+
// normal traffic and would otherwise inflate metadata miss stats with
162+
// entries that were never going to live in the metadata tier in the
163+
// first place. Hits still update `metadata_cache.hit_count` and
164+
// `hit_bytes` — those numbers stay meaningful.
165+
if let Some(bytes) = self.metadata_cache.try_get(key).await {
159166
return Some(bytes);
160167
}
161-
// Fall through to data cache.
168+
// Fall through to data cache (real hit/miss tracking happens here).
162169
self.data_cache.get(key).await
163170
})
164171
}

0 commit comments

Comments
 (0)