lsm_tree/cache.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Structured World Foundation
4
5#[cfg(feature = "zstd")]
6use crate::UserKey;
7use crate::sharded_cache::{Priority, ShardedCache, Weighter};
8use crate::table::block::{BlockType, Header};
9use crate::table::{Block, BlockOffset};
10use crate::value::InternalValue;
11use crate::{GlobalTableId, UserValue};
12
13const TAG_BLOCK: u8 = 0;
14const TAG_BLOB: u8 = 1;
15#[cfg(feature = "zstd")]
16const TAG_PARTIAL_BLOCK: u8 = 2;
17/// Row-cache tag: a fully resolved point-read result (`InternalValue`) keyed by
18/// the owning SST's id + the user key's hash, so a repeat point read returns the
19/// decoded value without re-loading and re-decoding its data block.
20const TAG_ROW: u8 = 3;
21
22#[derive(Clone)]
23enum Item {
24 Block(Block),
25 Blob(UserValue),
26 /// A resolved point-read result for one user key in one (immutable) SST: the
27 /// newest version found there. The full key is carried in `key.user_key` so a
28 /// hash collision on the cache key is caught (verified on lookup) rather than
29 /// returning a wrong value.
30 Row(InternalValue),
31 /// The adaptive partial-tier entry for a cold zstd block: the decompressed
32 /// prefix + resume snapshot (so a later read extends it without re-decoding
33 /// from block 0) plus the access stats driving promotion to a full resident
34 /// block. The served data block is synthesized on demand from the prefix, so
35 /// only the touched fraction stays resident. See [`Cache::peek_partial_block`].
36 #[cfg(feature = "zstd")]
37 PartialBlock(PartialBlockEntry),
38}
39
40/// A cached partial-tier entry: the resumable decode state for a cold block plus
41/// the access stats the promotion heuristic reads. When the block is read often
42/// enough or its decoded fraction passes the promotion threshold, the reader
43/// decodes it fully and caches the whole block instead, evicting this entry.
44#[cfg(feature = "zstd")]
45#[doc(hidden)]
46#[derive(Clone)]
47pub struct PartialBlockEntry {
48 /// Resumable decode state: the decompressed prefix (`window_prime`), the
49 /// entropy/repcode snapshot, the inner-block count, and the compressed
50 /// cursor. The served data block is synthesized from `window_prime`; growth
51 /// resumes from the snapshot.
52 pub resume: crate::table::lazy_block::PartialResume,
53 /// Highest user key the decoded prefix covers (its last complete entry).
54 pub covered_upper: UserKey,
55 /// Total inner zstd blocks in the full data block.
56 pub total_blocks: u32,
57 /// Number of times this partial entry has served a read (promotion input).
58 pub hits: u32,
59}
60
61#[derive(Clone, Copy, Eq, core::hash::Hash, PartialEq)]
62struct CacheKey(u8, u64, u64, u64);
63
64impl From<(u8, u64, u64, u64)> for CacheKey {
65 fn from((tag, root_id, table_id, offset): (u8, u64, u64, u64)) -> Self {
66 Self(tag, root_id, table_id, offset)
67 }
68}
69
70#[derive(Clone)]
71struct BlockWeighter;
72
73impl Weighter<CacheKey, Item> for BlockWeighter {
74 fn weight(&self, _: &CacheKey, item: &Item) -> u64 {
75 use Item::{Blob, Block};
76
77 match item {
78 Block(b) => {
79 (Header::header_len(b.header.block_type) as u64)
80 + u64::from(b.header.uncompressed_length)
81 }
82 Blob(b) => b.len() as u64,
83 // Key bytes + value bytes + a fixed term for the InternalKey scalars
84 // (seqno + value_type) and the entry's own bookkeeping.
85 Item::Row(iv) => iv.key.user_key.len() as u64 + iv.value.len() as u64 + 16,
86 // Weighed by the resident decompressed prefix + covered key; the
87 // shared `Arc<ResumeState>` scratch is approximated by a small fixed
88 // term rather than counted per entry.
89 #[cfg(feature = "zstd")]
90 Item::PartialBlock(entry) => {
91 entry.resume.window_prime.len() as u64 + entry.covered_upper.len() as u64 + 64
92 }
93 }
94 }
95}
96
97/// Cache, in which blocks or blobs are cached in-memory
98/// after being retrieved from disk
99///
100/// This speeds up consecutive queries to nearby data, improving
101/// read performance for hot data.
102///
103/// # Examples
104///
105/// Sharing cache between multiple trees
106///
107/// ```
108/// # use lsm_tree::{Tree, Config, Cache};
109/// # use std::sync::Arc;
110/// #
111/// // Provide 64 MB of cache capacity
112/// let cache = Arc::new(Cache::with_capacity_bytes(64 * 1_000 * 1_000));
113///
114/// # let folder = tempfile::tempdir()?;
115/// let tree1 = Config::new(folder, Default::default(), Default::default()).use_cache(cache.clone()).open()?;
116/// # let folder = tempfile::tempdir()?;
117/// let tree2 = Config::new(folder, Default::default(), Default::default()).use_cache(cache.clone()).open()?;
118/// #
119/// # Ok::<(), lsm_tree::Error>(())
120/// ```
121pub struct Cache {
122 // NOTE: rustc_hash performed best: https://fjall-rs.github.io/post/fjall-2-1
123 /// In-tree sharded S3-FIFO cache (byte-weighted).
124 data: ShardedCache<CacheKey, Item, BlockWeighter, rustc_hash::FxBuildHasher>,
125 /// Opt-in: when false, the row cache (decoded point-read results) is off, so
126 /// `get_row` always misses and `insert_row` is a no-op. Blocks / blobs are
127 /// cached regardless. Off by default to avoid spending the shared capacity on
128 /// rows for workloads that do not benefit (e.g. uniform / scan-heavy).
129 row_cache_enabled: bool,
130 /// When true (default), index / filter / range-tombstone blocks are admitted
131 /// at [`Priority::High`] so heavy data-block churn (working set >> cache)
132 /// cannot evict the metadata blocks every seek touches, sparing a re-read +
133 /// re-decode on the next index descent. Disable to put every block on equal
134 /// footing (the pre-priority behaviour), e.g. for A/B measurement.
135 metadata_priority: bool,
136}
137
138/// Number of shards in the block cache. 64 keeps per-shard write contention low
139/// on many-core hosts while the lock array stays small; reads take a shared lock
140/// and don't contend regardless of shard count.
141const BLOCK_CACHE_SHARDS: usize = 64;
142/// Seeds the per-shard ghost-queue sizing (S3-FIFO remembers recently-evicted
143/// fingerprints to fast-track re-admission). Matches the previous
144/// `estimated_items_capacity`.
145const BLOCK_CACHE_EST_ITEMS: usize = 10_000;
146
147impl Cache {
148 /// Creates a new block cache with roughly `n` bytes of capacity.
149 #[must_use]
150 pub fn with_capacity_bytes(bytes: u64) -> Self {
151 Self {
152 data: ShardedCache::with_weighter(
153 bytes,
154 BLOCK_CACHE_SHARDS,
155 BLOCK_CACHE_EST_ITEMS,
156 BlockWeighter,
157 rustc_hash::FxBuildHasher,
158 ),
159 row_cache_enabled: false,
160 metadata_priority: true,
161 }
162 }
163
164 /// Enables or disables the row cache (decoded point-read results), returning
165 /// the cache for builder-style configuration. Off by default; rows share the
166 /// block cache's byte capacity when enabled.
167 #[must_use]
168 pub fn with_row_cache(mut self, enabled: bool) -> Self {
169 self.row_cache_enabled = enabled;
170 self
171 }
172
173 /// Whether the row cache is enabled (see [`Cache::with_row_cache`]).
174 #[must_use]
175 pub fn row_cache_enabled(&self) -> bool {
176 self.row_cache_enabled
177 }
178
179 /// Enables or disables high-priority pinning of index / filter /
180 /// range-tombstone blocks (see [`Cache::metadata_priority`] field docs),
181 /// returning the cache for builder-style configuration. On by default.
182 #[must_use]
183 pub fn with_metadata_priority(mut self, enabled: bool) -> Self {
184 self.metadata_priority = enabled;
185 self
186 }
187
188 /// Whether metadata-block priority pinning is enabled (see
189 /// [`Cache::with_metadata_priority`]).
190 #[must_use]
191 pub fn metadata_priority(&self) -> bool {
192 self.metadata_priority
193 }
194
195 /// Returns the amount of cached bytes.
196 #[must_use]
197 pub fn size(&self) -> u64 {
198 self.data.weight()
199 }
200
201 /// Returns the cache capacity in bytes.
202 #[must_use]
203 pub fn capacity(&self) -> u64 {
204 self.data.capacity()
205 }
206
207 #[doc(hidden)]
208 #[must_use]
209 pub fn get_block(&self, id: GlobalTableId, offset: BlockOffset) -> Option<Block> {
210 let key: CacheKey = (TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into();
211
212 Some(match self.data.get(&key)? {
213 Item::Block(block) => block,
214 Item::Blob(_) | Item::Row(_) => unreachable!("invalid cache item"),
215 #[cfg(feature = "zstd")]
216 Item::PartialBlock(_) => unreachable!("invalid cache item"),
217 })
218 }
219
220 /// Whether a full (non-partial) data block is already resident for `offset`.
221 /// The partial-tier reader uses this to bail out (let the normal cached path
222 /// serve) once a block has been promoted to a full resident block.
223 #[cfg(feature = "zstd")]
224 #[doc(hidden)]
225 #[must_use]
226 pub fn has_block(&self, id: GlobalTableId, offset: BlockOffset) -> bool {
227 let key: CacheKey = (TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into();
228 self.data.peek(&key).is_some()
229 }
230
231 /// Reads the cached partial-tier entry for `offset` (resume state + access
232 /// stats), without mutating it. The caller checks coverage against its query,
233 /// applies the promotion heuristic, then re-inserts with bumped stats, grows
234 /// the extent, or promotes to a full block.
235 #[cfg(feature = "zstd")]
236 #[doc(hidden)]
237 #[must_use]
238 pub fn peek_partial_block(
239 &self,
240 id: GlobalTableId,
241 offset: BlockOffset,
242 ) -> Option<PartialBlockEntry> {
243 let key: CacheKey = (TAG_PARTIAL_BLOCK, id.tree_id(), id.table_id(), *offset).into();
244 match self.data.peek(&key) {
245 Some(Item::PartialBlock(entry)) => Some(entry),
246 _ => None,
247 }
248 }
249
250 /// Inserts or replaces the partial-tier entry for `offset` (high-water
251 /// growth: a wider covering prefix with more decoded inner blocks replaces a
252 /// narrower one).
253 #[cfg(feature = "zstd")]
254 #[doc(hidden)]
255 pub fn insert_partial_block(
256 &self,
257 id: GlobalTableId,
258 offset: BlockOffset,
259 entry: PartialBlockEntry,
260 ) {
261 self.data.insert(
262 (TAG_PARTIAL_BLOCK, id.tree_id(), id.table_id(), *offset).into(),
263 Item::PartialBlock(entry),
264 );
265 }
266
267 /// Drops the partial-tier entry for `offset` (used on promotion to a full
268 /// resident block, so the stale partial does not linger).
269 #[cfg(feature = "zstd")]
270 #[doc(hidden)]
271 pub fn evict_partial_block(&self, id: GlobalTableId, offset: BlockOffset) {
272 let key: CacheKey = (TAG_PARTIAL_BLOCK, id.tree_id(), id.table_id(), *offset).into();
273 self.data.remove(&key);
274 }
275
276 #[doc(hidden)]
277 pub fn insert_block(&self, id: GlobalTableId, offset: BlockOffset, block: Block) {
278 // Pin index / filter / range-tombstone blocks: they are touched on every
279 // seek (index descent + bloom check), so under data-block churn (working
280 // set >> cache) they must outlive the data blocks that would otherwise
281 // evict them and force a metadata re-read + re-decode on the next seek.
282 let priority = if self.metadata_priority
283 && matches!(
284 block.header.block_type,
285 BlockType::Index | BlockType::Filter | BlockType::RangeTombstone
286 ) {
287 Priority::High
288 } else {
289 Priority::Normal
290 };
291 self.data.insert_with_priority(
292 (TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into(),
293 Item::Block(block),
294 priority,
295 );
296 }
297
298 /// Looks up the cached point-read result for `user_key` in SST `id`. The
299 /// stored key is verified against `user_key` so a hash collision on the
300 /// cache slot is rejected (returns `None`) rather than serving a wrong value.
301 /// `key_hash` is the same hash the bloom filter uses, so the caller passes
302 /// the value it already computed.
303 #[doc(hidden)]
304 #[must_use]
305 pub fn get_row(
306 &self,
307 id: GlobalTableId,
308 key_hash: u64,
309 user_key: &[u8],
310 ) -> Option<InternalValue> {
311 if !self.row_cache_enabled {
312 return None;
313 }
314 let key: CacheKey = (TAG_ROW, id.tree_id(), id.table_id(), key_hash).into();
315 match self.data.get(&key)? {
316 Item::Row(iv) if &*iv.key.user_key == user_key => Some(iv),
317 // Hash collision (a different key hashed to this slot) or a foreign
318 // item kind: treat as a miss so the caller does the real lookup.
319 _ => None,
320 }
321 }
322
323 /// Caches the resolved point-read result `iv` for SST `id`, keyed by
324 /// `key_hash`. Only a newest-version result (from a latest-version read)
325 /// should be inserted, so the seqno-visibility check on lookup stays correct.
326 /// SSTs are immutable, so an entry stays valid until its SST is compacted
327 /// away (after which its `table_id` is never read again and the entry ages
328 /// out of the cache).
329 #[doc(hidden)]
330 pub fn insert_row(&self, id: GlobalTableId, key_hash: u64, iv: InternalValue) {
331 if !self.row_cache_enabled {
332 return;
333 }
334 self.data.insert(
335 (TAG_ROW, id.tree_id(), id.table_id(), key_hash).into(),
336 Item::Row(iv),
337 );
338 }
339
340 #[doc(hidden)]
341 pub fn insert_blob(
342 &self,
343 vlog_id: crate::TreeId,
344 vhandle: &crate::vlog::ValueHandle,
345 value: UserValue,
346 ) {
347 self.data.insert(
348 (TAG_BLOB, vlog_id, vhandle.blob_file_id, vhandle.offset).into(),
349 Item::Blob(value),
350 );
351 }
352
353 #[doc(hidden)]
354 #[must_use]
355 pub fn get_blob(
356 &self,
357 vlog_id: crate::TreeId,
358 vhandle: &crate::vlog::ValueHandle,
359 ) -> Option<UserValue> {
360 let key: CacheKey = (TAG_BLOB, vlog_id, vhandle.blob_file_id, vhandle.offset).into();
361
362 Some(match self.data.get(&key)? {
363 Item::Blob(blob) => blob,
364 Item::Block(_) | Item::Row(_) => unreachable!("invalid cache item"),
365 #[cfg(feature = "zstd")]
366 Item::PartialBlock(_) => unreachable!("invalid cache item"),
367 })
368 }
369}
370
371#[cfg(test)]
372mod tests;