reddb_server/storage/btree/visibility_map.rs
1//! Visibility map — Fase 5 P2 building block.
2//!
3//! Provides a `VisibilityMap` data structure that tracks per-page
4//! "all-visible" status: a single bit per heap page recording
5//! whether every row on that page is visible to every concurrent
6//! transaction. When the bit is set, the planner can answer
7//! certain queries without ever fetching the heap row — index-
8//! only scans return the indexed columns directly.
9//!
10//! Mirrors PG's `visibilitymap.c` modulo features we don't have:
11//!
12//! - **Crash recovery**: PG's vmap is durable via WAL. Ours is
13//! memory-only for now; on restart every page resets to
14//! "not all-visible" and gets re-marked lazily as queries
15//! verify rows.
16//! - **All-frozen bit**: PG tracks a second bit per page
17//! ("all-frozen") used by the freeze map for vacuum
18//! optimisation. We omit it — reddb doesn't have freeze
19//! semantics yet.
20//! - **Concurrent updates**: PG uses LWLocks per buffer slot
21//! for fine-grained concurrency. Ours uses a single RwLock
22//! over the bitmap. Acceptable for Week 5; later weeks
23//! shard by page range when contention shows up.
24//!
25//! ## Why it matters
26//!
27//! Index-only scans are the killer perf win for "narrow"
28//! queries that select only indexed columns:
29//!
30//! ```text
31//! SELECT user_id FROM users WHERE email = ?
32//! ```
33//!
34//! With an index on `email` covering `user_id`, the planner can
35//! skip the heap fetch entirely. But that's only safe when the
36//! tuple's MVCC visibility hasn't changed since the last vacuum
37//! — i.e. the page is "all-visible".
38//!
39//! Without a vmap, every index-only scan candidate must double-
40//! check by fetching the heap row anyway, defeating the point.
41//! With a vmap, the per-page bit is a single memory access.
42//!
43//! ## Marking strategy
44//!
45//! - **Set** the all-visible bit when:
46//! - VACUUM determines every row on the page is visible to
47//! every active xmin (no in-flight inserts / updates).
48//! - A bulk-load operation atomically writes a new page where
49//! every tuple is committed.
50//! - **Clear** the all-visible bit when:
51//! - Any tuple on the page is updated, deleted, or inserted.
52//! - Even a no-op WAL replay: clearing is conservative.
53//!
54//! reddb's MVCC version chains live in the btree itself
55//! (`storage/btree/node.rs:300`), not in row headers, so the
56//! "page" concept here is an *abstract* page — call sites use
57//! the entity ID divided by `entries_per_page` (~256 for 8 KB
58//! pages) as the page index.
59
60use std::sync::RwLock;
61
62/// Per-page visibility tracking bitmap. Pages are indexed by a
63/// dense u32 — sparse table sizes can overshoot the bitmap and
64/// trigger lazy resize via `ensure_capacity`.
65pub struct VisibilityMap {
66 /// Bit-packed visibility bits, indexed by page number.
67 /// `bits[i / 64] & (1 << (i % 64))` set means page `i` is
68 /// all-visible.
69 bits: RwLock<Vec<u64>>,
70}
71
72impl VisibilityMap {
73 /// Create an empty visibility map. Initial capacity is
74 /// minimal; pages get added as `mark_all_visible` extends
75 /// the bitmap.
76 pub fn new() -> Self {
77 Self {
78 bits: RwLock::new(Vec::new()),
79 }
80 }
81
82 /// Pre-allocate room for `pages` pages. Useful when the
83 /// caller knows the table size up-front (e.g. ANALYZE
84 /// freshly imported data).
85 pub fn with_capacity(pages: u32) -> Self {
86 let words = (pages as usize).div_ceil(64);
87 Self {
88 bits: RwLock::new(vec![0u64; words]),
89 }
90 }
91
92 /// Returns true when `page` is marked all-visible. Page
93 /// indexes beyond the current bitmap return false (treated
94 /// as "unknown / not-visible").
95 pub fn is_all_visible(&self, page: u32) -> bool {
96 let bits = self.bits.read().expect("vmap rwlock poisoned");
97 let word_idx = page as usize / 64;
98 if word_idx >= bits.len() {
99 return false;
100 }
101 let bit_idx = page as usize % 64;
102 (bits[word_idx] >> bit_idx) & 1 == 1
103 }
104
105 /// Mark `page` as all-visible. Extends the bitmap on demand.
106 /// Idempotent — calling twice has the same effect as calling
107 /// once.
108 pub fn mark_all_visible(&self, page: u32) {
109 let mut bits = self.bits.write().expect("vmap rwlock poisoned");
110 let word_idx = page as usize / 64;
111 if word_idx >= bits.len() {
112 bits.resize(word_idx + 1, 0);
113 }
114 let bit_idx = page as usize % 64;
115 bits[word_idx] |= 1u64 << bit_idx;
116 }
117
118 /// Clear the all-visible bit for `page`. Called by every
119 /// write path that touches the page (insert / update /
120 /// delete). Cheap no-op when the page wasn't marked or
121 /// doesn't exist in the bitmap yet.
122 pub fn clear_all_visible(&self, page: u32) {
123 let mut bits = self.bits.write().expect("vmap rwlock poisoned");
124 let word_idx = page as usize / 64;
125 if word_idx >= bits.len() {
126 // Page doesn't exist yet — implicit clear, nothing
127 // to do.
128 return;
129 }
130 let bit_idx = page as usize % 64;
131 bits[word_idx] &= !(1u64 << bit_idx);
132 }
133
134 /// Number of all-visible pages currently tracked.
135 pub fn all_visible_count(&self) -> u64 {
136 let bits = self.bits.read().expect("vmap rwlock poisoned");
137 bits.iter().map(|w| w.count_ones() as u64).sum()
138 }
139
140 /// Total number of pages the bitmap can address (capacity,
141 /// not "set count"). Mostly useful for diagnostics and
142 /// memory accounting.
143 pub fn capacity_pages(&self) -> u64 {
144 let bits = self.bits.read().expect("vmap rwlock poisoned");
145 (bits.len() as u64) * 64
146 }
147
148 /// Reset the entire bitmap to "not all-visible". Used by
149 /// crash recovery and DROP TABLE.
150 pub fn clear(&self) {
151 let mut bits = self.bits.write().expect("vmap rwlock poisoned");
152 for w in bits.iter_mut() {
153 *w = 0;
154 }
155 }
156
157 /// Bulk-mark a contiguous range of pages [`start`, `end`)
158 /// as all-visible. Used by VACUUM after a successful sweep
159 /// of a page range.
160 pub fn mark_range_visible(&self, start: u32, end: u32) {
161 if start >= end {
162 return;
163 }
164 let mut bits = self.bits.write().expect("vmap rwlock poisoned");
165 let last_word = (end as usize - 1) / 64;
166 if last_word >= bits.len() {
167 bits.resize(last_word + 1, 0);
168 }
169 for page in start..end {
170 let word_idx = page as usize / 64;
171 let bit_idx = page as usize % 64;
172 bits[word_idx] |= 1u64 << bit_idx;
173 }
174 }
175
176 /// Iterate over (page, all_visible_bool) for the first
177 /// `limit_pages` pages. Diagnostic / debugging helper.
178 pub fn snapshot(&self, limit_pages: u32) -> Vec<(u32, bool)> {
179 let bits = self.bits.read().expect("vmap rwlock poisoned");
180 let mut out = Vec::with_capacity(limit_pages as usize);
181 for page in 0..limit_pages {
182 let word_idx = page as usize / 64;
183 let visible = if word_idx < bits.len() {
184 let bit_idx = page as usize % 64;
185 (bits[word_idx] >> bit_idx) & 1 == 1
186 } else {
187 false
188 };
189 out.push((page, visible));
190 }
191 out
192 }
193}
194
195impl Default for VisibilityMap {
196 fn default() -> Self {
197 Self::new()
198 }
199}
200
201/// Helper: convert an entity ID to a page index using the given
202/// `rows_per_page` constant. The btree's MVCC version chain
203/// doesn't actually map onto fixed-size pages, so this is an
204/// abstraction layer that lets the planner reason about
205/// "page-shaped" visibility regions without committing to a
206/// specific physical layout.
207pub fn page_of(entity_id: u64, rows_per_page: u32) -> u32 {
208 if rows_per_page == 0 {
209 return 0;
210 }
211 (entity_id / rows_per_page as u64) as u32
212}
213
214/// Phase 3.5 wiring callback. The btree write path calls this
215/// after every insert / update / delete to clear the all-visible
216/// bit for the affected page. Centralised so a single function
217/// can be hooked into multiple write call sites without each
218/// rewriting the page-of math.
219pub fn mark_dirty_after_write(vmap: &VisibilityMap, entity_id: u64, rows_per_page: u32) {
220 let page = page_of(entity_id, rows_per_page);
221 vmap.clear_all_visible(page);
222}
223
224/// Phase 3.5 wiring callback for VACUUM / GC. After confirming
225/// every row in a page range is visible to all active txns, the
226/// GC sweeps this with the (start, end) page bounds.
227pub fn mark_clean_after_gc(vmap: &VisibilityMap, start_page: u32, end_page: u32) {
228 vmap.mark_range_visible(start_page, end_page);
229}