Skip to main content

akar_storage/
buffer_manager.rs

1//! Buffer manager — manages in-memory page cache with Clock eviction policy.
2
3#![allow(clippy::trivial_regex, clippy::collapsible_if)]
4
5use crate::page::{DEFAULT_PAGE_SIZE, Frame, PageNum};
6use akar_common::memory::MemoryManager;
7use akar_common::memory_account::BUFFER_POOL;
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::Arc;
11
12/// NUMA topology information.
13#[derive(Debug, Clone)]
14pub struct NumaInfo {
15    /// Number of NUMA nodes detected on the system.
16    pub num_nodes: u32,
17}
18
19impl NumaInfo {
20    /// Detect the NUMA topology of the current system.
21    ///
22    /// - On Windows: returns 1 (Windows NUMA API is complex; simple default).
23    /// - On Linux: parses `/sys/devices/system/node/node*` directories.
24    /// - On other platforms: returns 1.
25    pub fn detect() -> Self {
26        let num_nodes = Self::detect_num_nodes();
27        Self { num_nodes }
28    }
29
30    #[cfg(target_os = "linux")]
31    fn detect_num_nodes() -> u32 {
32        use std::fs;
33        let node_dir = "/sys/devices/system/node";
34        if let Ok(entries) = fs::read_dir(node_dir) {
35            let count = entries
36                .filter_map(|e| e.ok())
37                .filter(|e| {
38                    e.file_name()
39                        .to_str()
40                        .map(|s| s.starts_with("node") && s != "node")
41                        .unwrap_or(false)
42                })
43                .count();
44            if count > 0 {
45                return count as u32;
46            }
47        }
48        1
49    }
50
51    #[cfg(not(target_os = "linux"))]
52    fn detect_num_nodes() -> u32 {
53        1
54    }
55}
56
57/// Readahead configuration for sequential access detection.
58#[derive(Debug, Clone)]
59pub struct ReadaheadPolicy {
60    /// Whether readahead prefetching is enabled.
61    pub enabled: bool,
62    /// How many pages ahead to prefetch when a sequential pattern is detected.
63    pub window: usize,
64}
65
66impl Default for ReadaheadPolicy {
67    fn default() -> Self {
68        Self {
69            enabled: true,
70            window: 4,
71        }
72    }
73}
74
75/// Configuration for the buffer manager.
76#[derive(Debug, Clone)]
77pub struct BufferManagerConfig {
78    /// Maximum memory to use for the buffer pool (in bytes).
79    pub max_memory: u64,
80    /// Page size in bytes.
81    pub page_size: usize,
82    /// Use memory-mapped I/O instead of read syscalls when available.
83    pub use_mmap: bool,
84    /// Enable NUMA-aware frame tracking.
85    pub numa_aware: bool,
86    /// Sequential readahead policy.
87    pub readahead: ReadaheadPolicy,
88}
89
90impl Default for BufferManagerConfig {
91    fn default() -> Self {
92        Self {
93            max_memory: 64 * 1024 * 1024, // 64MB default
94            page_size: DEFAULT_PAGE_SIZE,
95            use_mmap: false,
96            numa_aware: false,
97            readahead: ReadaheadPolicy::default(),
98        }
99    }
100}
101
102/// Statistics for the buffer manager.
103#[derive(Debug, Default, Clone, Copy)]
104pub struct BufferManagerStats {
105    /// Total number of page faults (disk reads).
106    pub page_faults: u64,
107    /// Total number of page writes to disk.
108    pub page_writes: u64,
109    /// Current number of frames in the pool.
110    pub num_frames: usize,
111    /// Number of dirty frames.
112    pub dirty_frames: usize,
113    /// Number of pinned frames.
114    pub pinned_frames: usize,
115}
116
117/// The buffer manager manages a pool of frames with a Clock eviction policy.
118#[derive(Debug)]
119#[allow(dead_code)]
120pub struct BufferManager {
121    /// Path to the database directory.
122    db_path: PathBuf,
123    /// Page size in bytes.
124    page_size: usize,
125    /// Maximum number of frames allowed.
126    max_frames: usize,
127    /// Frame table: (file_name, page_num) → Frame.
128    /// Composite key so that different files don't collide on page 0.
129    frames: HashMap<(String, PageNum), Frame>,
130    /// File handles for each database file (path → FileHandle data).
131    files: HashMap<String, FileHandleInfo>,
132    /// Clock hand index for eviction.
133    clock_hand: usize,
134    /// Ordered list of (file_name, page_num) for Clock algorithm.
135    clock_order: Vec<(String, PageNum)>,
136    /// Memory manager for tracking allocation.
137    memory_manager: Arc<MemoryManager>,
138    /// Statistics.
139    stats: BufferManagerStats,
140    /// Whether mmap reads are enabled.
141    use_mmap: bool,
142    /// Memory-mapped regions keyed by file path.
143    #[cfg(not(target_arch = "wasm32"))]
144    mmap_regions: HashMap<String, memmap2::Mmap>,
145    /// NUMA topology info.
146    numa_info: NumaInfo,
147    /// Sequential readahead policy.
148    readahead: ReadaheadPolicy,
149    /// Track last page accessed per file for sequential detection.
150    last_accessed: HashMap<String, PageNum>,
151    /// Track the previous last-accessed page per file (for sequential pattern check).
152    prev_last_accessed: HashMap<String, PageNum>,
153}
154
155#[allow(dead_code)]
156#[derive(Debug, Clone)]
157struct FileHandleInfo {
158    path: PathBuf,
159    num_pages: u64,
160}
161
162impl BufferManager {
163    pub fn new(db_path: PathBuf, memory_manager: Arc<MemoryManager>, config: BufferManagerConfig) -> Self {
164        let max_frames = if config.max_memory > 0 {
165            (config.max_memory / config.page_size as u64) as usize
166        } else {
167            1000
168        };
169
170        let numa_info = if config.numa_aware {
171            NumaInfo::detect()
172        } else {
173            NumaInfo { num_nodes: 1 }
174        };
175
176        Self {
177            db_path,
178            page_size: config.page_size,
179            max_frames,
180            frames: HashMap::new(),
181            files: HashMap::new(),
182            clock_hand: 0,
183            clock_order: Vec::new(),
184            memory_manager,
185            stats: BufferManagerStats::default(),
186            use_mmap: config.use_mmap,
187            #[cfg(not(target_arch = "wasm32"))]
188            mmap_regions: HashMap::new(),
189            numa_info,
190            readahead: config.readahead,
191            last_accessed: HashMap::new(),
192            prev_last_accessed: HashMap::new(),
193        }
194    }
195
196    /// Build a composite key for the frames map.
197    fn key(file_name: &str, page_num: PageNum) -> (String, PageNum) {
198        (file_name.to_string(), page_num)
199    }
200
201    pub fn stats(&self) -> &BufferManagerStats {
202        &self.stats
203    }
204
205    pub fn page_size(&self) -> usize {
206        self.page_size
207    }
208
209    pub fn num_frames(&self) -> usize {
210        self.frames.len()
211    }
212
213    /// Check if a file is already registered with the buffer manager.
214    pub fn is_file_registered(&self, name: &str) -> bool {
215        self.files.contains_key(name)
216    }
217
218    /// Register a database file with the buffer manager.
219    pub fn register_file(&mut self, name: &str, path: PathBuf) {
220        let num_pages = if path.exists() {
221            let len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
222            len / self.page_size as u64
223        } else {
224            0
225        };
226        self.files.insert(name.to_string(), FileHandleInfo { path, num_pages });
227    }
228
229    /// Pin a page: bring it into the buffer pool if not already present.
230    pub fn pin(&mut self, file_name: &str, page_num: PageNum) -> std::io::Result<Frame> {
231        let k = Self::key(file_name, page_num);
232        if let Some(frame) = self.frames.get_mut(&k) {
233            frame.pin();
234            frame.clock_ref = true;
235
236            // Update sequential tracking (cache hit path)
237            let prev = self.last_accessed.remove(file_name);
238            if let Some(p) = prev {
239                self.prev_last_accessed.insert(file_name.to_string(), p);
240            }
241            self.last_accessed.insert(file_name.to_string(), page_num);
242
243            return Ok(frame.clone());
244        }
245
246        // Page fault
247        self.stats.page_faults += 1;
248
249        if self.frames.len() >= self.max_frames {
250            self.evict()?;
251        }
252
253        let data = self.read_from_disk(file_name, page_num)?;
254        let mut frame = Frame::new(page_num, data);
255        frame.pin();
256
257        if self.numa_info.num_nodes > 1 {
258            frame.numa_node = self.current_numa_node();
259        }
260
261        let k = Self::key(file_name, page_num);
262        self.clock_order.push(k.clone());
263        self.frames.insert(k.clone(), frame.clone());
264        self.memory_manager.allocate_with(BUFFER_POOL, self.page_size as u64);
265
266        self.update_stats();
267
268        // Update sequential tracking (fault path)
269        let prev = self.last_accessed.remove(file_name);
270        if let Some(p) = prev {
271            self.prev_last_accessed.insert(file_name.to_string(), p);
272        }
273        self.last_accessed.insert(file_name.to_string(), page_num);
274
275        // Check readahead AFTER updating tracking so prev_last_accessed is correct
276        self.maybe_readahead(file_name, page_num);
277
278        Ok(frame)
279    }
280
281    /// Pin a page and get mutable access.
282    pub fn pin_mut(&mut self, file_name: &str, page_num: PageNum) -> std::io::Result<&mut Frame> {
283        let k = Self::key(file_name, page_num);
284        if !self.frames.contains_key(&k) {
285            // Pin to bring it into the pool (we ignore the returned Frame)
286            let _ = self.pin(file_name, page_num)?;
287        }
288        if let Some(frame) = self.frames.get_mut(&k) {
289            frame.pin();
290            frame.clock_ref = true;
291            return Ok(frame);
292        }
293        unreachable!()
294    }
295
296    /// Unpin a page (requires file_name for composite key lookup).
297    pub fn unpin(&mut self, file_name: &str, page_num: PageNum) {
298        let k = Self::key(file_name, page_num);
299        if let Some(frame) = self.frames.get_mut(&k) {
300            frame.unpin();
301        }
302        self.update_stats();
303    }
304
305    /// Flush a dirty page to disk.
306    pub fn flush(&mut self, file_name: &str, page_num: PageNum) -> std::io::Result<()> {
307        let k = Self::key(file_name, page_num);
308        if let Some(frame) = self.frames.get(&k) {
309            if frame.is_dirty {
310                self.write_to_disk(file_name, page_num, &frame.data)?;
311                if let Some(f) = self.frames.get_mut(&k) {
312                    f.is_dirty = false;
313                }
314                self.stats.page_writes += 1;
315            }
316        }
317        self.update_stats();
318        Ok(())
319    }
320
321    /// Flush all dirty pages to disk.
322    pub fn flush_all(&mut self) -> std::io::Result<()> {
323        let dirty: Vec<(String, PageNum)> = self
324            .frames
325            .iter()
326            .filter(|(_, f)| f.is_dirty)
327            .map(|(k, _)| k.clone())
328            .collect();
329
330        for (file_name, page_num) in dirty {
331            self.flush(&file_name, page_num)?;
332        }
333        Ok(())
334    }
335
336    /// Return all dirty page numbers belonging to a specific file.
337    pub fn dirty_page_nums_for_file(&self, file_name: &str) -> Vec<PageNum> {
338        self.frames
339            .iter()
340            .filter(|(k, f)| k.0 == file_name && f.is_dirty)
341            .map(|(k, _)| k.1)
342            .collect()
343    }
344
345    /// Drop all state for a file: cached frames, registration, mmap region,
346    /// and sequential-access tracking. Used when a file is deleted or rebuilt
347    /// from scratch (e.g. a full persistence-mirror rewrite) so stale frames
348    /// are not re-read under the same file name.
349    pub fn drop_file(&mut self, file_name: &str) {
350        let path = self.files.get(file_name).map(|f| f.path.clone());
351        self.frames.retain(|(f, _), _| f != file_name);
352        self.clock_order.retain(|(f, _)| f != file_name);
353        self.files.remove(file_name);
354        self.last_accessed.remove(file_name);
355        self.prev_last_accessed.remove(file_name);
356        #[cfg(not(target_arch = "wasm32"))]
357        if let Some(p) = path {
358            let pstr = p.to_string_lossy().to_string();
359            self.mmap_regions.remove(&pstr);
360        }
361        self.update_stats();
362    }
363
364    /// Access the NUMA topology info.
365    pub fn numa_info(&self) -> &NumaInfo {
366        &self.numa_info
367    }
368
369    // --- Sequential access tracking & readahead ---
370
371    /// If the access pattern is sequential (page N follows page N-1 for the same
372    /// file), prefetch the next `window` pages into the cache (unpinned, just warm).
373    fn maybe_readahead(&mut self, file_name: &str, page_num: PageNum) {
374        if !self.readahead.enabled || self.readahead.window == 0 {
375            return;
376        }
377
378        if let Some(&prev) = self.prev_last_accessed.get(file_name) {
379            if page_num > 0 && prev == page_num - 1 {
380                // Sequential pattern detected — prefetch next `window` pages.
381                for offset in 1..=self.readahead.window as u64 {
382                    let prefetch_page = page_num + offset;
383                    let pk = Self::key(file_name, prefetch_page);
384                    if self.frames.contains_key(&pk) {
385                        continue; // Already in cache
386                    }
387                    if self.frames.len() >= self.max_frames {
388                        break; // Don't evict to prefetch
389                    }
390                    if let Ok(data) = self.read_from_disk(file_name, prefetch_page) {
391                        self.stats.page_faults += 1;
392                        let mut frame = Frame::new(prefetch_page, data);
393                        if self.numa_info.num_nodes > 1 {
394                            frame.numa_node = self.current_numa_node();
395                        }
396                        self.clock_order.push(pk.clone());
397                        self.frames.insert(pk, frame);
398                        self.memory_manager.allocate_with(BUFFER_POOL, self.page_size as u64);
399                    }
400                }
401            }
402        }
403    }
404
405    // --- Clock eviction ---
406
407    fn evict(&mut self) -> std::io::Result<()> {
408        let n = self.clock_order.len();
409        if n == 0 {
410            return Ok(());
411        }
412
413        for _ in 0..=n {
414            if self.clock_hand >= n {
415                self.clock_hand = 0;
416            }
417            let (ref file_name, ref page_num) = self.clock_order[self.clock_hand];
418            let k = Self::key(file_name, *page_num);
419
420            if let Some(frame) = self.frames.get(&k) {
421                if frame.is_pinned() {
422                    self.clock_hand += 1;
423                    continue;
424                }
425                if frame.clock_ref {
426                    self.frames.get_mut(&k).unwrap().clock_ref = false;
427                    self.clock_hand += 1;
428                    continue;
429                }
430                // Victim found
431                if frame.is_dirty {
432                    self.write_to_disk(file_name, *page_num, &frame.data)?;
433                    self.stats.page_writes += 1;
434                }
435                self.frames.remove(&k);
436                self.clock_order.remove(self.clock_hand);
437                self.memory_manager.deallocate_with(BUFFER_POOL, self.page_size as u64);
438                return Ok(());
439            }
440        }
441        Ok(())
442    }
443
444    // --- Disk I/O ---
445
446    /// Read a page from disk, using mmap if enabled.
447    fn read_from_disk(&mut self, file_name: &str, page_num: PageNum) -> std::io::Result<Vec<u8>> {
448        if self.use_mmap {
449            #[cfg(not(target_arch = "wasm32"))]
450            {
451                return self.read_mmap(file_name, page_num);
452            }
453            #[cfg(target_arch = "wasm32")]
454            {
455                return self.read_syscall(file_name, page_num);
456            }
457        }
458        self.read_syscall(file_name, page_num)
459    }
460
461    /// Read a page via standard syscalls.
462    fn read_syscall(&self, file_name: &str, page_num: PageNum) -> std::io::Result<Vec<u8>> {
463        if let Some(fh) = self.files.get(file_name) {
464            use std::io::{Read, Seek, SeekFrom};
465            let mut buf = vec![0u8; self.page_size];
466            match std::fs::File::open(&fh.path) {
467                Ok(mut file) => {
468                    file.seek(SeekFrom::Start(page_num * self.page_size as u64))?;
469                    // A file that does not cover the whole page (fresh file not
470                    // yet written, or partially written) is read as a zeroed
471                    // page; the file is created on first write.
472                    let _ = file.read(&mut buf);
473                    Ok(buf)
474                }
475                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(buf),
476                Err(e) => Err(e),
477            }
478        } else {
479            Ok(vec![0u8; self.page_size])
480        }
481    }
482
483    /// Read a page via memory-mapped I/O (zero-copy).
484    #[cfg(not(target_arch = "wasm32"))]
485    fn read_mmap(&mut self, file_name: &str, page_num: PageNum) -> std::io::Result<Vec<u8>> {
486        if let Some(fh) = self.files.get(file_name) {
487            let path_str = fh.path.to_string_lossy().to_string();
488            let page_size = self.page_size;
489
490            if !self.mmap_regions.contains_key(&path_str) {
491                match std::fs::File::open(&fh.path) {
492                    Ok(file) => {
493                        let mmap = unsafe { memmap2::Mmap::map(&file)? };
494                        self.mmap_regions.insert(path_str.clone(), mmap);
495                    }
496                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
497                        // Fresh file not yet created on disk — return a zeroed page.
498                        return Ok(vec![0u8; page_size]);
499                    }
500                    Err(e) => return Err(e),
501                }
502            }
503
504            let mmap = &self.mmap_regions[&path_str];
505            let offset = page_num as usize * page_size;
506            let end = offset + page_size;
507
508            if end > mmap.len() {
509                // File shorter than the requested page (fresh or partially
510                // written) — read as a zeroed page.
511                return Ok(vec![0u8; page_size]);
512            }
513
514            // SAFETY: We checked that offset..end is within bounds of the mmap region.
515            // The mmap region is valid for the lifetime of `self`, and we copy the
516            // data into a Vec so there is no dangling pointer risk.
517            let slice = unsafe { std::slice::from_raw_parts(mmap.as_ptr().add(offset), page_size) };
518            Ok(slice.to_vec())
519        } else {
520            Ok(vec![0u8; self.page_size])
521        }
522    }
523
524    fn write_to_disk(&self, file_name: &str, page_num: PageNum, data: &[u8]) -> std::io::Result<()> {
525        if let Some(fh) = self.files.get(file_name) {
526            use std::fs::OpenOptions;
527            use std::io::{Seek, SeekFrom, Write};
528            let mut file = OpenOptions::new()
529                .create(true)
530                .write(true)
531                .truncate(false)
532                .open(&fh.path)?;
533            file.seek(SeekFrom::Start(page_num * self.page_size as u64))?;
534            file.write_all(data)?;
535        }
536        Ok(())
537    }
538
539    /// Return the current NUMA node for the calling thread.
540    fn current_numa_node(&self) -> u32 {
541        // Simple heuristic: always return node 0.
542        // A real implementation would call getcpu() or use libnuma.
543        0
544    }
545
546    fn update_stats(&mut self) {
547        self.stats.num_frames = self.frames.len();
548        self.stats.dirty_frames = self.frames.values().filter(|f| f.is_dirty).count();
549        self.stats.pinned_frames = self.frames.values().filter(|f| f.is_pinned()).count();
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    const TEST_FILE: &str = "main";
558
559    fn create_test_bm() -> (BufferManager, tempfile::TempDir) {
560        let dir = tempfile::tempdir().unwrap();
561        let mm = Arc::new(MemoryManager::new(1024 * 1024));
562        let config = BufferManagerConfig {
563            max_memory: 256 * 1024,
564            page_size: DEFAULT_PAGE_SIZE,
565            readahead: ReadaheadPolicy {
566                enabled: false,
567                ..Default::default()
568            },
569            ..Default::default()
570        };
571        let mut bm = BufferManager::new(dir.path().to_path_buf(), mm, config);
572        let db_path = dir.path().join("test.db");
573        std::fs::write(&db_path, vec![0u8; 8192 * 10]).unwrap();
574        bm.register_file(TEST_FILE, db_path);
575        (bm, dir)
576    }
577
578    #[test]
579    fn test_pin_unpin() {
580        let (mut bm, _dir) = create_test_bm();
581        let frame = bm.pin(TEST_FILE, 0).unwrap();
582        assert_eq!(frame.page_num, 0);
583        assert!(frame.is_pinned());
584        bm.unpin(TEST_FILE, 0);
585        assert_eq!(bm.stats().page_faults, 1);
586    }
587
588    #[test]
589    fn test_cached_page_no_fault() {
590        let (mut bm, _dir) = create_test_bm();
591        bm.pin(TEST_FILE, 0).unwrap();
592        bm.unpin(TEST_FILE, 0);
593        bm.pin(TEST_FILE, 0).unwrap();
594        bm.unpin(TEST_FILE, 0);
595        assert_eq!(bm.stats().page_faults, 1);
596    }
597
598    #[test]
599    fn test_multiple_pages() {
600        let (mut bm, _dir) = create_test_bm();
601        for i in 0..5 {
602            bm.pin(TEST_FILE, i).unwrap();
603            bm.unpin(TEST_FILE, i);
604        }
605        assert_eq!(bm.stats().page_faults, 5);
606        assert_eq!(bm.num_frames(), 5);
607    }
608
609    #[test]
610    fn test_dirty_and_flush() {
611        let (mut bm, _dir) = create_test_bm();
612        let frame = bm.pin_mut(TEST_FILE, 1).unwrap();
613        frame.mark_dirty();
614        bm.unpin(TEST_FILE, 1);
615        assert_eq!(bm.stats().dirty_frames, 1);
616        bm.flush(TEST_FILE, 1).unwrap();
617        assert_eq!(bm.stats().dirty_frames, 0);
618        assert_eq!(bm.stats().page_writes, 1);
619    }
620
621    #[test]
622    fn test_clock_eviction() {
623        let dir = tempfile::tempdir().unwrap();
624        let mm = Arc::new(MemoryManager::new(3 * DEFAULT_PAGE_SIZE as u64));
625        let config = BufferManagerConfig {
626            max_memory: 3 * DEFAULT_PAGE_SIZE as u64,
627            page_size: DEFAULT_PAGE_SIZE,
628            ..Default::default()
629        };
630        let mut bm = BufferManager::new(dir.path().to_path_buf(), mm, config);
631        let db_path = dir.path().join("test.db");
632        std::fs::write(&db_path, vec![0u8; 8192 * 20]).unwrap();
633        bm.register_file(TEST_FILE, db_path);
634
635        for i in 0..3 {
636            bm.pin(TEST_FILE, i).unwrap();
637            bm.unpin(TEST_FILE, i);
638        }
639        assert_eq!(bm.num_frames(), 3);
640
641        bm.pin(TEST_FILE, 3).unwrap();
642        bm.unpin(TEST_FILE, 3);
643        assert_eq!(bm.num_frames(), 3);
644    }
645
646    #[test]
647    fn test_flush_all() {
648        let (mut bm, _dir) = create_test_bm();
649        for i in 0..3 {
650            let frame = bm.pin_mut(TEST_FILE, i).unwrap();
651            frame.mark_dirty();
652            bm.unpin(TEST_FILE, i);
653        }
654        bm.flush_all().unwrap();
655        assert_eq!(bm.stats().dirty_frames, 0);
656    }
657
658    // --- New tests for the three features ---
659
660    #[test]
661    fn test_mmap_read() {
662        let dir = tempfile::tempdir().unwrap();
663        let mm = Arc::new(MemoryManager::new(1024 * 1024));
664        let config = BufferManagerConfig {
665            max_memory: 256 * 1024,
666            page_size: DEFAULT_PAGE_SIZE,
667            use_mmap: true,
668            ..Default::default()
669        };
670        let mut bm = BufferManager::new(dir.path().to_path_buf(), mm, config);
671
672        // Write known data
673        let db_path = dir.path().join("mmap_test.db");
674        let mut data = vec![0u8; DEFAULT_PAGE_SIZE * 4];
675        for i in 0..DEFAULT_PAGE_SIZE {
676            data[i] = (i % 256) as u8;
677        }
678        std::fs::write(&db_path, &data).unwrap();
679        bm.register_file(TEST_FILE, db_path);
680
681        // Read page 0 via mmap
682        let frame = bm.pin(TEST_FILE, 0).unwrap();
683        assert_eq!(&frame.data[..], &data[..DEFAULT_PAGE_SIZE]);
684        bm.unpin(TEST_FILE, 0);
685
686        // Read page 1 via mmap
687        let frame = bm.pin(TEST_FILE, 1).unwrap();
688        assert_eq!(&frame.data[..], &data[DEFAULT_PAGE_SIZE..DEFAULT_PAGE_SIZE * 2]);
689        bm.unpin(TEST_FILE, 1);
690    }
691
692    #[test]
693    fn test_readahead_sequential() {
694        let dir = tempfile::tempdir().unwrap();
695        let mm = Arc::new(MemoryManager::new(1024 * 1024));
696        let config = BufferManagerConfig {
697            max_memory: 512 * 1024,
698            page_size: DEFAULT_PAGE_SIZE,
699            readahead: ReadaheadPolicy {
700                enabled: true,
701                window: 4,
702            },
703            ..Default::default()
704        };
705        let mut bm = BufferManager::new(dir.path().to_path_buf(), mm, config);
706
707        let db_path = dir.path().join("seq_test.db");
708        std::fs::write(&db_path, vec![42u8; DEFAULT_PAGE_SIZE * 20]).unwrap();
709        bm.register_file(TEST_FILE, db_path);
710
711        // Access page 0 — first access, no readahead
712        bm.pin(TEST_FILE, 0).unwrap();
713        bm.unpin(TEST_FILE, 0);
714        let faults_after_0 = bm.stats().page_faults;
715
716        // Access page 1 — sequential from 0, should prefetch pages 2..5
717        bm.pin(TEST_FILE, 1).unwrap();
718        bm.unpin(TEST_FILE, 1);
719        let faults_after_1 = bm.stats().page_faults;
720
721        // readahead of window=4 means pages 2,3,4,5 prefetched: total >= 6 faults
722        assert!(
723            faults_after_1 > faults_after_0 + 1,
724            "Expected readahead faults: after_page_0={}, after_page_1={}",
725            faults_after_0,
726            faults_after_1
727        );
728
729        // Pages 2-5 should now be in cache
730        for p in 2..=5 {
731            let k = BufferManager::key(TEST_FILE, p);
732            assert!(
733                bm.frames.contains_key(&k),
734                "Page {} should be in cache after readahead",
735                p
736            );
737        }
738
739        // Pinning page 2 again should NOT cause a new fault
740        let faults_before = bm.stats().page_faults;
741        bm.pin(TEST_FILE, 2).unwrap();
742        bm.unpin(TEST_FILE, 2);
743        assert_eq!(bm.stats().page_faults, faults_before);
744    }
745
746    #[test]
747    fn test_readahead_random() {
748        let dir = tempfile::tempdir().unwrap();
749        let mm = Arc::new(MemoryManager::new(1024 * 1024));
750        let config = BufferManagerConfig {
751            max_memory: 512 * 1024,
752            page_size: DEFAULT_PAGE_SIZE,
753            readahead: ReadaheadPolicy {
754                enabled: true,
755                window: 4,
756            },
757            ..Default::default()
758        };
759        let mut bm = BufferManager::new(dir.path().to_path_buf(), mm, config);
760
761        let db_path = dir.path().join("rand_test.db");
762        std::fs::write(&db_path, vec![0u8; DEFAULT_PAGE_SIZE * 20]).unwrap();
763        bm.register_file(TEST_FILE, db_path);
764
765        // Access page 0
766        bm.pin(TEST_FILE, 0).unwrap();
767        bm.unpin(TEST_FILE, 0);
768
769        // Access page 5 (random — NOT sequential from 0)
770        bm.pin(TEST_FILE, 5).unwrap();
771        bm.unpin(TEST_FILE, 5);
772        let faults = bm.stats().page_faults;
773        assert_eq!(faults, 2, "Random access should not trigger readahead");
774
775        // Pages 6,7,8,9 should NOT be in cache
776        for p in 6..=9 {
777            let k = BufferManager::key(TEST_FILE, p);
778            assert!(
779                !bm.frames.contains_key(&k),
780                "Page {} should NOT be in cache after random access",
781                p
782            );
783        }
784    }
785
786    #[test]
787    fn test_numa_detection() {
788        let numa = NumaInfo::detect();
789        assert!(
790            numa.num_nodes >= 1,
791            "NUMA detection should return at least 1 node, got {}",
792            numa.num_nodes
793        );
794    }
795}