Skip to main content

reddb_engine/storage/
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    /// Snapshot and clear one dirty image under the same entry lock. A later
717    /// writer must leave its replacement dirty, even while this image flushes.
718    fn take_dirty_page(&self, page_id: u32) -> Option<Page> {
719        let index = cache_read(&self.index);
720        let slot = *index.get(&page_id)?;
721        let mut entries = cache_write(&self.entries);
722        let entry = entries.get_mut(slot)?.as_mut()?;
723        if !entry.dirty {
724            return None;
725        }
726        let page = entry.page.clone();
727        entry.dirty = false;
728        drop(entries);
729        drop(index);
730        cache_lock(&self.stats).writebacks += 1;
731        Some(page)
732    }
733
734    /// Bounded counterpart of `flush_dirty` used by the background
735    /// writer. Snapshots up to `max` dirty pages, marks them clean,
736    /// and returns the (page_id, page) pairs for the caller to
737    /// persist via the pager. Clamps to the current dirty set —
738    /// returning fewer than `max` simply means we're caught up.
739    pub fn flush_some_dirty(&self, max: usize) -> Vec<(u32, Page)> {
740        if max == 0 {
741            return Vec::new();
742        }
743        let mut dirty_pages = Vec::with_capacity(max);
744
745        let index = cache_read(&self.index);
746        let entries = cache_read(&self.entries);
747
748        for (&page_id, &slot) in index.iter() {
749            if dirty_pages.len() >= max {
750                break;
751            }
752            if let Some(Some(entry)) = entries.get(slot) {
753                if entry.dirty {
754                    dirty_pages.push((page_id, entry.page.clone()));
755                }
756            }
757        }
758
759        drop(entries);
760        drop(index);
761
762        for (page_id, _) in &dirty_pages {
763            self.mark_clean(*page_id);
764        }
765
766        let count = dirty_pages.len();
767        cache_lock(&self.stats).writebacks += count as u64;
768
769        dirty_pages
770    }
771
772    /// Count dirty pages currently in the cache. Used by the
773    /// background writer to compute an adaptive flush budget.
774    pub fn dirty_count(&self) -> usize {
775        let index = cache_read(&self.index);
776        let entries = cache_read(&self.entries);
777        let mut count = 0;
778        for (_, &slot) in index.iter() {
779            if let Some(Some(entry)) = entries.get(slot) {
780                if entry.dirty {
781                    count += 1;
782                }
783            }
784        }
785        count
786    }
787
788    /// Clear all entries from cache
789    pub fn clear(&self) {
790        let mut index = cache_write(&self.index);
791        let mut entries = cache_write(&self.entries);
792        let mut fifo = cache_lock(&self.fifo);
793        let mut free_slots = cache_lock(&self.free_slots);
794
795        index.clear();
796        entries.clear();
797        fifo.clear();
798        free_slots.clear();
799        self.meta.reset();
800        *cache_lock(&self.hand) = 0;
801    }
802
803    /// Check if a page is in cache
804    pub fn contains(&self, page_id: u32) -> bool {
805        cache_read(&self.index).contains_key(&page_id)
806    }
807
808    /// Get all cached page IDs
809    pub fn page_ids(&self) -> Vec<u32> {
810        cache_read(&self.index).keys().copied().collect()
811    }
812
813    /// Snapshot IDs without cloning images or clearing their dirty flags.
814    fn dirty_page_ids(&self, max: usize) -> Vec<u32> {
815        let index = cache_read(&self.index);
816        let entries = cache_read(&self.entries);
817        index
818            .iter()
819            .filter_map(|(&page_id, &slot)| {
820                let entry = entries.get(slot)?.as_ref()?;
821                entry.dirty.then_some(page_id)
822            })
823            .take(max)
824            .collect()
825    }
826}
827
828impl Default for PageCacheShard {
829    fn default() -> Self {
830        Self::with_default_capacity()
831    }
832}
833
834/// Number of independent `PageCacheShard`s inside a `PageCache`. Must
835/// be a power of two so `page_id & (NUM_SHARDS - 1)` is a valid shard
836/// index. 8 was picked to cover typical bench concurrency (16 workers)
837/// without making each shard's own SIEVE queue too small; bumping to
838/// 16 is safe if profiling ever shows shard-level contention.
839const NUM_SHARDS: usize = 8;
840
841/// Sharded page cache.
842///
843/// Routes each `page_id` to one of `NUM_SHARDS` independent
844/// [`PageCacheShard`]s by the low bits of the id. Readers and writers
845/// for disjoint pages hit different shards and do not contend on the
846/// shard-internal RwLocks/Mutexes. Each shard runs its own SIEVE
847/// eviction loop over `capacity / NUM_SHARDS` slots; hot/cold
848/// asymmetry across shards is accepted in exchange for the
849/// contention win.
850pub struct PageCache {
851    shards: Box<[PageCacheShard]>,
852    capacity: usize,
853}
854
855impl PageCache {
856    pub fn new(capacity: usize) -> Self {
857        // Keep every shard above MIN_CACHE_CAPACITY so the inner
858        // SIEVE invariants stay valid even when callers pass tiny
859        // totals (tests frequently do).
860        let per_shard = capacity.div_ceil(NUM_SHARDS).max(MIN_CACHE_CAPACITY);
861        let total = per_shard * NUM_SHARDS;
862        let shards: Vec<PageCacheShard> = (0..NUM_SHARDS)
863            .map(|_| PageCacheShard::new(per_shard))
864            .collect();
865        Self {
866            shards: shards.into_boxed_slice(),
867            capacity: total,
868        }
869    }
870
871    pub fn with_default_capacity() -> Self {
872        Self::new(DEFAULT_CACHE_CAPACITY)
873    }
874
875    #[inline]
876    fn shard_for(&self, page_id: u32) -> &PageCacheShard {
877        &self.shards[(page_id as usize) & (NUM_SHARDS - 1)]
878    }
879
880    pub fn len(&self) -> usize {
881        self.shards.iter().map(|s| s.len()).sum()
882    }
883
884    pub fn is_empty(&self) -> bool {
885        self.shards.iter().all(|s| s.is_empty())
886    }
887
888    pub fn capacity(&self) -> usize {
889        self.capacity
890    }
891
892    pub fn stats(&self) -> CacheStats {
893        let mut agg = CacheStats::default();
894        for s in self.shards.iter() {
895            let cs = s.stats();
896            agg.hits += cs.hits;
897            agg.misses += cs.misses;
898            agg.evictions += cs.evictions;
899            agg.writebacks += cs.writebacks;
900        }
901        agg
902    }
903
904    pub fn reset_stats(&self) {
905        for s in self.shards.iter() {
906            s.reset_stats();
907        }
908    }
909
910    pub fn get(&self, page_id: u32) -> Option<Page> {
911        self.shard_for(page_id).get(page_id)
912    }
913
914    pub fn insert(&self, page_id: u32, page: Page) -> Option<Page> {
915        self.shard_for(page_id).insert(page_id, page)
916    }
917
918    pub fn mark_dirty(&self, page_id: u32) {
919        self.shard_for(page_id).mark_dirty(page_id)
920    }
921
922    pub fn mark_clean(&self, page_id: u32) {
923        self.shard_for(page_id).mark_clean(page_id)
924    }
925
926    pub fn pin(&self, page_id: u32) -> bool {
927        self.shard_for(page_id).pin(page_id)
928    }
929
930    pub fn unpin(&self, page_id: u32) -> bool {
931        self.shard_for(page_id).unpin(page_id)
932    }
933
934    pub fn remove(&self, page_id: u32) -> Option<Page> {
935        self.shard_for(page_id).remove(page_id)
936    }
937
938    pub fn contains(&self, page_id: u32) -> bool {
939        self.shard_for(page_id).contains(page_id)
940    }
941
942    pub fn flush_dirty(&self) -> Vec<(u32, Page)> {
943        let mut out = Vec::new();
944        for s in self.shards.iter() {
945            out.extend(s.flush_dirty());
946        }
947        out
948    }
949
950    /// Snapshot only the requested dirty images, bounding payload copies by
951    /// the caller's batch size rather than the full cache capacity.
952    pub fn flush_dirty_pages(&self, page_ids: &[u32]) -> Vec<(u32, Page)> {
953        let mut pages = Vec::with_capacity(page_ids.len());
954        for &page_id in page_ids {
955            if let Some(page) = self.shard_for(page_id).take_dirty_page(page_id) {
956                pages.push((page_id, page));
957            }
958        }
959        pages
960    }
961
962    pub fn flush_some_dirty(&self, max: usize) -> Vec<(u32, Page)> {
963        if max == 0 {
964            return Vec::new();
965        }
966        let mut out = Vec::with_capacity(max);
967        for s in self.shards.iter() {
968            if out.len() >= max {
969                break;
970            }
971            let budget = max - out.len();
972            out.extend(s.flush_some_dirty(budget));
973        }
974        out
975    }
976
977    pub fn dirty_count(&self) -> usize {
978        self.shards.iter().map(|s| s.dirty_count()).sum()
979    }
980
981    pub fn clear(&self) {
982        for s in self.shards.iter() {
983            s.clear();
984        }
985    }
986
987    pub fn page_ids(&self) -> Vec<u32> {
988        let mut out = Vec::with_capacity(self.len());
989        for s in self.shards.iter() {
990            out.extend(s.page_ids());
991        }
992        out
993    }
994
995    /// Clean entries must not split a small dirty set into many I/O batches.
996    pub fn dirty_page_ids(&self, max: usize) -> Vec<u32> {
997        let mut ids = Vec::new();
998        for shard in self.shards.iter() {
999            if ids.len() == max {
1000                break;
1001            }
1002            ids.extend(shard.dirty_page_ids(max - ids.len()));
1003        }
1004        ids
1005    }
1006}
1007
1008impl Default for PageCache {
1009    fn default() -> Self {
1010        Self::with_default_capacity()
1011    }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017    use crate::storage::page::PageType;
1018
1019    fn make_page(id: u32) -> Page {
1020        Page::new(PageType::BTreeLeaf, id)
1021    }
1022
1023    #[test]
1024    fn test_cache_basic() {
1025        let cache = PageCacheShard::new(100);
1026
1027        assert!(cache.is_empty());
1028        assert_eq!(cache.capacity(), 100);
1029
1030        // Insert
1031        cache.insert(1, make_page(1));
1032        assert_eq!(cache.len(), 1);
1033        assert!(cache.contains(1));
1034
1035        // Get
1036        let page = cache.get(1);
1037        assert!(page.is_some());
1038
1039        // Miss
1040        let page = cache.get(999);
1041        assert!(page.is_none());
1042    }
1043
1044    #[test]
1045    fn test_cache_eviction() {
1046        let cache = PageCacheShard::new(4);
1047
1048        // Fill cache
1049        for i in 0..4 {
1050            cache.insert(i, make_page(i));
1051        }
1052
1053        assert_eq!(cache.len(), 4);
1054
1055        // Insert one more, should trigger eviction
1056        cache.insert(100, make_page(100));
1057
1058        // One of the original pages should be evicted
1059        assert_eq!(cache.len(), 4);
1060        assert!(cache.contains(100));
1061    }
1062
1063    #[test]
1064    fn test_cache_sieve_visited() {
1065        let cache = PageCacheShard::new(4);
1066
1067        // Fill cache
1068        for i in 0..4 {
1069            cache.insert(i, make_page(i));
1070        }
1071
1072        // Access page 0 (marks as visited)
1073        cache.get(0);
1074
1075        // Insert new page, should evict non-visited first
1076        cache.insert(100, make_page(100));
1077
1078        // Page 0 should still be there (was visited)
1079        assert!(cache.contains(0));
1080        assert!(cache.contains(100));
1081    }
1082
1083    #[test]
1084    fn test_cache_dirty() {
1085        let cache = PageCacheShard::new(100);
1086
1087        cache.insert(1, make_page(1));
1088        cache.mark_dirty(1);
1089
1090        let dirty = cache.flush_dirty();
1091        assert_eq!(dirty.len(), 1);
1092        assert_eq!(dirty[0].0, 1);
1093
1094        // Should be clean now
1095        let dirty = cache.flush_dirty();
1096        assert_eq!(dirty.len(), 0);
1097    }
1098
1099    #[test]
1100    fn test_cache_pin() {
1101        let cache = PageCacheShard::new(2);
1102
1103        cache.insert(1, make_page(1));
1104        cache.insert(2, make_page(2));
1105
1106        // Pin page 1
1107        assert!(cache.pin(1));
1108
1109        // Fill cache to trigger eviction
1110        cache.insert(3, make_page(3));
1111
1112        // Page 1 should still be there (pinned)
1113        assert!(cache.contains(1));
1114
1115        // Unpin
1116        assert!(cache.unpin(1));
1117    }
1118
1119    #[test]
1120    fn test_cache_remove() {
1121        let cache = PageCacheShard::new(100);
1122
1123        cache.insert(1, make_page(1));
1124        assert!(cache.contains(1));
1125
1126        let removed = cache.remove(1);
1127        assert!(removed.is_some());
1128        assert!(!cache.contains(1));
1129    }
1130
1131    #[test]
1132    fn test_cache_stats() {
1133        let cache = PageCacheShard::new(100);
1134
1135        cache.insert(1, make_page(1));
1136
1137        // Hit
1138        cache.get(1);
1139        cache.get(1);
1140
1141        // Miss
1142        cache.get(999);
1143
1144        let stats = cache.stats();
1145        assert_eq!(stats.hits, 2);
1146        assert_eq!(stats.misses, 1);
1147        assert!((stats.hit_rate() - 0.666).abs() < 0.01);
1148    }
1149
1150    #[test]
1151    fn test_cache_clear() {
1152        let cache = PageCacheShard::new(100);
1153
1154        for i in 0..50 {
1155            cache.insert(i, make_page(i));
1156        }
1157
1158        assert_eq!(cache.len(), 50);
1159
1160        cache.clear();
1161        assert!(cache.is_empty());
1162    }
1163
1164    #[test]
1165    fn test_cache_update_existing() {
1166        let cache = PageCacheShard::new(100);
1167
1168        let mut page1 = make_page(1);
1169        page1.as_bytes_mut()[100] = 0xAA;
1170        cache.insert(1, page1);
1171
1172        let mut page1_updated = make_page(1);
1173        page1_updated.as_bytes_mut()[100] = 0xBB;
1174        cache.insert(1, page1_updated);
1175
1176        // Should still only have one entry
1177        assert_eq!(cache.len(), 1);
1178
1179        // Should have updated value
1180        let retrieved = cache.get(1).unwrap();
1181        assert_eq!(retrieved.as_bytes()[100], 0xBB);
1182    }
1183
1184    #[test]
1185    fn test_cache_recovers_after_index_lock_poisoning() {
1186        let cache = std::sync::Arc::new(PageCacheShard::new(8));
1187        let poison_target = std::sync::Arc::clone(&cache);
1188        let _ = std::thread::spawn(move || {
1189            let _guard = poison_target
1190                .index
1191                .write()
1192                .expect("index lock should be acquired");
1193            panic!("poison index lock");
1194        })
1195        .join();
1196
1197        cache.insert(1, make_page(1));
1198        assert!(cache.contains(1));
1199        assert!(cache.get(1).is_some());
1200    }
1201
1202    #[test]
1203    fn test_cache_recovers_after_stats_lock_poisoning() {
1204        let cache = std::sync::Arc::new(PageCacheShard::new(8));
1205        let poison_target = std::sync::Arc::clone(&cache);
1206        let _ = std::thread::spawn(move || {
1207            let _guard = poison_target
1208                .stats
1209                .lock()
1210                .expect("stats lock should be acquired");
1211            panic!("poison stats lock");
1212        })
1213        .join();
1214
1215        assert!(cache.get(999).is_none());
1216        assert_eq!(cache.stats().misses, 1);
1217        cache.reset_stats();
1218        assert_eq!(cache.stats().misses, 0);
1219    }
1220
1221    /// ADR 0033: the SIEVE `visited`/`tag` metadata lives in its own
1222    /// cache-line-packed, line-aligned arrays — separate from the
1223    /// lock-protected `CacheEntry`. Assert the line types are exactly
1224    /// one 64-byte line and 64-byte aligned (so the boxed backing
1225    /// buffers start on a line boundary and never false-share), and
1226    /// that `CacheEntry` no longer carries the visited bit.
1227    #[test]
1228    fn test_metadata_layout_is_cache_line_packed() {
1229        assert_eq!(std::mem::align_of::<VisitedLine>(), CACHE_LINE);
1230        assert_eq!(std::mem::size_of::<VisitedLine>(), CACHE_LINE);
1231        assert_eq!(std::mem::align_of::<TagLine>(), CACHE_LINE);
1232        assert_eq!(std::mem::size_of::<TagLine>(), CACHE_LINE);
1233
1234        // The metadata arrays are distinct allocations from `entries`.
1235        let meta = ShardMeta::new(200);
1236        // Densely packed: 64 visited bits per line, 16 tags per line.
1237        assert_eq!(meta.visited.len(), 200usize.div_ceil(64));
1238        assert_eq!(meta.tag.len(), 200usize.div_ceil(16));
1239
1240        // occupy/vacate round-trip through the tag array.
1241        meta.occupy(5, 42);
1242        assert_eq!(meta.tag(5), 42);
1243        assert!(!meta.is_visited(5));
1244        meta.mark_visited(5);
1245        assert!(meta.is_visited(5));
1246        meta.vacate(5);
1247        assert_eq!(meta.tag(5), TAG_EMPTY);
1248        assert!(!meta.is_visited(5));
1249    }
1250
1251    /// ADR 0033: a cache hit sets the SIEVE `visited` bit on the
1252    /// separate metadata array (no write-lock upgrade), and that bit
1253    /// drives eviction exactly as before — a hit page survives one
1254    /// eviction sweep while an un-hit page is evicted first.
1255    #[test]
1256    fn test_hit_sets_visited_and_preserves_sieve_policy() {
1257        let cache = PageCacheShard::new(4);
1258        for i in 0..4 {
1259            cache.insert(i, make_page(i));
1260        }
1261
1262        // Hit pages 0 and 1 (lock-free visited mark via metadata array).
1263        assert!(cache.get(0).is_some());
1264        assert!(cache.get(1).is_some());
1265
1266        // Insert two more: SIEVE evicts the un-hit 2 and 3 first.
1267        cache.insert(10, make_page(10));
1268        cache.insert(11, make_page(11));
1269
1270        assert!(cache.contains(0), "hit page 0 must survive");
1271        assert!(cache.contains(1), "hit page 1 must survive");
1272        assert!(cache.contains(10));
1273        assert!(cache.contains(11));
1274        assert!(!cache.contains(2));
1275        assert!(!cache.contains(3));
1276    }
1277
1278    /// ADR 0033 acceptance: under concurrent hits + eviction, no pinned
1279    /// page is ever evicted, and dirty pages are never silently lost —
1280    /// every dirty page that leaves the cache is returned for
1281    /// writeback. Stress/loom-style: many threads hammer hits while
1282    /// other threads drive insertions (and thus the eviction hand).
1283    #[test]
1284    fn test_concurrent_hits_never_evict_pinned_or_lose_dirty() {
1285        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1286        use std::sync::Arc;
1287
1288        const CAP: u32 = 64;
1289        const PINNED: u32 = 8; // pages 0..8 stay pinned for the whole run
1290        const DIRTY: u32 = 8; // pages 8..16 are dirty
1291        const HITTERS: usize = 6;
1292        const INSERTERS: usize = 4;
1293        const ROUNDS: usize = 4_000;
1294
1295        let cache = Arc::new(PageCacheShard::new(CAP as usize));
1296        for i in 0..CAP {
1297            cache.insert(i, make_page(i));
1298        }
1299        for p in 0..PINNED {
1300            assert!(cache.pin(p), "pin {p}");
1301        }
1302        for p in PINNED..PINNED + DIRTY {
1303            cache.mark_dirty(p);
1304        }
1305
1306        // Count dirty pages handed back by insert() so we can prove no
1307        // dirty data is silently dropped.
1308        let dirty_written_back = Arc::new(AtomicUsize::new(0));
1309        let stop = Arc::new(AtomicBool::new(false));
1310
1311        // Hitters: loop on the lock-free visited path until signalled.
1312        let mut hitters = Vec::with_capacity(HITTERS);
1313        for h in 0..HITTERS {
1314            let cache = Arc::clone(&cache);
1315            let stop = Arc::clone(&stop);
1316            hitters.push(std::thread::spawn(move || {
1317                let mut id = (h as u32) % CAP;
1318                while !stop.load(Ordering::Relaxed) {
1319                    let _ = cache.get(id);
1320                    id = (id + 1) % CAP;
1321                }
1322            }));
1323        }
1324
1325        // Inserters: bounded churn that drives the eviction hand.
1326        let mut inserters = Vec::with_capacity(INSERTERS);
1327        for w in 0..INSERTERS {
1328            let cache = Arc::clone(&cache);
1329            let dirty_written_back = Arc::clone(&dirty_written_back);
1330            inserters.push(std::thread::spawn(move || {
1331                let base = 1_000 + (w as u32) * 1_000_000;
1332                for i in 0..ROUNDS {
1333                    let id = base + i as u32;
1334                    if cache.insert(id, make_page(id)).is_some() {
1335                        // insert only returns Some for a dirty victim.
1336                        dirty_written_back.fetch_add(1, Ordering::Relaxed);
1337                    }
1338                    // Invariant checked mid-storm: pinned pages are
1339                    // never evicted.
1340                    for p in 0..PINNED {
1341                        assert!(
1342                            cache.contains(p),
1343                            "pinned page {p} was evicted under concurrency"
1344                        );
1345                    }
1346                }
1347            }));
1348        }
1349
1350        // Inserters finish their bounded work; then stop the hitters.
1351        for h in inserters {
1352            h.join()
1353                .expect("inserter thread panicked (invariant broke)");
1354        }
1355        stop.store(true, Ordering::Relaxed);
1356        for h in hitters {
1357            h.join().expect("hitter thread panicked (invariant broke)");
1358        }
1359
1360        // Pinned pages survived the entire storm.
1361        for p in 0..PINNED {
1362            assert!(cache.contains(p), "pinned page {p} missing after storm");
1363        }
1364
1365        // Dirty accounting: a dirty page still in the cache, plus any
1366        // dirty victims handed back for writeback, must conserve the
1367        // original dirty set — no dirty page silently vanished.
1368        let dirty_resident = (PINNED..PINNED + DIRTY)
1369            .filter(|&p| cache.contains(p))
1370            .count();
1371        assert!(
1372            dirty_resident + dirty_written_back.load(Ordering::Relaxed) >= DIRTY as usize,
1373            "dirty pages lost: resident={dirty_resident} written_back={}",
1374            dirty_written_back.load(Ordering::Relaxed)
1375        );
1376
1377        // Note: we deliberately do NOT assert `len <= CAP`. The
1378        // pre-existing fine-grained-lock `insert` checks length and
1379        // evicts in separate steps, so concurrent inserters can
1380        // transiently push a shard past capacity. ShardMeta's bounds
1381        // guards make that overflow safe (over-capacity slots report
1382        // "not visited" / TAG_EMPTY and drain first); enforcing a hard
1383        // cap is out of scope for this lock-free-hit change.
1384    }
1385
1386    /// Legacy single-lock baseline used as a regression check for the
1387    /// sharded `PageCache`. Mirrors the pre-shard cache shape: a single
1388    /// `RwLock<HashMap<u32, Page>>` so every mutation serializes on one
1389    /// writer. We only need the surface used by the concurrency test
1390    /// (`insert` / `get`); keeping it minimal avoids accidentally
1391    /// drifting toward the sharded design and silently flattening the
1392    /// regression signal.
1393    mod legacy_baseline {
1394        use super::Page;
1395        use std::collections::HashMap;
1396        use std::sync::RwLock;
1397
1398        pub struct LegacyPageCache {
1399            entries: RwLock<HashMap<u32, Page>>,
1400        }
1401
1402        impl LegacyPageCache {
1403            pub fn new(_capacity: usize) -> Self {
1404                Self {
1405                    entries: RwLock::new(HashMap::new()),
1406                }
1407            }
1408
1409            pub fn insert(&self, page_id: u32, page: Page) {
1410                let mut entries = self.entries.write().unwrap();
1411                entries.insert(page_id, page);
1412            }
1413
1414            pub fn get(&self, page_id: u32) -> Option<Page> {
1415                let entries = self.entries.read().unwrap();
1416                entries.get(&page_id).cloned()
1417            }
1418        }
1419    }
1420
1421    /// Workload shared by both the sharded and legacy runs of the
1422    /// concurrency property test below. Each worker churns through its
1423    /// own disjoint `page_id` range so any contention seen between
1424    /// workers is purely lock-induced, not data-induced.
1425    fn run_workload<F>(workers: usize, ops_per_worker: usize, run: F) -> std::time::Duration
1426    where
1427        F: Fn(u32, &Page) + Send + Sync + 'static + Clone,
1428    {
1429        use std::sync::Arc;
1430        use std::time::Instant;
1431
1432        let run = Arc::new(run);
1433        let start = Instant::now();
1434        let mut handles = Vec::with_capacity(workers);
1435        for w in 0..workers {
1436            let run = Arc::clone(&run);
1437            handles.push(std::thread::spawn(move || {
1438                let base = (w as u32) * 1_000_000;
1439                let page = make_page(0);
1440                for i in 0..ops_per_worker {
1441                    let id = base + (i as u32);
1442                    run(id, &page);
1443                }
1444            }));
1445        }
1446        for h in handles {
1447            h.join().unwrap();
1448        }
1449        start.elapsed()
1450    }
1451
1452    #[test]
1453    fn test_sharded_cache_scales_concurrently() {
1454        // Property: with disjoint page_id ranges, 10 workers should
1455        // beat 1 worker by a meaningful factor on the sharded cache,
1456        // and the sharded cache should beat the legacy single-lock
1457        // baseline at 10 workers (since the baseline serializes every
1458        // mutation through one global RwLock).
1459        //
1460        // We assert "sub-linear scaling" in a deliberately loose form
1461        // (parallel < 7x serial) so CI flakiness on busy runners
1462        // doesn't false-positive. The legacy comparison is the
1463        // stronger regression signal.
1464        use std::sync::Arc;
1465
1466        const WORKERS: usize = 10;
1467        const OPS: usize = 5_000;
1468        const CAPACITY: usize = 200_000;
1469
1470        // Sharded: 1 worker baseline.
1471        let sharded = Arc::new(PageCache::new(CAPACITY));
1472        let s1 = Arc::clone(&sharded);
1473        let sharded_serial = run_workload(1, OPS * WORKERS, move |id, page| {
1474            s1.insert(id, page.clone());
1475            let _ = s1.get(id);
1476        });
1477
1478        // Sharded: WORKERS parallel.
1479        let sharded = Arc::new(PageCache::new(CAPACITY));
1480        let s2 = Arc::clone(&sharded);
1481        let sharded_parallel = run_workload(WORKERS, OPS, move |id, page| {
1482            s2.insert(id, page.clone());
1483            let _ = s2.get(id);
1484        });
1485
1486        // Legacy: WORKERS parallel.
1487        let legacy = Arc::new(legacy_baseline::LegacyPageCache::new(CAPACITY));
1488        let l2 = Arc::clone(&legacy);
1489        let legacy_parallel = run_workload(WORKERS, OPS, move |id, page| {
1490            l2.insert(id, page.clone());
1491            let _ = l2.get(id);
1492        });
1493
1494        eprintln!(
1495            "page_cache concurrency: sharded 1w={:?} sharded {}w={:?} legacy {}w={:?}",
1496            sharded_serial, WORKERS, sharded_parallel, WORKERS, legacy_parallel
1497        );
1498
1499        // Sub-linear scaling: parallel must not be worse than ~7x the
1500        // serial run. Linear (no scaling) would be ~10x.
1501        assert!(
1502            sharded_parallel.as_nanos() < sharded_serial.as_nanos() * 7,
1503            "sharded cache did not scale: 1w={:?} {}w={:?}",
1504            sharded_serial,
1505            WORKERS,
1506            sharded_parallel
1507        );
1508
1509        // Regression check: sharded must beat the legacy single-lock
1510        // baseline at WORKERS workers. We give the legacy a generous
1511        // 1.2x cushion so a noisy CI box doesn't flip the assertion
1512        // when both designs are within noise (would itself indicate a
1513        // sharding regression worth investigating).
1514        assert!(
1515            sharded_parallel.as_nanos() * 12 < legacy_parallel.as_nanos() * 10,
1516            "sharded cache did not beat legacy baseline: sharded {}w={:?} legacy {}w={:?}",
1517            WORKERS,
1518            sharded_parallel,
1519            WORKERS,
1520            legacy_parallel
1521        );
1522    }
1523}