Skip to main content

reddb_server/storage/engine/
page_cache.rs

1//! SIEVE Page Cache Implementation
2//!
3//! A simple, efficient page cache using the SIEVE eviction algorithm.
4//! SIEVE outperforms LRU in many workloads while being simpler to implement.
5//!
6//! # SIEVE Algorithm (NSDI '24)
7//!
8//! SIEVE uses a single "visited" bit instead of LRU's complex list management:
9//! 1. On cache hit: set visited = true
10//! 2. On cache miss (insertion): add to FIFO queue
11//! 3. On eviction: sweep from "hand" position
12//!    - If visited=true: clear bit, skip
13//!    - If visited=false: evict this entry
14//!
15//! # References
16//!
17//! - Turso `core/storage/page_cache.rs:24-80` - PageCacheShardEntry with ref_bit
18//! - Turso `core/storage/page_cache.rs:129-150` - advance_clock_hand()
19//! - "SIEVE is Simpler than LRU" (NSDI '24)
20
21use std::collections::{HashMap, VecDeque};
22use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
23use std::sync::{Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard};
24
25use super::page::Page;
26
27fn cache_read<'a, T>(lock: &'a RwLock<T>) -> RwLockReadGuard<'a, T> {
28    lock.read().unwrap_or_else(|poisoned| poisoned.into_inner())
29}
30
31fn cache_write<'a, T>(lock: &'a RwLock<T>) -> RwLockWriteGuard<'a, T> {
32    lock.write()
33        .unwrap_or_else(|poisoned| poisoned.into_inner())
34}
35
36fn cache_lock<'a, T>(lock: &'a Mutex<T>) -> MutexGuard<'a, T> {
37    lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
38}
39
40/// Default cache capacity (number of pages)
41/// Turso uses 100,000 pages (~400MB for 4KB pages)
42pub const DEFAULT_CACHE_CAPACITY: usize = 100_000;
43
44/// Minimum cache capacity
45pub const MIN_CACHE_CAPACITY: usize = 2;
46
47/// Lock-protected cache entry.
48///
49/// Per ADR 0033 (`page-cache-lock-free-hit`), the SIEVE `visited` bit
50/// no longer lives here — it moved to [`ShardMeta`], a separate
51/// cache-line-packed atomic array, so a cache hit can flip it without
52/// upgrading the per-shard write lock. `pin_count` and `dirty` stay in
53/// the entry behind the `entries` `RwLock`: they are written only by
54/// structural operations (`pin`/`unpin`/`mark_dirty`/`mark_clean`,
55/// which take the write lock) and are *never* touched by the hit path.
56struct CacheEntry {
57    /// The cached page
58    page: Page,
59    /// Pin count (page cannot be evicted while pinned)
60    pin_count: usize,
61    /// Whether the page is dirty (modified)
62    dirty: bool,
63}
64
65impl CacheEntry {
66    fn new(page: Page) -> Self {
67        Self {
68            page,
69            pin_count: 0,
70            dirty: false,
71        }
72    }
73}
74
75/// Cache line size in bytes (x86-64 / aarch64). The SIEVE `visited`
76/// bits and per-slot `tag`s are laid out in their own line-aligned,
77/// densely packed arrays (see [`ShardMeta`]) so the lock-free hit path
78/// — which only ever touches `visited`/`tag` — never shares a cache
79/// line with the lock-protected [`CacheEntry`] payload (page bytes,
80/// `pin_count`, `dirty`). ADR 0033.
81const CACHE_LINE: usize = 64;
82
83/// Sentinel `tag` value for an unoccupied slot. `u32::MAX` is reserved
84/// (no real page id uses it) so the hit path can tell, with a single
85/// relaxed load, whether the slot it resolved still belongs to the
86/// page it is marking.
87const TAG_EMPTY: u32 = u32::MAX;
88
89/// One cache line of packed `visited` bits: one [`AtomicBool`] (1 byte)
90/// per slot, 64 slots per line. `#[repr(align(64))]` forces the boxed
91/// backing buffer onto a line boundary, so the array starts aligned and
92/// no two unrelated lines ever false-share.
93#[repr(align(64))]
94struct VisitedLine([AtomicBool; CACHE_LINE]);
95
96/// Number of `AtomicU32` tags that fit in one cache line.
97const TAGS_PER_LINE: usize = CACHE_LINE / 4;
98
99/// One cache line of packed slot tags: one [`AtomicU32`] (4 bytes) per
100/// slot, 16 slots per line. Line-aligned for the same reason as
101/// [`VisitedLine`].
102#[repr(align(64))]
103struct TagLine([AtomicU32; TAGS_PER_LINE]);
104
105/// Cache-line-packed SIEVE metadata, split out of [`CacheEntry`].
106///
107/// Both arrays are indexed by slot and are *separate, line-aligned*
108/// allocations (ADR 0033 acceptance: "visited/tag metadata is laid out
109/// in separate cache-line-aligned arrays"). Because the elements are
110/// atomics, the hit path mutates `visited` through a shared `&self`
111/// with no lock at all — satisfying the contract's "cache hits set
112/// visited without acquiring the per-shard write lock".
113///
114/// The arrays are sized to the shard `capacity`. In normal operation a
115/// slot index never reaches `capacity` (`insert` evicts before growing
116/// `entries`), so every access lands in range. The one pathological
117/// exception — a full cache whose every slot is pinned, where `insert`
118/// grows `entries` past `capacity` — is handled by the bounds guards
119/// below: an out-of-range slot reports "not visited" / `TAG_EMPTY`,
120/// which makes that emergency over-capacity entry the first eviction
121/// candidate once a slot frees. No panic, no unbounded metadata.
122struct ShardMeta {
123    visited: Box<[VisitedLine]>,
124    tag: Box<[TagLine]>,
125    capacity: usize,
126}
127
128impl ShardMeta {
129    fn new(capacity: usize) -> Self {
130        let visited = (0..capacity.div_ceil(CACHE_LINE))
131            .map(|_| VisitedLine(std::array::from_fn(|_| AtomicBool::new(false))))
132            .collect::<Vec<_>>()
133            .into_boxed_slice();
134        let tag = (0..capacity.div_ceil(TAGS_PER_LINE))
135            .map(|_| TagLine(std::array::from_fn(|_| AtomicU32::new(TAG_EMPTY))))
136            .collect::<Vec<_>>()
137            .into_boxed_slice();
138        Self {
139            visited,
140            tag,
141            capacity,
142        }
143    }
144
145    /// Lock-free SIEVE hit mark. Benign-race by contract (ADR 0033): a
146    /// concurrent eviction clearing this same bit only costs one extra
147    /// SIEVE cycle. `Relaxed` is sufficient — the bit carries no
148    /// happens-before obligation toward any other state.
149    #[inline]
150    fn mark_visited(&self, slot: usize) {
151        if let Some(b) = self.visited_bit(slot) {
152            b.store(true, Ordering::Relaxed);
153        }
154    }
155
156    #[inline]
157    fn is_visited(&self, slot: usize) -> bool {
158        self.visited_bit(slot)
159            .map(|b| b.load(Ordering::Relaxed))
160            .unwrap_or(false)
161    }
162
163    /// Clear the visited bit. Structural (called from the eviction hand
164    /// under the per-shard locks), but the store itself is a relaxed
165    /// atomic so it composes with the lock-free hit mark.
166    #[inline]
167    fn clear_visited(&self, slot: usize) {
168        if let Some(b) = self.visited_bit(slot) {
169            b.store(false, Ordering::Relaxed);
170        }
171    }
172
173    /// The page id currently tagged at `slot`, or [`TAG_EMPTY`].
174    #[inline]
175    fn tag(&self, slot: usize) -> u32 {
176        self.tag_slot(slot)
177            .map(|t| t.load(Ordering::Relaxed))
178            .unwrap_or(TAG_EMPTY)
179    }
180
181    /// Bind a slot to a page and reset its visited bit. Structural:
182    /// the caller holds the per-shard write lock that serialises slot
183    /// ownership.
184    #[inline]
185    fn occupy(&self, slot: usize, page_id: u32) {
186        if let Some(t) = self.tag_slot(slot) {
187            t.store(page_id, Ordering::Relaxed);
188        }
189        self.clear_visited(slot);
190    }
191
192    /// Release a slot. Structural; caller holds the write lock.
193    #[inline]
194    fn vacate(&self, slot: usize) {
195        if let Some(t) = self.tag_slot(slot) {
196            t.store(TAG_EMPTY, Ordering::Relaxed);
197        }
198        self.clear_visited(slot);
199    }
200
201    /// Reset all metadata to the empty state (used by `clear`).
202    fn reset(&self) {
203        for line in self.visited.iter() {
204            for b in line.0.iter() {
205                b.store(false, Ordering::Relaxed);
206            }
207        }
208        for line in self.tag.iter() {
209            for t in line.0.iter() {
210                t.store(TAG_EMPTY, Ordering::Relaxed);
211            }
212        }
213    }
214
215    #[inline]
216    fn visited_bit(&self, slot: usize) -> Option<&AtomicBool> {
217        if slot >= self.capacity {
218            return None;
219        }
220        self.visited
221            .get(slot / CACHE_LINE)
222            .map(|line| &line.0[slot % CACHE_LINE])
223    }
224
225    #[inline]
226    fn tag_slot(&self, slot: usize) -> Option<&AtomicU32> {
227        if slot >= self.capacity {
228            return None;
229        }
230        self.tag
231            .get(slot / TAGS_PER_LINE)
232            .map(|line| &line.0[slot % TAGS_PER_LINE])
233    }
234}
235
236/// Cache statistics
237#[derive(Debug, Default, Clone)]
238pub struct CacheStats {
239    /// Number of cache hits
240    pub hits: u64,
241    /// Number of cache misses
242    pub misses: u64,
243    /// Number of evictions
244    pub evictions: u64,
245    /// Number of dirty page writebacks
246    pub writebacks: u64,
247}
248
249impl CacheStats {
250    /// Calculate hit rate (0.0 to 1.0)
251    pub fn hit_rate(&self) -> f64 {
252        let total = self.hits + self.misses;
253        if total == 0 {
254            0.0
255        } else {
256            self.hits as f64 / total as f64
257        }
258    }
259}
260
261/// SIEVE-based page cache
262///
263/// Thread-safe page cache using the SIEVE eviction algorithm.
264pub struct PageCacheShard {
265    /// Maximum number of pages to cache
266    capacity: usize,
267    /// Page ID -> Entry index mapping
268    index: RwLock<HashMap<u32, usize>>,
269    /// FIFO queue of page IDs for eviction order
270    fifo: Mutex<VecDeque<u32>>,
271    /// Cache entries (indexed by slot)
272    entries: RwLock<Vec<Option<CacheEntry>>>,
273    /// Cache-line-packed SIEVE metadata (`visited` bits + slot `tag`s),
274    /// kept out of `entries` so the hit path can mark `visited` with a
275    /// lock-free relaxed atomic store. ADR 0033.
276    meta: ShardMeta,
277    /// Free slots
278    free_slots: Mutex<Vec<usize>>,
279    /// SIEVE eviction hand position
280    hand: Mutex<usize>,
281    /// Cache statistics
282    stats: Mutex<CacheStats>,
283}
284
285impl PageCacheShard {
286    /// Create a new page cache with specified capacity
287    pub fn new(capacity: usize) -> Self {
288        let capacity = capacity.max(MIN_CACHE_CAPACITY);
289
290        Self {
291            capacity,
292            index: RwLock::new(HashMap::with_capacity(capacity)),
293            fifo: Mutex::new(VecDeque::with_capacity(capacity)),
294            entries: RwLock::new(Vec::with_capacity(capacity)),
295            meta: ShardMeta::new(capacity),
296            free_slots: Mutex::new(Vec::new()),
297            hand: Mutex::new(0),
298            stats: Mutex::new(CacheStats::default()),
299        }
300    }
301
302    /// Create a page cache with default capacity
303    pub fn with_default_capacity() -> Self {
304        Self::new(DEFAULT_CACHE_CAPACITY)
305    }
306
307    /// Get the current number of cached pages
308    pub fn len(&self) -> usize {
309        cache_read(&self.index).len()
310    }
311
312    /// Check if cache is empty
313    pub fn is_empty(&self) -> bool {
314        self.len() == 0
315    }
316
317    /// Get cache capacity
318    pub fn capacity(&self) -> usize {
319        self.capacity
320    }
321
322    /// Get cache statistics
323    pub fn stats(&self) -> CacheStats {
324        cache_lock(&self.stats).clone()
325    }
326
327    /// Reset statistics
328    pub fn reset_stats(&self) {
329        *cache_lock(&self.stats) = CacheStats::default();
330    }
331
332    /// Get a page from cache
333    ///
334    /// Returns None if page is not in cache (cache miss).
335    /// On hit, marks the page as visited (SIEVE).
336    pub fn get(&self, page_id: u32) -> Option<Page> {
337        // Check if page is in cache
338        let index = cache_read(&self.index);
339        let slot = match index.get(&page_id) {
340            Some(&s) => s,
341            None => {
342                drop(index);
343                // Cache miss
344                cache_lock(&self.stats).misses += 1;
345                return None;
346            }
347        };
348        drop(index);
349
350        // Lock-free hit path (ADR 0033). Clone the page under a *read*
351        // lock — never a write lock — then set the SIEVE `visited` bit
352        // with a single relaxed atomic store on the separate
353        // cache-line-packed metadata array. The hit path acquires no
354        // write lock and never touches `pin_count`/`dirty` (those live
355        // in `CacheEntry` behind the write lock and are mutated only by
356        // structural operations). Dropping the per-hit write-lock
357        // upgrade is the whole point: hot-loop reads of an
358        // already-cached page (e.g. BTree inserts hammering the
359        // rightmost leaf) no longer serialise on the entries writer.
360        let entries = cache_read(&self.entries);
361        if let Some(entry) = entries.get(slot).and_then(|e| e.as_ref()) {
362            let page = entry.page.clone();
363            drop(entries);
364
365            // Mark visited lock-free. Confirm the slot still tags this
366            // page id first: if a racing eviction repurposed the slot
367            // we simply skip the mark (benign — at worst one extra
368            // SIEVE cycle, never a wrong-page write). The tag check
369            // never changes hit/miss accounting; the entry existed, so
370            // this is a hit.
371            if self.meta.tag(slot) == page_id {
372                self.meta.mark_visited(slot);
373            }
374
375            cache_lock(&self.stats).hits += 1;
376
377            Some(page)
378        } else {
379            cache_lock(&self.stats).misses += 1;
380            None
381        }
382    }
383
384    /// Insert a page into cache
385    ///
386    /// May trigger eviction if cache is full.
387    /// Returns the evicted page (if dirty) that needs to be written back.
388    pub fn insert(&self, page_id: u32, page: Page) -> Option<Page> {
389        // Check if already in cache
390        {
391            let index = cache_read(&self.index);
392            if let Some(&slot) = index.get(&page_id) {
393                drop(index);
394
395                // Update existing entry under the write lock; the
396                // SIEVE visited bit lives in the separate metadata
397                // array and is set lock-free (matches the prior
398                // behaviour of marking a re-inserted page visited).
399                let mut entries = cache_write(&self.entries);
400                if let Some(Some(entry)) = entries.get_mut(slot) {
401                    entry.page = page;
402                }
403                drop(entries);
404                self.meta.mark_visited(slot);
405                return None;
406            }
407        }
408
409        // Need to insert new entry
410        let mut evicted = None;
411
412        // Check if we need to evict before getting free_slots lock
413        let current_len = self.len();
414        if current_len >= self.capacity {
415            // Need to evict first (no locks held)
416            evicted = self.evict();
417        }
418
419        // Find or create a slot
420        let slot = {
421            let mut free_slots = cache_lock(&self.free_slots);
422            if let Some(slot) = free_slots.pop() {
423                slot
424            } else {
425                drop(free_slots);
426                // Add a new slot
427                let mut entries = cache_write(&self.entries);
428                let slot = entries.len();
429                entries.push(None);
430                slot
431            }
432        };
433
434        // Insert entry
435        {
436            let mut entries = cache_write(&self.entries);
437
438            // Ensure slot exists
439            while entries.len() <= slot {
440                entries.push(None);
441            }
442
443            entries[slot] = Some(CacheEntry::new(page));
444        }
445
446        // Update index
447        {
448            let mut index = cache_write(&self.index);
449            index.insert(page_id, slot);
450        }
451
452        // Bind the metadata slot to this page (tag = page_id, visited
453        // reset). Structural: ordered after the entry/index writes that
454        // the per-shard write lock serialises.
455        self.meta.occupy(slot, page_id);
456
457        // Add to FIFO
458        {
459            let mut fifo = cache_lock(&self.fifo);
460            fifo.push_back(page_id);
461        }
462
463        evicted
464    }
465
466    /// Evict a page using SIEVE algorithm
467    ///
468    /// Returns the evicted page if it was dirty.
469    fn evict(&self) -> Option<Page> {
470        let mut fifo = cache_lock(&self.fifo);
471        let mut hand = cache_lock(&self.hand);
472
473        if fifo.is_empty() {
474            return None;
475        }
476
477        let fifo_len = fifo.len();
478        let mut attempts = 0;
479
480        // Sweep from hand position
481        loop {
482            if attempts >= fifo_len * 2 {
483                // Couldn't find anything to evict (all pinned?)
484                return None;
485            }
486
487            // Wrap around
488            if *hand >= fifo_len {
489                *hand = 0;
490            }
491
492            let page_id = fifo[*hand];
493            attempts += 1;
494
495            // Get entry slot
496            let slot = {
497                let index = cache_read(&self.index);
498                match index.get(&page_id) {
499                    Some(&s) => s,
500                    None => {
501                        // Page was removed, skip
502                        *hand += 1;
503                        continue;
504                    }
505                }
506            };
507
508            // Check entry. `pin_count`/`dirty` are read under the
509            // entries lock; the SIEVE `visited` bit is read from the
510            // separate atomic metadata array.
511            let (should_evict, dirty) = {
512                let entries = cache_read(&self.entries);
513                match entries.get(slot).and_then(|e| e.as_ref()) {
514                    Some(entry) => {
515                        if entry.pin_count > 0 {
516                            // Pinned, can't evict
517                            (false, false)
518                        } else if self.meta.is_visited(slot) {
519                            // Clear visited bit, skip (SIEVE)
520                            (false, false)
521                        } else {
522                            // Can evict
523                            (true, entry.dirty)
524                        }
525                    }
526                    None => {
527                        *hand += 1;
528                        continue;
529                    }
530                }
531            };
532
533            if !should_evict {
534                // Clear visited bit lock-free (no entries write lock
535                // needed — `visited` is no longer part of `CacheEntry`).
536                self.meta.clear_visited(slot);
537                *hand += 1;
538                continue;
539            }
540
541            // Evict this entry. Contract guard (ADR 0033): the eviction
542            // hand must never select a pinned page. We re-check under
543            // the write lock — the only place `pin_count` is read for
544            // the eviction decision and the place it must hold.
545            let evicted_page = {
546                let mut entries = cache_write(&self.entries);
547                debug_assert!(
548                    entries
549                        .get(slot)
550                        .and_then(|e| e.as_ref())
551                        .map(|e| e.pin_count == 0)
552                        .unwrap_or(true),
553                    "ADR 0033 violation: eviction selected a pinned page (slot {slot})"
554                );
555                let entry = entries[slot].take();
556                entry.map(|e| e.page)
557            };
558
559            // Release the metadata slot (tag -> empty, visited -> false).
560            self.meta.vacate(slot);
561
562            // Remove from index
563            {
564                let mut index = cache_write(&self.index);
565                index.remove(&page_id);
566            }
567
568            // Remove from FIFO
569            fifo.remove(*hand);
570
571            // Add slot to free list
572            {
573                let mut free_slots = cache_lock(&self.free_slots);
574                free_slots.push(slot);
575            }
576
577            // Update stats
578            {
579                let mut stats = cache_lock(&self.stats);
580                stats.evictions += 1;
581                if dirty {
582                    stats.writebacks += 1;
583                }
584            }
585
586            // Return evicted page if dirty
587            if dirty {
588                return evicted_page;
589            } else {
590                return None;
591            }
592        }
593    }
594
595    /// Mark a page as dirty
596    pub fn mark_dirty(&self, page_id: u32) {
597        let index = cache_read(&self.index);
598        if let Some(&slot) = index.get(&page_id) {
599            drop(index);
600
601            let mut entries = cache_write(&self.entries);
602            if let Some(Some(entry)) = entries.get_mut(slot) {
603                entry.dirty = true;
604            }
605        }
606    }
607
608    /// Mark a page as clean
609    pub fn mark_clean(&self, page_id: u32) {
610        let index = cache_read(&self.index);
611        if let Some(&slot) = index.get(&page_id) {
612            drop(index);
613
614            let mut entries = cache_write(&self.entries);
615            if let Some(Some(entry)) = entries.get_mut(slot) {
616                entry.dirty = false;
617            }
618        }
619    }
620
621    /// Pin a page (prevent eviction)
622    pub fn pin(&self, page_id: u32) -> bool {
623        let index = cache_read(&self.index);
624        if let Some(&slot) = index.get(&page_id) {
625            drop(index);
626
627            let mut entries = cache_write(&self.entries);
628            if let Some(Some(entry)) = entries.get_mut(slot) {
629                entry.pin_count += 1;
630                return true;
631            }
632        }
633        false
634    }
635
636    /// Unpin a page
637    pub fn unpin(&self, page_id: u32) -> bool {
638        let index = cache_read(&self.index);
639        if let Some(&slot) = index.get(&page_id) {
640            drop(index);
641
642            let mut entries = cache_write(&self.entries);
643            if let Some(Some(entry)) = entries.get_mut(slot) {
644                if entry.pin_count > 0 {
645                    entry.pin_count -= 1;
646                    return true;
647                }
648            }
649        }
650        false
651    }
652
653    /// Remove a page from cache
654    pub fn remove(&self, page_id: u32) -> Option<Page> {
655        // Get and remove from index
656        let slot = {
657            let mut index = cache_write(&self.index);
658            index.remove(&page_id)?
659        };
660
661        // Remove entry
662        let entry = {
663            let mut entries = cache_write(&self.entries);
664            entries.get_mut(slot).and_then(|e| e.take())
665        };
666
667        // Release the metadata slot.
668        self.meta.vacate(slot);
669
670        // Remove from FIFO
671        {
672            let mut fifo = cache_lock(&self.fifo);
673            fifo.retain(|&id| id != page_id);
674        }
675
676        // Add slot to free list
677        {
678            let mut free_slots = cache_lock(&self.free_slots);
679            free_slots.push(slot);
680        }
681
682        entry.map(|e| e.page)
683    }
684
685    /// Flush all dirty pages
686    ///
687    /// Returns an iterator of (page_id, page) for dirty pages.
688    pub fn flush_dirty(&self) -> Vec<(u32, Page)> {
689        let mut dirty_pages = Vec::new();
690
691        let index = cache_read(&self.index);
692        let entries = cache_read(&self.entries);
693
694        for (&page_id, &slot) in index.iter() {
695            if let Some(Some(entry)) = entries.get(slot) {
696                if entry.dirty {
697                    dirty_pages.push((page_id, entry.page.clone()));
698                }
699            }
700        }
701
702        drop(entries);
703        drop(index);
704
705        // Mark all as clean
706        for (page_id, _) in &dirty_pages {
707            self.mark_clean(*page_id);
708        }
709
710        let count = dirty_pages.len();
711        cache_lock(&self.stats).writebacks += count as u64;
712
713        dirty_pages
714    }
715
716    /// Bounded counterpart of `flush_dirty` used by the background
717    /// writer. Snapshots up to `max` dirty pages, marks them clean,
718    /// and returns the (page_id, page) pairs for the caller to
719    /// persist via the pager. Clamps to the current dirty set —
720    /// returning fewer than `max` simply means we're caught up.
721    pub fn flush_some_dirty(&self, max: usize) -> Vec<(u32, Page)> {
722        if max == 0 {
723            return Vec::new();
724        }
725        let mut dirty_pages = Vec::with_capacity(max);
726
727        let index = cache_read(&self.index);
728        let entries = cache_read(&self.entries);
729
730        for (&page_id, &slot) in index.iter() {
731            if dirty_pages.len() >= max {
732                break;
733            }
734            if let Some(Some(entry)) = entries.get(slot) {
735                if entry.dirty {
736                    dirty_pages.push((page_id, entry.page.clone()));
737                }
738            }
739        }
740
741        drop(entries);
742        drop(index);
743
744        for (page_id, _) in &dirty_pages {
745            self.mark_clean(*page_id);
746        }
747
748        let count = dirty_pages.len();
749        cache_lock(&self.stats).writebacks += count as u64;
750
751        dirty_pages
752    }
753
754    /// Count dirty pages currently in the cache. Used by the
755    /// background writer to compute an adaptive flush budget.
756    pub fn dirty_count(&self) -> usize {
757        let index = cache_read(&self.index);
758        let entries = cache_read(&self.entries);
759        let mut count = 0;
760        for (_, &slot) in index.iter() {
761            if let Some(Some(entry)) = entries.get(slot) {
762                if entry.dirty {
763                    count += 1;
764                }
765            }
766        }
767        count
768    }
769
770    /// Clear all entries from cache
771    pub fn clear(&self) {
772        let mut index = cache_write(&self.index);
773        let mut entries = cache_write(&self.entries);
774        let mut fifo = cache_lock(&self.fifo);
775        let mut free_slots = cache_lock(&self.free_slots);
776
777        index.clear();
778        entries.clear();
779        fifo.clear();
780        free_slots.clear();
781        self.meta.reset();
782        *cache_lock(&self.hand) = 0;
783    }
784
785    /// Check if a page is in cache
786    pub fn contains(&self, page_id: u32) -> bool {
787        cache_read(&self.index).contains_key(&page_id)
788    }
789
790    /// Get all cached page IDs
791    pub fn page_ids(&self) -> Vec<u32> {
792        cache_read(&self.index).keys().copied().collect()
793    }
794}
795
796impl Default for PageCacheShard {
797    fn default() -> Self {
798        Self::with_default_capacity()
799    }
800}
801
802/// Number of independent `PageCacheShard`s inside a `PageCache`. Must
803/// be a power of two so `page_id & (NUM_SHARDS - 1)` is a valid shard
804/// index. 8 was picked to cover typical bench concurrency (16 workers)
805/// without making each shard's own SIEVE queue too small; bumping to
806/// 16 is safe if profiling ever shows shard-level contention.
807const NUM_SHARDS: usize = 8;
808
809/// Sharded page cache.
810///
811/// Routes each `page_id` to one of `NUM_SHARDS` independent
812/// [`PageCacheShard`]s by the low bits of the id. Readers and writers
813/// for disjoint pages hit different shards and do not contend on the
814/// shard-internal RwLocks/Mutexes. Each shard runs its own SIEVE
815/// eviction loop over `capacity / NUM_SHARDS` slots; hot/cold
816/// asymmetry across shards is accepted in exchange for the
817/// contention win.
818pub struct PageCache {
819    shards: Box<[PageCacheShard]>,
820    capacity: usize,
821}
822
823impl PageCache {
824    pub fn new(capacity: usize) -> Self {
825        // Keep every shard above MIN_CACHE_CAPACITY so the inner
826        // SIEVE invariants stay valid even when callers pass tiny
827        // totals (tests frequently do).
828        let per_shard = capacity.div_ceil(NUM_SHARDS).max(MIN_CACHE_CAPACITY);
829        let total = per_shard * NUM_SHARDS;
830        let shards: Vec<PageCacheShard> = (0..NUM_SHARDS)
831            .map(|_| PageCacheShard::new(per_shard))
832            .collect();
833        Self {
834            shards: shards.into_boxed_slice(),
835            capacity: total,
836        }
837    }
838
839    pub fn with_default_capacity() -> Self {
840        Self::new(DEFAULT_CACHE_CAPACITY)
841    }
842
843    #[inline]
844    fn shard_for(&self, page_id: u32) -> &PageCacheShard {
845        &self.shards[(page_id as usize) & (NUM_SHARDS - 1)]
846    }
847
848    pub fn len(&self) -> usize {
849        self.shards.iter().map(|s| s.len()).sum()
850    }
851
852    pub fn is_empty(&self) -> bool {
853        self.shards.iter().all(|s| s.is_empty())
854    }
855
856    pub fn capacity(&self) -> usize {
857        self.capacity
858    }
859
860    pub fn stats(&self) -> CacheStats {
861        let mut agg = CacheStats::default();
862        for s in self.shards.iter() {
863            let cs = s.stats();
864            agg.hits += cs.hits;
865            agg.misses += cs.misses;
866            agg.evictions += cs.evictions;
867            agg.writebacks += cs.writebacks;
868        }
869        agg
870    }
871
872    pub fn reset_stats(&self) {
873        for s in self.shards.iter() {
874            s.reset_stats();
875        }
876    }
877
878    pub fn get(&self, page_id: u32) -> Option<Page> {
879        self.shard_for(page_id).get(page_id)
880    }
881
882    pub fn insert(&self, page_id: u32, page: Page) -> Option<Page> {
883        self.shard_for(page_id).insert(page_id, page)
884    }
885
886    pub fn mark_dirty(&self, page_id: u32) {
887        self.shard_for(page_id).mark_dirty(page_id)
888    }
889
890    pub fn mark_clean(&self, page_id: u32) {
891        self.shard_for(page_id).mark_clean(page_id)
892    }
893
894    pub fn pin(&self, page_id: u32) -> bool {
895        self.shard_for(page_id).pin(page_id)
896    }
897
898    pub fn unpin(&self, page_id: u32) -> bool {
899        self.shard_for(page_id).unpin(page_id)
900    }
901
902    pub fn remove(&self, page_id: u32) -> Option<Page> {
903        self.shard_for(page_id).remove(page_id)
904    }
905
906    pub fn contains(&self, page_id: u32) -> bool {
907        self.shard_for(page_id).contains(page_id)
908    }
909
910    pub fn flush_dirty(&self) -> Vec<(u32, Page)> {
911        let mut out = Vec::new();
912        for s in self.shards.iter() {
913            out.extend(s.flush_dirty());
914        }
915        out
916    }
917
918    pub fn flush_some_dirty(&self, max: usize) -> Vec<(u32, Page)> {
919        if max == 0 {
920            return Vec::new();
921        }
922        let mut out = Vec::with_capacity(max);
923        for s in self.shards.iter() {
924            if out.len() >= max {
925                break;
926            }
927            let budget = max - out.len();
928            out.extend(s.flush_some_dirty(budget));
929        }
930        out
931    }
932
933    pub fn dirty_count(&self) -> usize {
934        self.shards.iter().map(|s| s.dirty_count()).sum()
935    }
936
937    pub fn clear(&self) {
938        for s in self.shards.iter() {
939            s.clear();
940        }
941    }
942
943    pub fn page_ids(&self) -> Vec<u32> {
944        let mut out = Vec::with_capacity(self.len());
945        for s in self.shards.iter() {
946            out.extend(s.page_ids());
947        }
948        out
949    }
950}
951
952impl Default for PageCache {
953    fn default() -> Self {
954        Self::with_default_capacity()
955    }
956}
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961    use crate::storage::engine::page::PageType;
962
963    fn make_page(id: u32) -> Page {
964        Page::new(PageType::BTreeLeaf, id)
965    }
966
967    #[test]
968    fn test_cache_basic() {
969        let cache = PageCacheShard::new(100);
970
971        assert!(cache.is_empty());
972        assert_eq!(cache.capacity(), 100);
973
974        // Insert
975        cache.insert(1, make_page(1));
976        assert_eq!(cache.len(), 1);
977        assert!(cache.contains(1));
978
979        // Get
980        let page = cache.get(1);
981        assert!(page.is_some());
982
983        // Miss
984        let page = cache.get(999);
985        assert!(page.is_none());
986    }
987
988    #[test]
989    fn test_cache_eviction() {
990        let cache = PageCacheShard::new(4);
991
992        // Fill cache
993        for i in 0..4 {
994            cache.insert(i, make_page(i));
995        }
996
997        assert_eq!(cache.len(), 4);
998
999        // Insert one more, should trigger eviction
1000        cache.insert(100, make_page(100));
1001
1002        // One of the original pages should be evicted
1003        assert_eq!(cache.len(), 4);
1004        assert!(cache.contains(100));
1005    }
1006
1007    #[test]
1008    fn test_cache_sieve_visited() {
1009        let cache = PageCacheShard::new(4);
1010
1011        // Fill cache
1012        for i in 0..4 {
1013            cache.insert(i, make_page(i));
1014        }
1015
1016        // Access page 0 (marks as visited)
1017        cache.get(0);
1018
1019        // Insert new page, should evict non-visited first
1020        cache.insert(100, make_page(100));
1021
1022        // Page 0 should still be there (was visited)
1023        assert!(cache.contains(0));
1024        assert!(cache.contains(100));
1025    }
1026
1027    #[test]
1028    fn test_cache_dirty() {
1029        let cache = PageCacheShard::new(100);
1030
1031        cache.insert(1, make_page(1));
1032        cache.mark_dirty(1);
1033
1034        let dirty = cache.flush_dirty();
1035        assert_eq!(dirty.len(), 1);
1036        assert_eq!(dirty[0].0, 1);
1037
1038        // Should be clean now
1039        let dirty = cache.flush_dirty();
1040        assert_eq!(dirty.len(), 0);
1041    }
1042
1043    #[test]
1044    fn test_cache_pin() {
1045        let cache = PageCacheShard::new(2);
1046
1047        cache.insert(1, make_page(1));
1048        cache.insert(2, make_page(2));
1049
1050        // Pin page 1
1051        assert!(cache.pin(1));
1052
1053        // Fill cache to trigger eviction
1054        cache.insert(3, make_page(3));
1055
1056        // Page 1 should still be there (pinned)
1057        assert!(cache.contains(1));
1058
1059        // Unpin
1060        assert!(cache.unpin(1));
1061    }
1062
1063    #[test]
1064    fn test_cache_remove() {
1065        let cache = PageCacheShard::new(100);
1066
1067        cache.insert(1, make_page(1));
1068        assert!(cache.contains(1));
1069
1070        let removed = cache.remove(1);
1071        assert!(removed.is_some());
1072        assert!(!cache.contains(1));
1073    }
1074
1075    #[test]
1076    fn test_cache_stats() {
1077        let cache = PageCacheShard::new(100);
1078
1079        cache.insert(1, make_page(1));
1080
1081        // Hit
1082        cache.get(1);
1083        cache.get(1);
1084
1085        // Miss
1086        cache.get(999);
1087
1088        let stats = cache.stats();
1089        assert_eq!(stats.hits, 2);
1090        assert_eq!(stats.misses, 1);
1091        assert!((stats.hit_rate() - 0.666).abs() < 0.01);
1092    }
1093
1094    #[test]
1095    fn test_cache_clear() {
1096        let cache = PageCacheShard::new(100);
1097
1098        for i in 0..50 {
1099            cache.insert(i, make_page(i));
1100        }
1101
1102        assert_eq!(cache.len(), 50);
1103
1104        cache.clear();
1105        assert!(cache.is_empty());
1106    }
1107
1108    #[test]
1109    fn test_cache_update_existing() {
1110        let cache = PageCacheShard::new(100);
1111
1112        let mut page1 = make_page(1);
1113        page1.as_bytes_mut()[100] = 0xAA;
1114        cache.insert(1, page1);
1115
1116        let mut page1_updated = make_page(1);
1117        page1_updated.as_bytes_mut()[100] = 0xBB;
1118        cache.insert(1, page1_updated);
1119
1120        // Should still only have one entry
1121        assert_eq!(cache.len(), 1);
1122
1123        // Should have updated value
1124        let retrieved = cache.get(1).unwrap();
1125        assert_eq!(retrieved.as_bytes()[100], 0xBB);
1126    }
1127
1128    #[test]
1129    fn test_cache_recovers_after_index_lock_poisoning() {
1130        let cache = std::sync::Arc::new(PageCacheShard::new(8));
1131        let poison_target = std::sync::Arc::clone(&cache);
1132        let _ = std::thread::spawn(move || {
1133            let _guard = poison_target
1134                .index
1135                .write()
1136                .expect("index lock should be acquired");
1137            panic!("poison index lock");
1138        })
1139        .join();
1140
1141        cache.insert(1, make_page(1));
1142        assert!(cache.contains(1));
1143        assert!(cache.get(1).is_some());
1144    }
1145
1146    #[test]
1147    fn test_cache_recovers_after_stats_lock_poisoning() {
1148        let cache = std::sync::Arc::new(PageCacheShard::new(8));
1149        let poison_target = std::sync::Arc::clone(&cache);
1150        let _ = std::thread::spawn(move || {
1151            let _guard = poison_target
1152                .stats
1153                .lock()
1154                .expect("stats lock should be acquired");
1155            panic!("poison stats lock");
1156        })
1157        .join();
1158
1159        assert!(cache.get(999).is_none());
1160        assert_eq!(cache.stats().misses, 1);
1161        cache.reset_stats();
1162        assert_eq!(cache.stats().misses, 0);
1163    }
1164
1165    /// ADR 0033: the SIEVE `visited`/`tag` metadata lives in its own
1166    /// cache-line-packed, line-aligned arrays — separate from the
1167    /// lock-protected `CacheEntry`. Assert the line types are exactly
1168    /// one 64-byte line and 64-byte aligned (so the boxed backing
1169    /// buffers start on a line boundary and never false-share), and
1170    /// that `CacheEntry` no longer carries the visited bit.
1171    #[test]
1172    fn test_metadata_layout_is_cache_line_packed() {
1173        assert_eq!(std::mem::align_of::<VisitedLine>(), CACHE_LINE);
1174        assert_eq!(std::mem::size_of::<VisitedLine>(), CACHE_LINE);
1175        assert_eq!(std::mem::align_of::<TagLine>(), CACHE_LINE);
1176        assert_eq!(std::mem::size_of::<TagLine>(), CACHE_LINE);
1177
1178        // The metadata arrays are distinct allocations from `entries`.
1179        let meta = ShardMeta::new(200);
1180        // Densely packed: 64 visited bits per line, 16 tags per line.
1181        assert_eq!(meta.visited.len(), 200usize.div_ceil(64));
1182        assert_eq!(meta.tag.len(), 200usize.div_ceil(16));
1183
1184        // occupy/vacate round-trip through the tag array.
1185        meta.occupy(5, 42);
1186        assert_eq!(meta.tag(5), 42);
1187        assert!(!meta.is_visited(5));
1188        meta.mark_visited(5);
1189        assert!(meta.is_visited(5));
1190        meta.vacate(5);
1191        assert_eq!(meta.tag(5), TAG_EMPTY);
1192        assert!(!meta.is_visited(5));
1193    }
1194
1195    /// ADR 0033: a cache hit sets the SIEVE `visited` bit on the
1196    /// separate metadata array (no write-lock upgrade), and that bit
1197    /// drives eviction exactly as before — a hit page survives one
1198    /// eviction sweep while an un-hit page is evicted first.
1199    #[test]
1200    fn test_hit_sets_visited_and_preserves_sieve_policy() {
1201        let cache = PageCacheShard::new(4);
1202        for i in 0..4 {
1203            cache.insert(i, make_page(i));
1204        }
1205
1206        // Hit pages 0 and 1 (lock-free visited mark via metadata array).
1207        assert!(cache.get(0).is_some());
1208        assert!(cache.get(1).is_some());
1209
1210        // Insert two more: SIEVE evicts the un-hit 2 and 3 first.
1211        cache.insert(10, make_page(10));
1212        cache.insert(11, make_page(11));
1213
1214        assert!(cache.contains(0), "hit page 0 must survive");
1215        assert!(cache.contains(1), "hit page 1 must survive");
1216        assert!(cache.contains(10));
1217        assert!(cache.contains(11));
1218        assert!(!cache.contains(2));
1219        assert!(!cache.contains(3));
1220    }
1221
1222    /// ADR 0033 acceptance: under concurrent hits + eviction, no pinned
1223    /// page is ever evicted, and dirty pages are never silently lost —
1224    /// every dirty page that leaves the cache is returned for
1225    /// writeback. Stress/loom-style: many threads hammer hits while
1226    /// other threads drive insertions (and thus the eviction hand).
1227    #[test]
1228    fn test_concurrent_hits_never_evict_pinned_or_lose_dirty() {
1229        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1230        use std::sync::Arc;
1231
1232        const CAP: u32 = 64;
1233        const PINNED: u32 = 8; // pages 0..8 stay pinned for the whole run
1234        const DIRTY: u32 = 8; // pages 8..16 are dirty
1235        const HITTERS: usize = 6;
1236        const INSERTERS: usize = 4;
1237        const ROUNDS: usize = 4_000;
1238
1239        let cache = Arc::new(PageCacheShard::new(CAP as usize));
1240        for i in 0..CAP {
1241            cache.insert(i, make_page(i));
1242        }
1243        for p in 0..PINNED {
1244            assert!(cache.pin(p), "pin {p}");
1245        }
1246        for p in PINNED..PINNED + DIRTY {
1247            cache.mark_dirty(p);
1248        }
1249
1250        // Count dirty pages handed back by insert() so we can prove no
1251        // dirty data is silently dropped.
1252        let dirty_written_back = Arc::new(AtomicUsize::new(0));
1253        let stop = Arc::new(AtomicBool::new(false));
1254
1255        // Hitters: loop on the lock-free visited path until signalled.
1256        let mut hitters = Vec::with_capacity(HITTERS);
1257        for h in 0..HITTERS {
1258            let cache = Arc::clone(&cache);
1259            let stop = Arc::clone(&stop);
1260            hitters.push(std::thread::spawn(move || {
1261                let mut id = (h as u32) % CAP;
1262                while !stop.load(Ordering::Relaxed) {
1263                    let _ = cache.get(id);
1264                    id = (id + 1) % CAP;
1265                }
1266            }));
1267        }
1268
1269        // Inserters: bounded churn that drives the eviction hand.
1270        let mut inserters = Vec::with_capacity(INSERTERS);
1271        for w in 0..INSERTERS {
1272            let cache = Arc::clone(&cache);
1273            let dirty_written_back = Arc::clone(&dirty_written_back);
1274            inserters.push(std::thread::spawn(move || {
1275                let base = 1_000 + (w as u32) * 1_000_000;
1276                for i in 0..ROUNDS {
1277                    let id = base + i as u32;
1278                    if cache.insert(id, make_page(id)).is_some() {
1279                        // insert only returns Some for a dirty victim.
1280                        dirty_written_back.fetch_add(1, Ordering::Relaxed);
1281                    }
1282                    // Invariant checked mid-storm: pinned pages are
1283                    // never evicted.
1284                    for p in 0..PINNED {
1285                        assert!(
1286                            cache.contains(p),
1287                            "pinned page {p} was evicted under concurrency"
1288                        );
1289                    }
1290                }
1291            }));
1292        }
1293
1294        // Inserters finish their bounded work; then stop the hitters.
1295        for h in inserters {
1296            h.join()
1297                .expect("inserter thread panicked (invariant broke)");
1298        }
1299        stop.store(true, Ordering::Relaxed);
1300        for h in hitters {
1301            h.join().expect("hitter thread panicked (invariant broke)");
1302        }
1303
1304        // Pinned pages survived the entire storm.
1305        for p in 0..PINNED {
1306            assert!(cache.contains(p), "pinned page {p} missing after storm");
1307        }
1308
1309        // Dirty accounting: a dirty page still in the cache, plus any
1310        // dirty victims handed back for writeback, must conserve the
1311        // original dirty set — no dirty page silently vanished.
1312        let dirty_resident = (PINNED..PINNED + DIRTY)
1313            .filter(|&p| cache.contains(p))
1314            .count();
1315        assert!(
1316            dirty_resident + dirty_written_back.load(Ordering::Relaxed) >= DIRTY as usize,
1317            "dirty pages lost: resident={dirty_resident} written_back={}",
1318            dirty_written_back.load(Ordering::Relaxed)
1319        );
1320
1321        // Note: we deliberately do NOT assert `len <= CAP`. The
1322        // pre-existing fine-grained-lock `insert` checks length and
1323        // evicts in separate steps, so concurrent inserters can
1324        // transiently push a shard past capacity. ShardMeta's bounds
1325        // guards make that overflow safe (over-capacity slots report
1326        // "not visited" / TAG_EMPTY and drain first); enforcing a hard
1327        // cap is out of scope for this lock-free-hit change.
1328    }
1329
1330    /// Legacy single-lock baseline used as a regression check for the
1331    /// sharded `PageCache`. Mirrors the pre-shard cache shape: a single
1332    /// `RwLock<HashMap<u32, Page>>` so every mutation serializes on one
1333    /// writer. We only need the surface used by the concurrency test
1334    /// (`insert` / `get`); keeping it minimal avoids accidentally
1335    /// drifting toward the sharded design and silently flattening the
1336    /// regression signal.
1337    mod legacy_baseline {
1338        use super::Page;
1339        use std::collections::HashMap;
1340        use std::sync::RwLock;
1341
1342        pub struct LegacyPageCache {
1343            entries: RwLock<HashMap<u32, Page>>,
1344        }
1345
1346        impl LegacyPageCache {
1347            pub fn new(_capacity: usize) -> Self {
1348                Self {
1349                    entries: RwLock::new(HashMap::new()),
1350                }
1351            }
1352
1353            pub fn insert(&self, page_id: u32, page: Page) {
1354                let mut entries = self.entries.write().unwrap();
1355                entries.insert(page_id, page);
1356            }
1357
1358            pub fn get(&self, page_id: u32) -> Option<Page> {
1359                let entries = self.entries.read().unwrap();
1360                entries.get(&page_id).cloned()
1361            }
1362        }
1363    }
1364
1365    /// Workload shared by both the sharded and legacy runs of the
1366    /// concurrency property test below. Each worker churns through its
1367    /// own disjoint `page_id` range so any contention seen between
1368    /// workers is purely lock-induced, not data-induced.
1369    fn run_workload<F>(workers: usize, ops_per_worker: usize, run: F) -> std::time::Duration
1370    where
1371        F: Fn(u32, &Page) + Send + Sync + 'static + Clone,
1372    {
1373        use std::sync::Arc;
1374        use std::time::Instant;
1375
1376        let run = Arc::new(run);
1377        let start = Instant::now();
1378        let mut handles = Vec::with_capacity(workers);
1379        for w in 0..workers {
1380            let run = Arc::clone(&run);
1381            handles.push(std::thread::spawn(move || {
1382                let base = (w as u32) * 1_000_000;
1383                let page = make_page(0);
1384                for i in 0..ops_per_worker {
1385                    let id = base + (i as u32);
1386                    run(id, &page);
1387                }
1388            }));
1389        }
1390        for h in handles {
1391            h.join().unwrap();
1392        }
1393        start.elapsed()
1394    }
1395
1396    #[test]
1397    fn test_sharded_cache_scales_concurrently() {
1398        // Property: with disjoint page_id ranges, 10 workers should
1399        // beat 1 worker by a meaningful factor on the sharded cache,
1400        // and the sharded cache should beat the legacy single-lock
1401        // baseline at 10 workers (since the baseline serializes every
1402        // mutation through one global RwLock).
1403        //
1404        // We assert "sub-linear scaling" in a deliberately loose form
1405        // (parallel < 7x serial) so CI flakiness on busy runners
1406        // doesn't false-positive. The legacy comparison is the
1407        // stronger regression signal.
1408        use std::sync::Arc;
1409
1410        const WORKERS: usize = 10;
1411        const OPS: usize = 5_000;
1412        const CAPACITY: usize = 200_000;
1413
1414        // Sharded: 1 worker baseline.
1415        let sharded = Arc::new(PageCache::new(CAPACITY));
1416        let s1 = Arc::clone(&sharded);
1417        let sharded_serial = run_workload(1, OPS * WORKERS, move |id, page| {
1418            s1.insert(id, page.clone());
1419            let _ = s1.get(id);
1420        });
1421
1422        // Sharded: WORKERS parallel.
1423        let sharded = Arc::new(PageCache::new(CAPACITY));
1424        let s2 = Arc::clone(&sharded);
1425        let sharded_parallel = run_workload(WORKERS, OPS, move |id, page| {
1426            s2.insert(id, page.clone());
1427            let _ = s2.get(id);
1428        });
1429
1430        // Legacy: WORKERS parallel.
1431        let legacy = Arc::new(legacy_baseline::LegacyPageCache::new(CAPACITY));
1432        let l2 = Arc::clone(&legacy);
1433        let legacy_parallel = run_workload(WORKERS, OPS, move |id, page| {
1434            l2.insert(id, page.clone());
1435            let _ = l2.get(id);
1436        });
1437
1438        eprintln!(
1439            "page_cache concurrency: sharded 1w={:?} sharded {}w={:?} legacy {}w={:?}",
1440            sharded_serial, WORKERS, sharded_parallel, WORKERS, legacy_parallel
1441        );
1442
1443        // Sub-linear scaling: parallel must not be worse than ~7x the
1444        // serial run. Linear (no scaling) would be ~10x.
1445        assert!(
1446            sharded_parallel.as_nanos() < sharded_serial.as_nanos() * 7,
1447            "sharded cache did not scale: 1w={:?} {}w={:?}",
1448            sharded_serial,
1449            WORKERS,
1450            sharded_parallel
1451        );
1452
1453        // Regression check: sharded must beat the legacy single-lock
1454        // baseline at WORKERS workers. We give the legacy a generous
1455        // 1.2x cushion so a noisy CI box doesn't flip the assertion
1456        // when both designs are within noise (would itself indicate a
1457        // sharding regression worth investigating).
1458        assert!(
1459            sharded_parallel.as_nanos() * 12 < legacy_parallel.as_nanos() * 10,
1460            "sharded cache did not beat legacy baseline: sharded {}w={:?} legacy {}w={:?}",
1461            WORKERS,
1462            sharded_parallel,
1463            WORKERS,
1464            legacy_parallel
1465        );
1466    }
1467}