lsm_tree/cache.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
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/// Decoded-directory tag: a columnar row group's page directory, decoded and
22/// checked once, keyed by the group's offset.
23#[cfg(feature = "columnar")]
24const TAG_DIRECTORY: u8 = 4;
25
26#[derive(Clone)]
27enum Item {
28 Block(Block),
29 /// A separated value, carrying everything about the handle that the cache
30 /// key does not: the user key it belongs to and the record's on-disk size.
31 ///
32 /// The cache key is the blob file id plus the offset, which identifies a
33 /// POSITION, not a record. A corrupt index entry can name that position
34 /// with a different key, or with the right key and the wrong size. A direct
35 /// read rejects both (the reader compares the key it was asked for AND the
36 /// declared size against the record header), so the cached path keeps the
37 /// same two fields and compares them, rather than trusting the position.
38 Blob(crate::UserKey, u32, UserValue),
39 /// A resolved point-read result for one user key in one (immutable) SST: the
40 /// newest version found there. The full key is carried in `key.user_key` so a
41 /// hash collision on the cache key is caught (verified on lookup) rather than
42 /// returning a wrong value.
43 Row(InternalValue),
44 /// The adaptive partial-tier entry for a cold zstd block: the decompressed
45 /// prefix + resume snapshot (so a later read extends it without re-decoding
46 /// from block 0) plus the access stats driving promotion to a full resident
47 /// block. The served data block is synthesized on demand from the prefix, so
48 /// only the touched fraction stays resident. See [`Cache::peek_partial_block`].
49 #[cfg(feature = "zstd")]
50 PartialBlock(PartialBlockEntry),
51 /// A columnar row group's page directory, decoded and checked, with the
52 /// directory block's on-disk length. Every read of a group starts from its
53 /// directory, and a directory of many row pages is dozens to hundreds of
54 /// entries whose decode sorts them to prove the page grid: caching it
55 /// decoded makes every later read of the group skip both, where caching
56 /// the raw block would repeat them per read.
57 #[cfg(feature = "columnar")]
58 Directory(
59 alloc::sync::Arc<crate::table::column_page::PageDirectory>,
60 u32,
61 ),
62}
63
64/// A cached partial-tier entry: the resumable decode state for a cold block plus
65/// the access stats the promotion heuristic reads. When the block is read often
66/// enough or its decoded fraction passes the promotion threshold, the reader
67/// decodes it fully and caches the whole block instead, evicting this entry.
68#[cfg(feature = "zstd")]
69#[doc(hidden)]
70#[derive(Clone)]
71pub struct PartialBlockEntry {
72 /// Resumable decode state: the decompressed prefix (`window_prime`), the
73 /// entropy/repcode snapshot, the inner-block count, and the compressed
74 /// cursor. The served data block is synthesized from `window_prime`; growth
75 /// resumes from the snapshot.
76 pub resume: crate::table::lazy_block::PartialResume,
77 /// Highest user key the decoded prefix covers (its last complete entry).
78 pub covered_upper: UserKey,
79 /// Total inner zstd blocks in the full data block.
80 pub total_blocks: u32,
81 /// Number of times this partial entry has served a read (promotion input).
82 pub hits: u32,
83}
84
85#[derive(Clone, Copy, Eq, core::hash::Hash, PartialEq)]
86struct CacheKey(u8, u64, u64, u64);
87
88impl From<(u8, u64, u64, u64)> for CacheKey {
89 fn from((tag, root_id, table_id, offset): (u8, u64, u64, u64)) -> Self {
90 Self(tag, root_id, table_id, offset)
91 }
92}
93
94/// What a cached row costs the cache: key bytes + value bytes + a fixed term for
95/// the `InternalKey` scalars (seqno + `value_type`) and the entry's own
96/// bookkeeping.
97///
98/// Shared by the weigher and by `insert_row`'s admission pre-check so the two
99/// cannot drift: a pre-check that charged less than the weigher would copy a
100/// value the cache then refuses, and one that charged more would skip a row that
101/// would have fit.
102const fn row_weight(key_len: usize, value_len: usize) -> u64 {
103 key_len as u64 + value_len as u64 + 16
104}
105
106/// What a block weighs in the cache: its header and its decoded payload.
107pub fn block_weight(block: &crate::table::Block) -> u64 {
108 (Header::header_len(block.header.block_type) as u64)
109 + u64::from(block.header.uncompressed_length)
110}
111
112#[derive(Clone)]
113struct BlockWeighter;
114
115impl Weighter<CacheKey, Item> for BlockWeighter {
116 fn weight(&self, _: &CacheKey, item: &Item) -> u64 {
117 use Item::{Blob, Block};
118
119 match item {
120 Block(b) => block_weight(b),
121 // Key + value; the size field is an inline scalar. The prefetch's
122 // admission budget charges itself the same way, so the two agree.
123 Blob(key, _, b) => (key.len() + b.len()) as u64,
124 Item::Row(iv) => row_weight(iv.key.user_key.len(), iv.value.len()),
125 // Weighed by the resident decompressed prefix + covered key; the
126 // shared `Arc<ResumeState>` scratch is approximated by a small fixed
127 // term rather than counted per entry.
128 #[cfg(feature = "zstd")]
129 Item::PartialBlock(entry) => {
130 entry.resume.window_prime.len() as u64 + entry.covered_upper.len() as u64 + 64
131 }
132 #[cfg(feature = "columnar")]
133 Item::Directory(directory, _) => directory.heap_size() as u64 + 64,
134 }
135 }
136}
137
138/// Cache, in which blocks or blobs are cached in-memory
139/// after being retrieved from disk
140///
141/// This speeds up consecutive queries to nearby data, improving
142/// read performance for hot data.
143///
144/// # Examples
145///
146/// Sharing cache between multiple trees
147///
148/// ```
149/// # use lsm_tree::{Tree, Config, Cache};
150/// # use std::sync::Arc;
151/// #
152/// // Provide 64 MB of cache capacity
153/// let cache = Arc::new(Cache::with_capacity_bytes(64 * 1_000 * 1_000));
154///
155/// # let folder = tempfile::tempdir()?;
156/// let tree1 = Config::new(folder, Default::default(), Default::default()).use_cache(cache.clone()).open()?;
157/// # let folder = tempfile::tempdir()?;
158/// let tree2 = Config::new(folder, Default::default(), Default::default()).use_cache(cache.clone()).open()?;
159/// #
160/// # Ok::<(), lsm_tree::Error>(())
161/// ```
162pub struct Cache {
163 // NOTE: rustc_hash performed best: https://fjall-rs.github.io/post/fjall-2-1
164 /// In-tree sharded S3-FIFO cache (byte-weighted).
165 data: ShardedCache<CacheKey, Item, BlockWeighter, rustc_hash::FxBuildHasher>,
166 /// When false, the row cache (decoded point-read results) is off, so
167 /// `get_row` always misses and `insert_row` is a no-op. Blocks / blobs are
168 /// cached regardless.
169 ///
170 /// On by default. Rows share the block cache's byte capacity, so the worry
171 /// was that a workload without key reuse would spend capacity on rows at the
172 /// blocks' expense. Measurement says it does not: scan-heavy and
173 /// larger-than-cache arms are unchanged or better, while a repeat point read
174 /// skips the index walk and the data-block decode outright. Turn it off for
175 /// a workload measured to be one of the exceptions.
176 row_cache_enabled: bool,
177 /// When true (default), index / filter / range-tombstone blocks are admitted
178 /// at [`Priority::High`] so heavy data-block churn (working set >> cache)
179 /// cannot evict the metadata blocks every seek touches, sparing a re-read +
180 /// re-decode on the next index descent. Disable to put every block on equal
181 /// footing (the pre-priority behaviour), e.g. for A/B measurement.
182 metadata_priority: bool,
183 /// The cold loads in flight, keyed like the entries they will fill, so
184 /// readers that miss the same block or blob at once share one read and
185 /// one decode. Only a miss consults it.
186 loads: Loads,
187}
188
189/// The registry of cold loads in flight.
190type Loads =
191 crate::inflight::InflightLoads<CacheKey, Item, LoadIdentity, rustc_hash::FxBuildHasher>;
192
193/// The lead of one cold load, borrowing the cache's registry.
194type LoadLead<'a> =
195 crate::inflight::Lead<'a, CacheKey, Item, LoadIdentity, rustc_hash::FxBuildHasher>;
196
197/// What a cold load reads, beyond its position: a waiter takes a load's
198/// result only when it asked for the same thing, so a request with another
199/// extent, role or codec never receives bytes interpreted for a different one.
200#[derive(Clone, Copy, PartialEq, Eq)]
201pub enum LoadIdentity {
202 /// A table block: its role, on-disk length, codec (dictionary included)
203 /// and whether it is encrypted.
204 Block {
205 block_type: BlockType,
206 size: u32,
207 compression: crate::CompressionType,
208 encrypted: bool,
209 },
210 /// A separated value: its record's on-disk size. The user key is checked
211 /// against the value the load returns, as a cache hit checks it.
212 Blob { on_disk_size: u32 },
213 /// Growth of a block's partially decoded prefix. Waiters take no value:
214 /// once the growth ends they look at the cached prefix again.
215 #[cfg(feature = "zstd")]
216 PartialGrowth,
217}
218
219/// How a read that needs a block's partial prefix grown proceeds.
220#[cfg(feature = "zstd")]
221pub enum GrowthClaim<'a> {
222 /// No growth of the block runs: grow it, holding the lead until the
223 /// grown prefix is cached.
224 Lead(GrowthLead<'a>),
225 /// Another read grew the block while this one waited: look at the cached
226 /// prefix again before growing anything.
227 Waited,
228 /// Grow without coordination: the registry is full.
229 Alone,
230}
231
232/// The lead of a partial prefix's growth. Dropping it, after the grown prefix
233/// is cached or on any failure, lets the reads waiting on it look again.
234#[cfg(feature = "zstd")]
235pub struct GrowthLead<'a>(
236 #[expect(dead_code, reason = "held for its drop, which releases the waiters")] LoadLead<'a>,
237);
238
239/// How a block miss proceeds: lead the load, take a concurrent one's block,
240/// or load alone.
241pub enum BlockClaim<'a> {
242 /// No load of the block is in flight: read it, then [`BlockLead::publish`].
243 Lead(BlockLead<'a>),
244 /// A load of the block finished while this request waited for it.
245 Shared(Block),
246 /// Read the block without coordination: the registry is full, an
247 /// incompatible load of the position is in flight, or the load this
248 /// request waited for failed.
249 Alone,
250}
251
252/// The lead of a block load. Dropping it unpublished (an error or an unwind)
253/// releases its waiters to read on their own. A batched read's lead also
254/// keeps its thread from waiting on other loads while it lives.
255pub struct BlockLead<'a>(LoadLead<'a>, Option<crate::inflight::BatchToken>);
256
257impl BlockLead<'_> {
258 /// Hands the loaded block to the waiters, if any; insert it into the
259 /// cache first, so a miss after this sees it there.
260 pub(crate) fn publish(self, block: &Block) {
261 let Self(lead, _batch) = self;
262 lead.publish_with(|| Item::Block(block.clone()));
263 }
264}
265
266/// How a miss of a row group's decoded directory proceeds, as [`BlockClaim`]
267/// for a block.
268#[cfg(feature = "columnar")]
269pub enum DirectoryClaim<'a> {
270 /// Read the directory, then [`DirectoryLead::publish`] it.
271 Lead(DirectoryLead<'a>),
272 /// A concurrent read of the group's directory finished: the directory
273 /// and its block's on-disk length.
274 Shared(
275 alloc::sync::Arc<crate::table::column_page::PageDirectory>,
276 u32,
277 ),
278 /// Read the directory without coordination.
279 Alone,
280}
281
282/// The lead of a directory load, as [`BlockLead`] for a block.
283#[cfg(feature = "columnar")]
284pub struct DirectoryLead<'a>(LoadLead<'a>);
285
286#[cfg(feature = "columnar")]
287impl DirectoryLead<'_> {
288 /// Hands the decoded directory and its block's on-disk length to the
289 /// waiters; cache it first, so a miss after this sees it there.
290 pub(crate) fn publish(
291 self,
292 directory: &alloc::sync::Arc<crate::table::column_page::PageDirectory>,
293 len: u32,
294 ) {
295 self.0
296 .publish_with(|| Item::Directory(alloc::sync::Arc::clone(directory), len));
297 }
298}
299
300/// How a blob miss proceeds, as [`BlockClaim`] for a block.
301pub enum BlobClaim<'a> {
302 /// Read the value, then [`BlobLead::publish`].
303 Lead(BlobLead<'a>),
304 /// A concurrent read of the same record, for the same key, finished.
305 Shared(UserValue),
306 /// Read the value without coordination.
307 Alone,
308}
309
310/// The lead of a blob load, as [`BlockLead`] for a block.
311pub struct BlobLead<'a>(LoadLead<'a>);
312
313impl BlobLead<'_> {
314 /// Hands the value read for `user_key` to the waiters, caching it first
315 /// when `fill` says so. Returns the bytes copied to own the key, which the
316 /// cache entry and the waiters share: none when the value is neither
317 /// cached nor awaited.
318 pub(crate) fn publish(
319 self,
320 cache: &Cache,
321 vlog_id: crate::TreeId,
322 vhandle: &crate::vlog::ValueHandle,
323 user_key: &[u8],
324 value: UserValue,
325 fill: bool,
326 ) -> usize {
327 if !fill && !self.0.has_waiters() {
328 // Dropped unpublished: nothing to share, and the key is not
329 // copied for nobody.
330 return 0;
331 }
332 let item = Item::Blob(user_key.into(), vhandle.on_disk_size, value);
333 if fill {
334 cache.data.insert(
335 (TAG_BLOB, vlog_id, vhandle.blob_file_id, vhandle.offset).into(),
336 item.clone(),
337 );
338 }
339 self.0.publish(item);
340 user_key.len()
341 }
342}
343
344/// Shards of the load registry: as many as the cache, so two loads that
345/// would contend on one cache shard are the ones that share a registry shard.
346const LOAD_SHARDS: usize = BLOCK_CACHE_SHARDS;
347/// Loads one registry shard coordinates at once, so the registry holds at
348/// most `LOAD_SHARDS * LOADS_PER_SHARD` tickets; a miss past that loads on
349/// its own.
350const LOADS_PER_SHARD: usize = 64;
351/// How long cold loads must take before concurrent misses of one object wait
352/// for a single load instead of each loading it (see
353/// [`Cache::with_load_coalescing_after`]). Loads served from the page cache
354/// take a few microseconds for a block and tens for a whole column row group,
355/// and waiting on another reader there loses to loading alone; this sits
356/// above them and below a read that reaches a device or the network.
357const DEFAULT_LOAD_COALESCING_AFTER: core::time::Duration = core::time::Duration::from_micros(64);
358
359/// Number of shards in the block cache. 64 keeps per-shard write contention low
360/// on many-core hosts while the lock array stays small; reads take a shared lock
361/// and don't contend regardless of shard count.
362const BLOCK_CACHE_SHARDS: usize = 64;
363/// Seeds the per-shard ghost-queue sizing (S3-FIFO remembers recently-evicted
364/// fingerprints to fast-track re-admission). Matches the previous
365/// `estimated_items_capacity`.
366const BLOCK_CACHE_EST_ITEMS: usize = 10_000;
367
368impl Cache {
369 /// Creates a new block cache with roughly `n` bytes of capacity.
370 #[must_use]
371 pub fn with_capacity_bytes(bytes: u64) -> Self {
372 Self {
373 data: ShardedCache::with_weighter(
374 bytes,
375 BLOCK_CACHE_SHARDS,
376 BLOCK_CACHE_EST_ITEMS,
377 BlockWeighter,
378 rustc_hash::FxBuildHasher,
379 ),
380 row_cache_enabled: true,
381 metadata_priority: true,
382 loads: crate::inflight::InflightLoads::new(
383 LOAD_SHARDS,
384 LOADS_PER_SHARD,
385 DEFAULT_LOAD_COALESCING_AFTER,
386 rustc_hash::FxBuildHasher,
387 ),
388 }
389 }
390
391 /// Claims the cold load of the block at `offset` after a cache miss.
392 ///
393 /// Blocks while a compatible load of the block runs on another thread,
394 /// returning its block. A [`BlockClaim::Lead`] holder checks the cache
395 /// once more before reading: a load that finished between its miss and
396 /// this claim left the block there.
397 ///
398 /// A thread that holds a batched read's lead never waits (see
399 /// [`crate::inflight::may_wait`]): it takes a block another read already
400 /// published and reads one still being read again on its own.
401 pub(crate) fn claim_block(
402 &self,
403 id: GlobalTableId,
404 offset: BlockOffset,
405 identity: LoadIdentity,
406 ) -> BlockClaim<'_> {
407 let key: CacheKey = (TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into();
408 match self.loads.claim(key, identity) {
409 crate::inflight::Claim::Lead(lead) => BlockClaim::Lead(BlockLead(lead, None)),
410 crate::inflight::Claim::Join(join) => {
411 let shared = if crate::inflight::may_wait() {
412 join.wait()
413 } else {
414 join.try_take()
415 };
416 match shared {
417 Some(Item::Block(block)) => BlockClaim::Shared(block),
418 _ => BlockClaim::Alone,
419 }
420 }
421 crate::inflight::Claim::Alone => BlockClaim::Alone,
422 }
423 }
424
425 /// Claims the lead of the load of the block at `offset` for a batched
426 /// read about to read it along with others; `None` when the read reads
427 /// it alone. Never waits, since the read goes on to lead more blocks: a
428 /// block another read is loading is read again alone. Nor does it take
429 /// one: the registry holds only loads not yet published (a lead leaves
430 /// it before it settles), and a published one is in the cache the caller
431 /// just missed.
432 ///
433 /// `lead` says whether the read may lead the load (see
434 /// [`crate::inflight::batches_may_lead`]); a lead it takes counts on this
435 /// thread until it is published or dropped, so the thread waits on
436 /// nothing meanwhile.
437 pub(crate) fn claim_batched_block(
438 &self,
439 id: GlobalTableId,
440 offset: BlockOffset,
441 identity: LoadIdentity,
442 lead: bool,
443 ) -> Option<BlockLead<'_>> {
444 if !(lead && crate::inflight::batches_may_lead()) {
445 return None;
446 }
447 let key: CacheKey = (TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into();
448 match self.loads.claim(key, identity) {
449 crate::inflight::Claim::Lead(lead) => {
450 Some(BlockLead(lead, Some(crate::inflight::BatchToken::new())))
451 }
452 crate::inflight::Claim::Join(_) | crate::inflight::Claim::Alone => None,
453 }
454 }
455
456 /// Claims the load of a row group's page at `offset` for a read about to
457 /// fetch it along with the group's other pages. Waits for a compatible
458 /// load another read runs only while this thread holds no lead of a
459 /// batched read, so until this read leads a page of its own: from then
460 /// on others may be waiting on it, and a page another read is loading is
461 /// read again alone. A lead taken here counts as a batched one.
462 ///
463 /// `with_directory` says the load of the group's directory is
464 /// coordinated, by this read or one it took the directory from: its pages
465 /// are then coordinated too, whatever their own loads measured, since a
466 /// page read along with the directory is never timed on its own.
467 #[cfg(feature = "columnar")]
468 pub(crate) fn claim_page(
469 &self,
470 id: GlobalTableId,
471 offset: BlockOffset,
472 identity: LoadIdentity,
473 with_directory: bool,
474 ) -> BlockClaim<'_> {
475 if !crate::inflight::batches_may_lead() {
476 return BlockClaim::Alone;
477 }
478 let key: CacheKey = (TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into();
479 match self.loads.claim_as(key, identity, with_directory) {
480 crate::inflight::Claim::Lead(lead) => {
481 BlockClaim::Lead(BlockLead(lead, Some(crate::inflight::BatchToken::new())))
482 }
483 crate::inflight::Claim::Join(join) if crate::inflight::may_wait() => {
484 match join.wait() {
485 Some(Item::Block(block)) => BlockClaim::Shared(block),
486 _ => BlockClaim::Alone,
487 }
488 }
489 crate::inflight::Claim::Join(_) | crate::inflight::Claim::Alone => BlockClaim::Alone,
490 }
491 }
492
493 /// Claims the cold read of the decoded directory of the row group at
494 /// `offset`, as [`Self::claim_block`] does for a block. `identity` names
495 /// the group's extent, so a read of another extent at the same offset
496 /// never takes this directory.
497 #[cfg(feature = "columnar")]
498 pub(crate) fn claim_directory(
499 &self,
500 id: GlobalTableId,
501 offset: BlockOffset,
502 identity: LoadIdentity,
503 ) -> DirectoryClaim<'_> {
504 let key: CacheKey = (TAG_DIRECTORY, id.tree_id(), id.table_id(), *offset).into();
505 match self.loads.claim(key, identity) {
506 crate::inflight::Claim::Lead(lead) => DirectoryClaim::Lead(DirectoryLead(lead)),
507 crate::inflight::Claim::Join(join) => {
508 let shared = if crate::inflight::may_wait() {
509 join.wait()
510 } else {
511 join.try_take()
512 };
513 match shared {
514 Some(Item::Directory(directory, len)) => DirectoryClaim::Shared(directory, len),
515 _ => DirectoryClaim::Alone,
516 }
517 }
518 crate::inflight::Claim::Alone => DirectoryClaim::Alone,
519 }
520 }
521
522 /// How many requests wait on cold loads in flight, for tests.
523 #[cfg(test)]
524 pub(crate) fn load_waiters(&self) -> usize {
525 self.loads.waiters()
526 }
527
528 /// How many cold loads are in flight, for tests.
529 #[cfg(test)]
530 pub(crate) fn loads_in_flight(&self) -> usize {
531 self.loads.len()
532 }
533
534 /// The load-time estimates of every registry shard summed, for tests.
535 #[cfg(test)]
536 pub(crate) fn load_estimates_total(&self) -> u64 {
537 self.loads.estimates_total()
538 }
539
540 /// The load-time estimate of the shard that the partial-prefix growth of
541 /// the block at `offset` coordinates in, for tests.
542 #[cfg(all(test, feature = "zstd"))]
543 pub(crate) fn partial_growth_estimate(&self, id: GlobalTableId, offset: BlockOffset) -> u64 {
544 self.loads
545 .estimate(&(TAG_PARTIAL_BLOCK, id.tree_id(), id.table_id(), *offset).into())
546 }
547
548 /// Claims the cold read of the separated value `vhandle` names for
549 /// `user_key`, as [`Self::claim_block`] does for a block. A shared value
550 /// is returned only when the read it came from was for the same key and
551 /// size: the position alone names a record, not whose it is.
552 pub(crate) fn claim_blob(
553 &self,
554 vlog_id: crate::TreeId,
555 vhandle: &crate::vlog::ValueHandle,
556 user_key: &[u8],
557 ) -> BlobClaim<'_> {
558 let key: CacheKey = (TAG_BLOB, vlog_id, vhandle.blob_file_id, vhandle.offset).into();
559 let identity = LoadIdentity::Blob {
560 on_disk_size: vhandle.on_disk_size,
561 };
562 match self.loads.claim(key, identity) {
563 crate::inflight::Claim::Lead(lead) => BlobClaim::Lead(BlobLead(lead)),
564 crate::inflight::Claim::Join(join) => {
565 let shared = if crate::inflight::may_wait() {
566 join.wait()
567 } else {
568 join.try_take()
569 };
570 match shared {
571 Some(Item::Blob(read_key, size, value))
572 if &*read_key == user_key && size == vhandle.on_disk_size =>
573 {
574 BlobClaim::Shared(value)
575 }
576 _ => BlobClaim::Alone,
577 }
578 }
579 crate::inflight::Claim::Alone => BlobClaim::Alone,
580 }
581 }
582
583 /// Enables or disables the row cache (decoded point-read results), returning
584 /// the cache for builder-style configuration. On by default; rows share the
585 /// block cache's byte capacity.
586 #[must_use]
587 pub fn with_row_cache(mut self, enabled: bool) -> Self {
588 self.row_cache_enabled = enabled;
589 self
590 }
591
592 /// Whether the row cache is enabled (see [`Cache::with_row_cache`]).
593 #[must_use]
594 pub fn row_cache_enabled(&self) -> bool {
595 self.row_cache_enabled
596 }
597
598 /// Enables or disables high-priority pinning of index / filter /
599 /// range-tombstone blocks (see [`Cache::metadata_priority`] field docs),
600 /// returning the cache for builder-style configuration. On by default.
601 #[must_use]
602 pub fn with_metadata_priority(mut self, enabled: bool) -> Self {
603 self.metadata_priority = enabled;
604 self
605 }
606
607 /// Whether metadata-block priority pinning is enabled (see
608 /// [`Cache::with_metadata_priority`]).
609 #[must_use]
610 pub fn metadata_priority(&self) -> bool {
611 self.metadata_priority
612 }
613
614 /// Sets how long cold loads must take before readers that miss the same
615 /// block, blob or column directory at once share one read and decode
616 /// instead of each doing it, returning the cache for builder-style
617 /// configuration.
618 ///
619 /// The cache times a sample of its cold loads. While they finish quicker
620 /// than `after` (blocks served from the page cache, with cores to spare),
621 /// a reader loads on its own: waiting for another reader's load would
622 /// cost it more than its own. Once loads take longer (slow or remote
623 /// storage, or cores saturated), concurrent misses of one object wait for
624 /// a single load. `Duration::ZERO` shares every load; `Duration::MAX`
625 /// none. Without the `std` feature loads are not timed, so only
626 /// `Duration::ZERO` shares there.
627 ///
628 /// # Examples
629 ///
630 /// ```
631 /// use core::time::Duration;
632 /// use lsm_tree::Cache;
633 ///
634 /// let cache = Cache::with_capacity_bytes(64 << 20)
635 /// .with_load_coalescing_after(Duration::from_micros(50));
636 /// assert_eq!(cache.load_coalescing_after(), Duration::from_micros(50));
637 /// ```
638 #[must_use]
639 pub fn with_load_coalescing_after(mut self, after: core::time::Duration) -> Self {
640 self.loads.set_coalesce_after(after);
641 self
642 }
643
644 /// How long cold loads must take before concurrent misses share them
645 /// (see [`Cache::with_load_coalescing_after`]).
646 #[must_use]
647 pub fn load_coalescing_after(&self) -> core::time::Duration {
648 self.loads.coalesce_after()
649 }
650
651 /// Starts timing a cold load about to run on the calling thread, for the
652 /// estimate that decides whether misses share loads.
653 pub(crate) fn time_load() -> crate::inflight::LoadTimer {
654 crate::inflight::LoadTimer::start()
655 }
656
657 /// Records the cold load of the block at `offset` that `timer` timed.
658 pub(crate) fn record_block_load(
659 &self,
660 id: GlobalTableId,
661 offset: BlockOffset,
662 timer: crate::inflight::LoadTimer,
663 ) {
664 self.loads.record_load(
665 &(TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into(),
666 timer,
667 );
668 }
669
670 /// Records a read of the blocks at `offsets` that `timer` timed, issued
671 /// together: each takes an even share of it.
672 #[cfg(feature = "columnar")]
673 pub(crate) fn record_block_loads(
674 &self,
675 id: GlobalTableId,
676 offsets: impl Iterator<Item = BlockOffset> + Clone,
677 timer: crate::inflight::LoadTimer,
678 ) {
679 self.loads.record_loads(
680 offsets.map(|offset| (TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into()),
681 timer,
682 );
683 }
684
685 /// Records the growth of the partially decoded prefix of the block at
686 /// `offset` that `timer` timed.
687 #[cfg(feature = "zstd")]
688 pub(crate) fn record_partial_growth(
689 &self,
690 id: GlobalTableId,
691 offset: BlockOffset,
692 timer: crate::inflight::LoadTimer,
693 ) {
694 self.loads.record_load(
695 &(TAG_PARTIAL_BLOCK, id.tree_id(), id.table_id(), *offset).into(),
696 timer,
697 );
698 }
699
700 /// Whether a full load of the block at `offset` is in flight, which a
701 /// read that would decode only a prefix of it can take instead.
702 #[cfg(feature = "zstd")]
703 pub(crate) fn block_load_in_flight(&self, id: GlobalTableId, offset: BlockOffset) -> bool {
704 self.loads
705 .in_flight(&(TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into())
706 }
707
708 /// Records a wait of a batched read that `timer` timed, which handed back
709 /// `reads` block reads.
710 pub(crate) fn record_batch_wait(&self, timer: crate::inflight::LoadTimer, reads: usize) {
711 self.loads.record_batch_wait(timer, reads);
712 }
713
714 /// Records the cold read of the separated value `vhandle` names that
715 /// `timer` timed.
716 pub(crate) fn record_blob_load(
717 &self,
718 vlog_id: crate::TreeId,
719 vhandle: &crate::vlog::ValueHandle,
720 timer: crate::inflight::LoadTimer,
721 ) {
722 self.loads.record_load(
723 &(TAG_BLOB, vlog_id, vhandle.blob_file_id, vhandle.offset).into(),
724 timer,
725 );
726 }
727
728 /// Records the cold read of the row group at `offset` that `timer` timed.
729 #[cfg(feature = "columnar")]
730 pub(crate) fn record_directory_load(
731 &self,
732 id: GlobalTableId,
733 offset: BlockOffset,
734 timer: crate::inflight::LoadTimer,
735 ) {
736 self.loads.record_load(
737 &(TAG_DIRECTORY, id.tree_id(), id.table_id(), *offset).into(),
738 timer,
739 );
740 }
741
742 /// Returns the amount of cached bytes.
743 #[must_use]
744 pub fn size(&self) -> u64 {
745 self.data.weight()
746 }
747
748 /// Returns the cache capacity in bytes.
749 #[must_use]
750 pub fn capacity(&self) -> u64 {
751 self.data.capacity()
752 }
753
754 /// The heaviest entry this cache can actually keep resident: one shard's
755 /// capacity. Heavier inserts are refused by the cache; callers that would
756 /// build such an entry (e.g. the scan read-ahead) can skip the work.
757 #[must_use]
758 pub(crate) fn max_entry_weight(&self) -> u64 {
759 self.data.max_entry_weight()
760 }
761
762 #[doc(hidden)]
763 #[must_use]
764 pub fn get_block(&self, id: GlobalTableId, offset: BlockOffset) -> Option<Block> {
765 let key: CacheKey = (TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into();
766
767 Some(match self.data.get(&key)? {
768 Item::Block(block) => block,
769 Item::Blob(..) | Item::Row(_) => unreachable!("invalid cache item"),
770 #[cfg(feature = "zstd")]
771 Item::PartialBlock(_) => unreachable!("invalid cache item"),
772 #[cfg(feature = "columnar")]
773 Item::Directory(..) => unreachable!("invalid cache item"),
774 })
775 }
776
777 /// [`Self::get_block`] that leaves the entry's recency and the hit
778 /// statistics as they were, for a reader that must not change what a later
779 /// read finds cached.
780 #[must_use]
781 pub(crate) fn peek_block(&self, id: GlobalTableId, offset: BlockOffset) -> Option<Block> {
782 let key: CacheKey = (TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into();
783
784 Some(match self.data.peek(&key)? {
785 Item::Block(block) => block,
786 Item::Blob(..) | Item::Row(_) => unreachable!("invalid cache item"),
787 #[cfg(feature = "zstd")]
788 Item::PartialBlock(_) => unreachable!("invalid cache item"),
789 #[cfg(feature = "columnar")]
790 Item::Directory(..) => unreachable!("invalid cache item"),
791 })
792 }
793
794 /// The decoded directory of the row group at `offset`, with its block's
795 /// on-disk length, when it is cached. `touch` says whether the lookup may
796 /// promote the entry and count as a hit, as [`Self::get_block`] does, or
797 /// must leave both as they were, as [`Self::peek_block`] does.
798 #[cfg(feature = "columnar")]
799 #[must_use]
800 pub(crate) fn get_directory(
801 &self,
802 id: GlobalTableId,
803 offset: BlockOffset,
804 touch: bool,
805 ) -> Option<(
806 alloc::sync::Arc<crate::table::column_page::PageDirectory>,
807 u32,
808 )> {
809 let key: CacheKey = (TAG_DIRECTORY, id.tree_id(), id.table_id(), *offset).into();
810 let item = if touch {
811 self.data.get(&key)
812 } else {
813 self.data.peek(&key)
814 };
815 match item? {
816 Item::Directory(directory, len) => Some((directory, len)),
817 _ => unreachable!("invalid cache item"),
818 }
819 }
820
821 /// Caches the decoded directory of the row group at `offset`, with its
822 /// block's on-disk length. A directory is read before any page of its
823 /// group, like an index block before its data blocks, so it is pinned at
824 /// the same priority.
825 #[cfg(feature = "columnar")]
826 pub(crate) fn insert_directory(
827 &self,
828 id: GlobalTableId,
829 offset: BlockOffset,
830 directory: alloc::sync::Arc<crate::table::column_page::PageDirectory>,
831 len: u32,
832 ) {
833 let priority = if self.metadata_priority {
834 Priority::High
835 } else {
836 Priority::Normal
837 };
838 self.data.insert_with_priority(
839 (TAG_DIRECTORY, id.tree_id(), id.table_id(), *offset).into(),
840 Item::Directory(directory, len),
841 priority,
842 );
843 }
844
845 /// Whether a full (non-partial) data block is already resident for `offset`.
846 ///
847 /// The partial-tier reader uses this to bail out (let the normal cached path
848 /// serve) once a block has been promoted to a full resident block, and the
849 /// batched prewarm to tell a cold block from one it would re-read for
850 /// nothing. Neither wants the block itself, so this answers without cloning
851 /// it out and without counting a hit for a read that never happens.
852 #[doc(hidden)]
853 #[must_use]
854 pub fn has_block(&self, id: GlobalTableId, offset: BlockOffset) -> bool {
855 let key: CacheKey = (TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into();
856 self.data.contains(&key)
857 }
858
859 /// Reads the cached partial-tier entry for `offset` (resume state + access
860 /// stats), without mutating it. The caller checks coverage against its query,
861 /// applies the promotion heuristic, then re-inserts with bumped stats, grows
862 /// the extent, or promotes to a full block.
863 #[cfg(feature = "zstd")]
864 #[doc(hidden)]
865 #[must_use]
866 pub fn peek_partial_block(
867 &self,
868 id: GlobalTableId,
869 offset: BlockOffset,
870 ) -> Option<PartialBlockEntry> {
871 let key: CacheKey = (TAG_PARTIAL_BLOCK, id.tree_id(), id.table_id(), *offset).into();
872 match self.data.peek(&key) {
873 Some(Item::PartialBlock(entry)) => Some(entry),
874 _ => None,
875 }
876 }
877
878 /// Records the partial-tier entry for `offset`, never letting its decoded
879 /// prefix shrink: when the resident entry already decoded more inner
880 /// blocks (a concurrent read grew it further), that one stays, with the
881 /// higher of the two hit counts. A read that served from an older copy of
882 /// the entry, or grew it less, can therefore not undo another read's
883 /// growth.
884 #[cfg(feature = "zstd")]
885 #[doc(hidden)]
886 pub fn insert_partial_block(
887 &self,
888 id: GlobalTableId,
889 offset: BlockOffset,
890 entry: PartialBlockEntry,
891 ) {
892 self.data.insert_merged(
893 (TAG_PARTIAL_BLOCK, id.tree_id(), id.table_id(), *offset).into(),
894 |resident| match resident {
895 Some(Item::PartialBlock(resident))
896 if resident.resume.decoded_blocks > entry.resume.decoded_blocks =>
897 {
898 Some(Item::PartialBlock(PartialBlockEntry {
899 hits: resident.hits.max(entry.hits),
900 ..resident.clone()
901 }))
902 }
903 Some(Item::PartialBlock(resident)) => Some(Item::PartialBlock(PartialBlockEntry {
904 hits: resident.hits.max(entry.hits),
905 ..entry
906 })),
907 _ => Some(Item::PartialBlock(entry)),
908 },
909 );
910 }
911
912 /// Claims the growth of the partially decoded prefix of the block at
913 /// `offset`, so two reads that both need it longer decode the new inner
914 /// blocks once: the second waits for the first, then finds the longer
915 /// prefix cached.
916 #[cfg(feature = "zstd")]
917 pub(crate) fn claim_partial_growth(
918 &self,
919 id: GlobalTableId,
920 offset: BlockOffset,
921 ) -> GrowthClaim<'_> {
922 let key: CacheKey = (TAG_PARTIAL_BLOCK, id.tree_id(), id.table_id(), *offset).into();
923 match self.loads.claim(key, LoadIdentity::PartialGrowth) {
924 crate::inflight::Claim::Lead(lead) => GrowthClaim::Lead(GrowthLead(lead)),
925 crate::inflight::Claim::Join(join) if crate::inflight::may_wait() => {
926 let _ = join.wait();
927 GrowthClaim::Waited
928 }
929 crate::inflight::Claim::Join(_) | crate::inflight::Claim::Alone => GrowthClaim::Alone,
930 }
931 }
932
933 /// Drops the partial-tier entry for `offset` (used on promotion to a full
934 /// resident block, so the stale partial does not linger).
935 #[cfg(feature = "zstd")]
936 #[doc(hidden)]
937 pub fn evict_partial_block(&self, id: GlobalTableId, offset: BlockOffset) {
938 let key: CacheKey = (TAG_PARTIAL_BLOCK, id.tree_id(), id.table_id(), *offset).into();
939 self.data.remove(&key);
940 }
941
942 #[doc(hidden)]
943 pub fn insert_block(&self, id: GlobalTableId, offset: BlockOffset, block: Block) {
944 // Pin index / filter / range-tombstone blocks: they are touched on every
945 // seek (index descent + bloom check), so under data-block churn (working
946 // set >> cache) they must outlive the data blocks that would otherwise
947 // evict them and force a metadata re-read + re-decode on the next seek.
948 let priority = if self.metadata_priority
949 && matches!(
950 block.header.block_type,
951 BlockType::Index | BlockType::Filter | BlockType::RangeTombstone
952 ) {
953 Priority::High
954 } else {
955 Priority::Normal
956 };
957 self.data.insert_with_priority(
958 (TAG_BLOCK, id.tree_id(), id.table_id(), *offset).into(),
959 Item::Block(block),
960 priority,
961 );
962 }
963
964 /// Looks up the cached point-read result for `user_key` in SST `id`. The
965 /// stored key is verified against `user_key` so a hash collision on the
966 /// cache slot is rejected (returns `None`) rather than serving a wrong value.
967 /// `key_hash` is the same hash the bloom filter uses, so the caller passes
968 /// the value it already computed.
969 #[doc(hidden)]
970 #[must_use]
971 pub fn get_row(
972 &self,
973 id: GlobalTableId,
974 key_hash: u64,
975 user_key: &[u8],
976 ) -> Option<InternalValue> {
977 if !self.row_cache_enabled {
978 return None;
979 }
980 let key: CacheKey = (TAG_ROW, id.tree_id(), id.table_id(), key_hash).into();
981 match self.data.get(&key)? {
982 Item::Row(iv) if crate::comparator::same_user_key(&iv.key.user_key, user_key) => {
983 Some(iv)
984 }
985 // Hash collision (a different key hashed to this slot) or a foreign
986 // item kind: treat as a miss so the caller does the real lookup.
987 _ => None,
988 }
989 }
990
991 /// Caches the resolved point-read result (`user_key`, `seqno`,
992 /// `value_type`, `value`) for SST `id`, keyed by `key_hash`, and returns
993 /// the bytes it copied to own the row (zero when the row cache is off or
994 /// the row is too heavy to admit). Only a newest-version result (from a
995 /// latest-version read) should be inserted, so the seqno-visibility check
996 /// on lookup stays correct. SSTs are immutable, so an entry stays valid
997 /// until its SST is compacted away (after which its `table_id` is never
998 /// read again and the entry ages out of the cache).
999 #[doc(hidden)]
1000 #[must_use = "the bytes copied belong to the reading caller's gather count"]
1001 pub fn insert_row(
1002 &self,
1003 id: GlobalTableId,
1004 key_hash: u64,
1005 user_key: &[u8],
1006 seqno: crate::SeqNo,
1007 value_type: crate::ValueType,
1008 value: &[u8],
1009 ) -> usize {
1010 if !self.row_cache_enabled {
1011 return 0;
1012 }
1013 // The point-read path hands over a key and value that may be SUBSLICES
1014 // of the decoded data block, and a subslice keeps the whole block
1015 // allocation alive. The weigher charges a row its own key and value
1016 // bytes only, so a 100-byte row viewing a 4 KiB block would be
1017 // accounted as 100 bytes while holding 4096 — and a workload touching
1018 // one key per block, the one a row cache helps least, would overrun the
1019 // requested capacity by that ratio. Copying both out costs small
1020 // allocations per MISS, on a path that has just walked the index and
1021 // decoded a block, and it makes the charge equal to what is actually
1022 // retained.
1023 //
1024 // Unless the row cannot be admitted at all. A row heavier than one shard
1025 // is refused by the insert below, and a value that big is re-read as
1026 // often as any other, so copying it first would burn an allocation and a
1027 // memcpy on every one of those reads to produce something immediately
1028 // discarded. The copy does not change the weight, so projecting it here
1029 // is exact.
1030 if row_weight(user_key.len(), value.len()) > self.data.max_entry_weight() {
1031 return 0;
1032 }
1033 let iv = InternalValue {
1034 key: crate::key::InternalKey::new(crate::UserKey::from(user_key), seqno, value_type),
1035 value: crate::UserValue::from(value),
1036 };
1037 self.data.insert(
1038 (TAG_ROW, id.tree_id(), id.table_id(), key_hash).into(),
1039 Item::Row(iv),
1040 );
1041 user_key.len() + value.len()
1042 }
1043
1044 /// Caches a separated value under its position, together with the two
1045 /// fields the position does not carry (`user_key` and the handle's declared
1046 /// on-disk size) so a lookup can prove the entry belongs to the handle it
1047 /// is asked about. Returns the bytes copied to own the entry's key.
1048 #[doc(hidden)]
1049 #[must_use = "the bytes copied belong to the reading caller's gather count"]
1050 pub fn insert_blob(
1051 &self,
1052 vlog_id: crate::TreeId,
1053 vhandle: &crate::vlog::ValueHandle,
1054 user_key: &[u8],
1055 value: UserValue,
1056 ) -> usize {
1057 self.data.insert(
1058 (TAG_BLOB, vlog_id, vhandle.blob_file_id, vhandle.offset).into(),
1059 Item::Blob(user_key.into(), vhandle.on_disk_size, value),
1060 );
1061 user_key.len()
1062 }
1063
1064 /// Whether a separated value is already cached.
1065 ///
1066 /// For a caller deciding whether it has to read, not what the value is:
1067 /// [`get_blob`](Self::get_blob) would clone the value out (an atomic bump
1068 /// on the refcounted payload) and count a cache hit for a read that never
1069 /// happens, skewing the eviction policy toward entries nobody consumed.
1070 #[doc(hidden)]
1071 #[must_use]
1072 pub fn contains_blob(
1073 &self,
1074 vlog_id: crate::TreeId,
1075 vhandle: &crate::vlog::ValueHandle,
1076 ) -> bool {
1077 let key: CacheKey = (TAG_BLOB, vlog_id, vhandle.blob_file_id, vhandle.offset).into();
1078 self.data.contains(&key)
1079 }
1080
1081 /// The cached value for `vhandle`, but only if it belongs to that exact
1082 /// handle: same `user_key` and same declared on-disk size.
1083 ///
1084 /// The cache key is a POSITION in a blob file, and a corrupt index entry
1085 /// can name that position with a different key, or with the right key and
1086 /// the wrong size. The reader rejects both on a direct read, comparing what
1087 /// it was asked for against the record header; these comparisons are how
1088 /// the cached path reaches the same verdict instead of serving whatever
1089 /// sits at the offset. A mismatch reads as a miss, so the caller does the
1090 /// real read and gets the real error.
1091 #[doc(hidden)]
1092 #[must_use]
1093 pub fn get_blob(
1094 &self,
1095 vlog_id: crate::TreeId,
1096 vhandle: &crate::vlog::ValueHandle,
1097 user_key: &[u8],
1098 ) -> Option<UserValue> {
1099 let key: CacheKey = (TAG_BLOB, vlog_id, vhandle.blob_file_id, vhandle.offset).into();
1100
1101 match self.data.get(&key)? {
1102 Item::Blob(cached_key, cached_size, blob)
1103 if &*cached_key == user_key && cached_size == vhandle.on_disk_size =>
1104 {
1105 Some(blob)
1106 }
1107 Item::Blob(..) => None,
1108 Item::Block(_) | Item::Row(_) => unreachable!("invalid cache item"),
1109 #[cfg(feature = "zstd")]
1110 Item::PartialBlock(_) => unreachable!("invalid cache item"),
1111 #[cfg(feature = "columnar")]
1112 Item::Directory(..) => unreachable!("invalid cache item"),
1113 }
1114 }
1115}
1116
1117#[cfg(test)]
1118#[expect(clippy::expect_used, reason = "test code")]
1119mod tests;
1120
1121#[cfg(test)]
1122#[expect(clippy::expect_used, reason = "test code")]
1123mod coalesce_tests;