Skip to main content

akar_storage/
index.rs

1//! Hash index for primary key lookups — on-disk persistent + in-memory cache.
2//!
3//! # Architecture
4//!
5//! Two-layer index:
6//! - **L1 (cache):** `HashMap<K, u64>` — fast in-memory lookups.
7//! - **L2 (persistent):** `OnDiskHashIndex` — page-based storage via `BufferManager`.
8//!
9//! On `flush()`, L2 is written to disk. On startup, L1 is rebuilt by scanning L2.
10//! This gives O(1) lookups at runtime while ensuring data survives restarts.
11//!
12//! # On-Disk Page Layout
13//!
14//! Each page stores one "bucket" of the hash table:
15//!
16//! ```text
17//! [header: 12 bytes] [slots...]
18//!
19//! header:
20//!   num_slots: u32        — total slots in this page
21//!   num_entries: u32      — used slots
22//!   collision_next: u32   — page number of next overflow page (0 = none)
23//!
24//! slot layout (variable width per key type):
25//!   [key_bytes: key_size] [value: u64 LE] [flags: u8]
26//!   flags: bit 0 = occupied, bit 1 = deleted
27//! ```
28//!
29//! The slot width is fixed per index instance. Keys longer than ~64 bytes
30//! store a u64 hash instead and use the in-memory L1 for collision resolution.
31
32use crate::buffer_manager::BufferManager;
33use hashbrown::HashMap;
34use std::hash::{Hash, Hasher};
35use std::marker::PhantomData;
36use std::path::PathBuf;
37
38/// Default number of slots per page.
39const SLOTS_PER_PAGE: u32 = 64;
40
41/// Size of the page header in bytes.
42const PAGE_HEADER_SIZE: usize = 12;
43
44/// Per-slot flags byte.
45const FLAG_OCCUPIED: u8 = 0x01;
46const FLAG_DELETED: u8 = 0x02;
47
48// ---------------------------------------------------------------------------
49// In-memory L1 cache — wraps `HashMap`
50// ---------------------------------------------------------------------------
51
52/// A hash index mapping a key to a row offset in a table.
53/// L1 cache layer — fast in-memory lookups.
54#[derive(Debug, Clone)]
55pub struct HashIndex<K: Hash + Eq + Clone> {
56    entries: HashMap<K, u64>,
57}
58
59impl<K: Hash + Eq + Clone> HashIndex<K> {
60    pub fn new() -> Self {
61        Self {
62            entries: HashMap::new(),
63        }
64    }
65
66    pub fn insert(&mut self, key: K, row_offset: u64) {
67        self.entries.insert(key, row_offset);
68    }
69
70    pub fn lookup(&self, key: &K) -> Option<u64> {
71        self.entries.get(key).copied()
72    }
73
74    pub fn delete(&mut self, key: &K) {
75        self.entries.remove(key);
76    }
77
78    pub fn len(&self) -> usize {
79        self.entries.len()
80    }
81
82    pub fn is_empty(&self) -> bool {
83        self.entries.is_empty()
84    }
85
86    /// Remove all entries.
87    pub fn clear(&mut self) {
88        self.entries.clear();
89    }
90
91    /// Iterate over all (key, offset) entries.
92    pub fn iter(&self) -> impl Iterator<Item = (&K, &u64)> {
93        self.entries.iter()
94    }
95}
96
97impl<K: Hash + Eq + Clone> Default for HashIndex<K> {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103// ---------------------------------------------------------------------------
104// On-disk persistent L2 index
105// ---------------------------------------------------------------------------
106
107/// Trait for types that can be serialized into fixed-size byte arrays
108/// for on-disk hash index storage.
109pub trait IndexKey: Clone + std::fmt::Debug {
110    /// Size of the serialized key in bytes.
111    fn key_size() -> usize;
112
113    /// Serialize this key into the provided byte slice (must be `key_size()` bytes).
114    fn serialize_into(&self, buf: &mut [u8]);
115
116    /// Deserialize a key from a byte slice (must be `key_size()` bytes).
117    fn deserialize_from(buf: &[u8]) -> Self;
118}
119
120// Implement for the most common key type: String (variable-length → store hash)
121impl IndexKey for String {
122    fn key_size() -> usize {
123        8 // store u64 hash for variable-length keys
124    }
125
126    fn serialize_into(&self, buf: &mut [u8]) {
127        let h = {
128            let mut hasher = std::collections::hash_map::DefaultHasher::new();
129            self.hash(&mut hasher);
130            hasher.finish()
131        };
132        buf.copy_from_slice(&h.to_le_bytes());
133    }
134
135    fn deserialize_from(buf: &[u8]) -> Self {
136        // We can't recover the original string from a hash.
137        // For String keys, the L1 cache is the canonical store.
138        // The on-disk index is used only for persistence/recovery.
139        let h = u64::from_le_bytes(buf[..8].try_into().unwrap());
140        format!("__hash_{h}") // placeholder; real recovery uses L1 cache
141    }
142}
143
144impl IndexKey for u64 {
145    fn key_size() -> usize {
146        8
147    }
148
149    fn serialize_into(&self, buf: &mut [u8]) {
150        buf.copy_from_slice(&self.to_le_bytes());
151    }
152
153    fn deserialize_from(buf: &[u8]) -> Self {
154        u64::from_le_bytes(buf[..8].try_into().unwrap())
155    }
156}
157
158impl IndexKey for i64 {
159    fn key_size() -> usize {
160        8
161    }
162
163    fn serialize_into(&self, buf: &mut [u8]) {
164        buf.copy_from_slice(&self.to_le_bytes());
165    }
166
167    fn deserialize_from(buf: &[u8]) -> Self {
168        i64::from_le_bytes(buf[..8].try_into().unwrap())
169    }
170}
171
172impl IndexKey for u32 {
173    fn key_size() -> usize {
174        4
175    }
176
177    fn serialize_into(&self, buf: &mut [u8]) {
178        buf.copy_from_slice(&self.to_le_bytes());
179    }
180
181    fn deserialize_from(buf: &[u8]) -> Self {
182        u32::from_le_bytes(buf[..8].try_into().unwrap())
183    }
184}
185
186impl IndexKey for i32 {
187    fn key_size() -> usize {
188        4
189    }
190
191    fn serialize_into(&self, buf: &mut [u8]) {
192        buf.copy_from_slice(&self.to_le_bytes());
193    }
194
195    fn deserialize_from(buf: &[u8]) -> Self {
196        i32::from_le_bytes(buf[..8].try_into().unwrap())
197    }
198}
199
200/// On-disk persistent hash index backed by the BufferManager.
201///
202/// # Type Parameters
203///
204/// - `K`: The key type. Must implement `IndexKey` for serialization.
205///
206/// # Thread Safety
207///
208/// This struct is `Send + Sync` when wrapped in `Arc<Mutex<...>>`.
209#[derive(Debug)]
210pub struct OnDiskHashIndex<K: IndexKey> {
211    /// Name of the index file (used as BufferManager file identifier).
212    file_name: String,
213    /// Number of key bytes per slot.
214    key_size: usize,
215    /// Total slot size = key_size + 8 (value) + 1 (flags).
216    slot_size: usize,
217    /// Number of slots per page.
218    slots_per_page: u32,
219    /// L1 in-memory cache for O(1) lookups.
220    cache: HashMap<K, u64>,
221    /// Number of pages allocated.
222    num_pages: u32,
223    _phantom: PhantomData<K>,
224}
225
226impl<K: IndexKey + Hash + Eq> OnDiskHashIndex<K> {
227    /// Create a new on-disk hash index.
228    ///
229    /// If `bm` is `None`, operates purely in-memory (L1 cache only).
230    /// The `file_name` is used to register the index file with the BufferManager.
231    pub fn new(file_name: &str) -> Self {
232        let key_size = K::key_size();
233        let slot_size = key_size + 8 + 1; // key + value(u64) + flags
234        Self {
235            file_name: file_name.to_string(),
236            key_size,
237            slot_size,
238            slots_per_page: SLOTS_PER_PAGE,
239            cache: HashMap::new(),
240            num_pages: 0,
241            _phantom: PhantomData,
242        }
243    }
244
245    /// Rebuild the L1 cache from on-disk pages.
246    ///
247    /// Call this on database startup after the BufferManager is initialized.
248    pub fn rebuild_from_disk(&mut self, bm: &mut BufferManager) -> std::io::Result<()> {
249        self.cache.clear();
250
251        // Register the index file if not already registered.
252        let file_path = PathBuf::from(&self.file_name);
253        let db_path = file_path.parent().unwrap_or(&file_path);
254        let full_path = db_path.join(format!("{}.idx", self.file_name));
255        if !bm.is_file_registered(&self.file_name) {
256            bm.register_file(&self.file_name, full_path);
257        }
258
259        for page_num in 0..self.num_pages as u64 {
260            let frame = bm.pin(&self.file_name, page_num)?;
261            let data = &frame.data;
262
263            let num_slots = u32::from_le_bytes(data[0..4].try_into().unwrap());
264            let num_entries = u32::from_le_bytes(data[4..8].try_into().unwrap());
265            let _collision_next = u32::from_le_bytes(data[8..12].try_into().unwrap());
266
267            if num_entries == 0 {
268                bm.unpin(&self.file_name, page_num);
269                continue;
270            }
271
272            for slot_idx in 0..num_slots as usize {
273                let offset = PAGE_HEADER_SIZE + slot_idx * self.slot_size;
274                if offset + self.slot_size > data.len() {
275                    break;
276                }
277                let flags = data[offset + self.key_size + 8];
278                if flags & FLAG_OCCUPIED == 0 || flags & FLAG_DELETED != 0 {
279                    continue;
280                }
281
282                // Read key bytes
283                let mut key_buf = vec![0u8; self.key_size];
284                key_buf.copy_from_slice(&data[offset..offset + self.key_size]);
285
286                // Read value
287                let value = u64::from_le_bytes(
288                    data[offset + self.key_size..offset + self.key_size + 8]
289                        .try_into()
290                        .unwrap(),
291                );
292
293                // For fixed-size keys, we can deserialize directly.
294                // For hash-based keys (like String), we rely on the fact that
295                // rebuild_from_disk is only used at startup when L1 is empty.
296                // The L1 entries will be re-inserted during WAL replay.
297                if self.key_size <= 8 {
298                    let key = K::deserialize_from(&key_buf);
299                    self.cache.insert(key, value);
300                }
301            }
302
303            bm.unpin(&self.file_name, page_num);
304        }
305
306        Ok(())
307    }
308
309    /// Get the number of entries.
310    pub fn len(&self) -> usize {
311        self.cache.len()
312    }
313
314    pub fn is_empty(&self) -> bool {
315        self.cache.is_empty()
316    }
317
318    // ---- Slot helpers ----
319
320    /// Compute the page number for a key.
321    fn hash_to_page(&self, key: &K) -> u32 {
322        let mut hasher = std::collections::hash_map::DefaultHasher::new();
323        key.hash(&mut hasher);
324        let hash = hasher.finish();
325        if self.num_pages == 0 {
326            0
327        } else {
328            (hash % self.num_pages as u64) as u32
329        }
330    }
331
332    /// Serialize a key into a byte buffer.
333    fn serialize_key(key: &K, buf: &mut [u8]) {
334        key.serialize_into(buf);
335    }
336
337    /// Compute the byte offset of a slot within a page.
338    fn slot_offset(&self, slot_idx: u32) -> usize {
339        PAGE_HEADER_SIZE + (slot_idx as usize) * self.slot_size
340    }
341
342    // ---- Public API ----
343
344    /// Look up a key in the L1 cache (fast path).
345    ///
346    /// For write operations during query execution, this is always used.
347    pub fn lookup_cached(&self, key: &K) -> Option<u64> {
348        self.cache.get(key).copied()
349    }
350
351    /// Look up a key, potentially reading from the on-disk index.
352    ///
353    /// If the key is in L1 cache, returns immediately.
354    /// Otherwise, scans the on-disk bucket page.
355    pub fn lookup(&self, key: &K, bm: &mut BufferManager) -> std::io::Result<Option<u64>> {
356        // Fast path: L1 cache hit
357        if let Some(offset) = self.cache.get(key) {
358            return Ok(Some(*offset));
359        }
360
361        // Slow path: scan on-disk pages
362        if self.num_pages == 0 {
363            return Ok(None);
364        }
365
366        let page_num = self.hash_to_page(key) as u64;
367        let frame = bm.pin(&self.file_name, page_num)?;
368        let data = &frame.data;
369
370        let _num_slots = u32::from_le_bytes(data[0..4].try_into().unwrap());
371        let mut num_entries = u32::from_le_bytes(data[4..8].try_into().unwrap());
372        let mut collision_next = u32::from_le_bytes(data[8..12].try_into().unwrap());
373
374        let mut result = None;
375        let mut current_page = page_num;
376
377        loop {
378            if num_entries == 0 {
379                break;
380            }
381
382            let mut key_buf = vec![0u8; self.key_size];
383            let page_frame = bm.pin(&self.file_name, current_page)?;
384            let page_data = &page_frame.data;
385
386            let nslots = u32::from_le_bytes(page_data[0..4].try_into().unwrap());
387            for slot_idx in 0..nslots as usize {
388                let offset = PAGE_HEADER_SIZE + slot_idx * self.slot_size;
389                if offset + self.slot_size > page_data.len() {
390                    break;
391                }
392                let flags = page_data[offset + self.key_size + 8];
393                if flags & FLAG_OCCUPIED == 0 || flags & FLAG_DELETED != 0 {
394                    continue;
395                }
396
397                key_buf.copy_from_slice(&page_data[offset..offset + self.key_size]);
398                let candidate = K::deserialize_from(&key_buf);
399
400                if &candidate == key {
401                    let value = u64::from_le_bytes(
402                        page_data[offset + self.key_size..offset + self.key_size + 8]
403                            .try_into()
404                            .unwrap(),
405                    );
406                    result = Some(value);
407                    bm.unpin(&self.file_name, current_page);
408                    break;
409                }
410            }
411
412            bm.unpin(&self.file_name, current_page);
413
414            if result.is_some() {
415                break;
416            }
417
418            if collision_next == 0 {
419                break;
420            }
421            current_page = collision_next as u64;
422            let next_frame = bm.pin(&self.file_name, current_page)?;
423            let next_data = &next_frame.data;
424            num_entries = u32::from_le_bytes(next_data[4..8].try_into().unwrap());
425            collision_next = u32::from_le_bytes(next_data[8..12].try_into().unwrap());
426            bm.unpin(&self.file_name, current_page);
427        }
428
429        // Update cache for future lookups
430        // L1 cache is updated on insert(); on-disk scan results are not cached
431        // to avoid stale entries. The caller should ensure cache consistency.
432
433        bm.unpin(&self.file_name, page_num);
434        Ok(result)
435    }
436
437    /// Insert a key-value pair.
438    ///
439    /// Writes to L1 cache immediately. The on-disk write happens on `flush()`.
440    pub fn insert(&mut self, key: K, value: u64) {
441        self.cache.insert(key, value);
442    }
443
444    /// Delete a key.
445    pub fn delete(&mut self, key: &K) {
446        self.cache.remove(key);
447    }
448
449    /// Flush all cached entries to the on-disk hash index.
450    ///
451    /// This writes all key-value pairs from L1 cache into the BufferManager
452    /// page-structured hash table. After this, the index is durably stored.
453    pub fn flush(&mut self, bm: &mut BufferManager) -> std::io::Result<()> {
454        if self.cache.is_empty() {
455            if self.num_pages > 0 {
456                // Write an empty header page
457                let frame = bm.pin_mut(&self.file_name, 0)?;
458                let data = &mut frame.data;
459                data[0..4].copy_from_slice(&0u32.to_le_bytes()); // num_slots
460                data[4..8].copy_from_slice(&0u32.to_le_bytes()); // num_entries
461                data[8..12].copy_from_slice(&0u32.to_le_bytes()); // collision_next
462                frame.mark_dirty();
463                bm.unpin(&self.file_name, 0);
464            }
465            return bm.flush_all();
466        }
467
468        // Determine number of pages needed: ceil(entries / slots_per_page)
469        let num_entries = self.cache.len() as u32;
470        let pages_needed = num_entries.div_ceil(self.slots_per_page);
471        let pages_needed = pages_needed.max(1);
472
473        // Allocate pages if needed
474        if pages_needed > self.num_pages {
475            self.num_pages = pages_needed;
476        }
477
478        // Write header for each page
479        for page_num in 0..self.num_pages as u64 {
480            let frame = bm.pin_mut(&self.file_name, page_num)?;
481            let data = &mut frame.data;
482
483            // Clear the page
484            data.fill(0);
485
486            // Write header
487            data[0..4].copy_from_slice(&self.slots_per_page.to_le_bytes());
488            data[4..8].copy_from_slice(&0u32.to_le_bytes()); // num_entries (updated below)
489            data[8..12].copy_from_slice(&0u32.to_le_bytes()); // collision_next
490
491            frame.mark_dirty();
492            bm.unpin(&self.file_name, page_num);
493        }
494
495        // Distribute entries across pages by hash
496        if self.num_pages == 0 {
497            return Ok(());
498        }
499
500        // Count entries per page and write slots
501        let mut page_counts = vec![0u32; self.num_pages as usize];
502
503        // First pass: count
504        for (key, _value) in self.cache.iter() {
505            let page = self.hash_to_page(key) as usize;
506            page_counts[page] = page_counts[page].saturating_add(1);
507        }
508
509        // Second pass: write entries
510        let mut page_cursors = vec![0u32; self.num_pages as usize];
511
512        for (key, value) in self.cache.iter() {
513            let page = self.hash_to_page(key) as u64;
514            let cursor = &mut page_cursors[page as usize];
515
516            let frame = bm.pin_mut(&self.file_name, page)?;
517            let data = &mut frame.data;
518
519            let slot_offset = self.slot_offset(*cursor);
520            if slot_offset + self.slot_size <= data.len() {
521                // Write key
522                let mut key_buf = vec![0u8; self.key_size];
523                Self::serialize_key(key, &mut key_buf);
524                data[slot_offset..slot_offset + self.key_size].copy_from_slice(&key_buf);
525
526                // Write value
527                data[slot_offset + self.key_size..slot_offset + self.key_size + 8]
528                    .copy_from_slice(&value.to_le_bytes());
529
530                // Write flags
531                data[slot_offset + self.key_size + 8] = FLAG_OCCUPIED;
532
533                // Update num_entries in header
534                let current = u32::from_le_bytes(data[4..8].try_into().unwrap());
535                data[4..8].copy_from_slice(&(current + 1).to_le_bytes());
536            }
537
538            frame.mark_dirty();
539            bm.unpin(&self.file_name, page);
540
541            *cursor += 1;
542        }
543
544        // Flush all dirty pages to disk
545        bm.flush_all()
546    }
547
548    /// Iterate over all cached entries.
549    pub fn iter(&self) -> impl Iterator<Item = (&K, &u64)> {
550        self.cache.iter()
551    }
552}
553
554impl<K: IndexKey + Hash + Eq> Default for OnDiskHashIndex<K> {
555    fn default() -> Self {
556        Self::new("default")
557    }
558}
559
560// ---------------------------------------------------------------------------
561// Tests
562// ---------------------------------------------------------------------------
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use crate::buffer_manager::BufferManagerConfig;
568    use crate::page::DEFAULT_PAGE_SIZE;
569    use akar_common::memory::MemoryManager;
570    use std::sync::Arc;
571
572    fn create_test_bm() -> (BufferManager, tempfile::TempDir) {
573        let dir = tempfile::tempdir().unwrap();
574        let mm = Arc::new(MemoryManager::new(1024 * 1024));
575        let config = BufferManagerConfig {
576            max_memory: 256 * 1024,
577            page_size: DEFAULT_PAGE_SIZE,
578            ..Default::default()
579        };
580        let mut bm = BufferManager::new(dir.path().to_path_buf(), mm, config);
581        let idx_path = dir.path().join("test_index.idx");
582        std::fs::write(&idx_path, vec![0u8; 8192 * 10]).unwrap();
583        bm.register_file("test_index", idx_path);
584        (bm, dir)
585    }
586
587    #[test]
588    fn test_hash_index_in_memory() {
589        let mut idx: HashIndex<String> = HashIndex::new();
590        idx.insert("Alice".to_string(), 0);
591        idx.insert("Bob".to_string(), 1);
592        assert_eq!(idx.lookup(&"Alice".to_string()), Some(0));
593        assert_eq!(idx.lookup(&"Bob".to_string()), Some(1));
594        assert_eq!(idx.lookup(&"Charlie".to_string()), None);
595        assert_eq!(idx.len(), 2);
596    }
597
598    #[test]
599    fn test_hash_index_delete() {
600        let mut idx: HashIndex<String> = HashIndex::new();
601        idx.insert("X".to_string(), 42);
602        assert_eq!(idx.lookup(&"X".to_string()), Some(42));
603        idx.delete(&"X".to_string());
604        assert_eq!(idx.lookup(&"X".to_string()), None);
605        assert!(idx.is_empty());
606    }
607
608    #[test]
609    fn test_on_disk_basic_insert_and_lookup() {
610        let (mut bm, _dir) = create_test_bm();
611        let mut idx: OnDiskHashIndex<u64> = OnDiskHashIndex::new("test_index");
612
613        // Insert via L1 cache
614        idx.insert(100u64, 0);
615        idx.insert(200u64, 1);
616        idx.insert(300u64, 2);
617
618        // Flush to disk
619        idx.flush(&mut bm).unwrap();
620
621        // Lookup via L1 cache
622        assert_eq!(idx.lookup_cached(&100), Some(0));
623        assert_eq!(idx.lookup_cached(&200), Some(1));
624        assert_eq!(idx.lookup_cached(&300), Some(2));
625        assert_eq!(idx.lookup_cached(&999), None);
626
627        // Lookup via on-disk
628        assert_eq!(idx.lookup(&100, &mut bm).unwrap(), Some(0));
629        assert_eq!(idx.lookup(&200, &mut bm).unwrap(), Some(1));
630        assert_eq!(idx.lookup(&999, &mut bm).unwrap(), None);
631    }
632
633    #[test]
634    fn test_on_disk_multiple_pages() {
635        let (mut bm, _dir) = create_test_bm();
636        let mut idx: OnDiskHashIndex<u64> = OnDiskHashIndex::new("test_index");
637
638        // Insert many entries to force multiple pages
639        for i in 0..200u64 {
640            idx.insert(i * 1000, i);
641        }
642
643        idx.flush(&mut bm).unwrap();
644
645        // Verify num_pages > 1
646        assert!(idx.num_pages > 1, "Should have multiple pages for 200 entries");
647
648        // Verify all entries are found
649        for i in 0..200u64 {
650            assert_eq!(idx.lookup_cached(&(i * 1000)), Some(i));
651        }
652    }
653
654    #[test]
655    fn test_on_disk_delete() {
656        let (mut bm, _dir) = create_test_bm();
657        let mut idx: OnDiskHashIndex<u64> = OnDiskHashIndex::new("test_index");
658
659        idx.insert(42u64, 0);
660        idx.insert(99u64, 1);
661        idx.flush(&mut bm).unwrap();
662
663        assert_eq!(idx.lookup_cached(&42), Some(0));
664
665        // Delete from L1
666        idx.delete(&42);
667        assert_eq!(idx.lookup_cached(&42), None);
668
669        // Re-flush — now entry 42 shouldn't be on disk
670        idx.flush(&mut bm).unwrap();
671        assert_eq!(idx.lookup(&42, &mut bm).unwrap(), None);
672    }
673
674    #[test]
675    fn test_on_disk_rebuild_from_disk() {
676        let (mut bm, dir) = create_test_bm();
677        let mut idx: OnDiskHashIndex<u64> = OnDiskHashIndex::new("test_index");
678
679        // Insert and flush (writes via BM to disk)
680        for i in 0..50u64 {
681            idx.insert(i, i);
682        }
683        idx.flush(&mut bm).unwrap();
684        assert_eq!(idx.len(), 50);
685        let pages_used = idx.num_pages;
686        assert!(pages_used > 0, "Should have at least 1 page for 50 entries");
687
688        // Verify data on disk by reading directly
689        let file_path = dir.path().join("test_index.idx");
690        let file_data = std::fs::read(&file_path).unwrap();
691        let header_num_slots = u32::from_le_bytes(file_data[0..4].try_into().unwrap());
692        let header_num_entries = u32::from_le_bytes(file_data[4..8].try_into().unwrap());
693        assert_eq!(header_num_slots, 64, "Should have 64 slots per page");
694        assert_eq!(header_num_entries, 50, "Should have 50 entries on disk");
695
696        // Simulate restart: create a new BM pointing at the same file.
697        let mut bm2 = BufferManager::new(
698            dir.path().to_path_buf(),
699            Arc::new(MemoryManager::new(1024 * 1024)),
700            BufferManagerConfig::default(),
701        );
702        bm2.register_file("test_index", file_path);
703
704        let mut idx2: OnDiskHashIndex<u64> = OnDiskHashIndex::new("test_index");
705        idx2.num_pages = pages_used;
706        idx2.rebuild_from_disk(&mut bm2).unwrap();
707
708        // After rebuild, L1 cache should be populated
709        assert_eq!(idx2.len(), 50);
710        for i in 0..50u64 {
711            assert_eq!(idx2.lookup_cached(&i), Some(i));
712        }
713    }
714
715    #[test]
716    fn test_on_disk_empty_index() {
717        let (mut bm, _dir) = create_test_bm();
718        let mut idx: OnDiskHashIndex<u64> = OnDiskHashIndex::new("test_index");
719
720        // Flush an empty index
721        idx.flush(&mut bm).unwrap();
722        assert_eq!(idx.len(), 0);
723        assert!(idx.is_empty());
724    }
725
726    #[test]
727    fn test_on_disk_large_entries() {
728        let (mut bm, _dir) = create_test_bm();
729        let mut idx: OnDiskHashIndex<u64> = OnDiskHashIndex::new("test_index");
730
731        // Insert 500 entries
732        for i in 0..500u64 {
733            idx.insert(i, i + 1000);
734        }
735        idx.flush(&mut bm).unwrap();
736
737        // Verify all
738        for i in 0..500u64 {
739            assert_eq!(idx.lookup_cached(&i), Some(i + 1000));
740        }
741    }
742
743    #[test]
744    fn test_on_disk_collision_handling() {
745        let (mut bm, _dir) = create_test_bm();
746        // Use a small number of pages to force collisions
747        let mut idx: OnDiskHashIndex<u64> = OnDiskHashIndex::new("test_index");
748        idx.num_pages = 2; // Only 2 pages → many collisions for distinct values
749
750        for i in 0..100u64 {
751            idx.insert(i, i * 10);
752        }
753        idx.flush(&mut bm).unwrap();
754
755        // All entries should still be findable
756        for i in 0..100u64 {
757            assert_eq!(idx.lookup_cached(&i), Some(i * 10));
758        }
759    }
760}