Skip to main content

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;