Skip to main content

sqlrite/sql/pager/
allocator.rs

1//! Page allocator for `save_database` (SQLR-6).
2//!
3//! Replaces the bare `next_free_page: u32` counter that the staging code
4//! used to thread through every `stage_*_btree` function. The allocator
5//! draws from three pools, in order of preference:
6//!
7//! 1. **Per-table preferred pool** — pages the table previously occupied,
8//!    seeded by [`set_preferred`]. An unchanged table's stage produces
9//!    byte-identical pages at the same numbers, so the diff pager skips
10//!    every write for it.
11//! 2. **Global freelist** — pages dropped tables/indexes used to occupy
12//!    plus the trunk pages of the previously-persisted freelist.
13//! 3. **Extend** — `next_extend++`, monotonic past the current high water.
14//!
15//! After staging finishes, [`high_water`] is the new `page_count` and
16//! [`used`] enumerates every page actually written this save (so the
17//! caller can compute the new freelist as `old_live − used`).
18
19use std::collections::{HashSet, VecDeque};
20
21/// Hands out page numbers during a save.
22///
23/// Lifetime: one allocator per `save_database` call. Not thread-safe; not
24/// shared across saves.
25pub struct PageAllocator {
26    /// Pages available globally. Drained after the per-table pool is empty.
27    /// Stored as a VecDeque so callers can append (push_back) and we always
28    /// hand them out front-first for ascending-order determinism.
29    freelist: VecDeque<u32>,
30    /// The current table's preferred pool (its previous-rootpage pages).
31    /// Drained before [`freelist`]. Cleared between tables by
32    /// [`finish_preferred`].
33    preferred: VecDeque<u32>,
34    /// Next page number for fresh extension. Page 0 is the header, so
35    /// the first alloc always returns ≥ 1.
36    next_extend: u32,
37    /// Every page handed out this save. Used to compute the newly-freed
38    /// set after staging completes.
39    used: HashSet<u32>,
40}
41
42impl PageAllocator {
43    /// `freelist` carries the pages from the previously-persisted
44    /// freelist (sorted ascending by the caller). `next_extend` is
45    /// typically `1` for a brand-new save.
46    pub fn new(freelist: VecDeque<u32>, next_extend: u32) -> Self {
47        let mut alloc = Self {
48            freelist,
49            preferred: VecDeque::new(),
50            next_extend,
51            used: HashSet::new(),
52        };
53        // Defensive: a corrupt freelist could push the high-water mark
54        // higher than `next_extend` claims. Bump so we never hand out a
55        // duplicate page on extend.
56        let max_free = alloc.freelist.iter().copied().max().unwrap_or(0);
57        if max_free + 1 > alloc.next_extend {
58            alloc.next_extend = max_free + 1;
59        }
60        alloc
61    }
62
63    /// Seeds the per-table preferred pool. Drained on subsequent
64    /// [`allocate`] calls before any other source.
65    pub fn set_preferred(&mut self, mut pool: Vec<u32>) {
66        // Sort ascending so the order matches the linear staging order
67        // and unchanged tables get byte-identical leaves.
68        pool.sort_unstable();
69        pool.dedup();
70        // Filter out anything the allocator has already handed out
71        // (defensive — shouldn't happen but keeps the invariant tidy).
72        pool.retain(|p| !self.used.contains(p));
73        self.preferred = VecDeque::from(pool);
74    }
75
76    /// Empties the per-table preferred pool, returning any leftover
77    /// pages to the global freelist (they're now free again).
78    pub fn finish_preferred(&mut self) {
79        while let Some(p) = self.preferred.pop_front() {
80            if !self.used.contains(&p) {
81                self.freelist.push_back(p);
82            }
83        }
84    }
85
86    /// Returns the next page to write. Picks from preferred → freelist →
87    /// extend. Records the result in `used` and bumps `next_extend` if
88    /// the page came from one of the pools and was past the current
89    /// high water.
90    pub fn allocate(&mut self) -> u32 {
91        let page = if let Some(p) = self.preferred.pop_front() {
92            p
93        } else if let Some(p) = self.freelist.pop_front() {
94            p
95        } else {
96            let p = self.next_extend;
97            self.next_extend += 1;
98            p
99        };
100        if page >= self.next_extend {
101            self.next_extend = page + 1;
102        }
103        // A double-allocation is an internal bug; assert in debug.
104        debug_assert!(
105            !self.used.contains(&page),
106            "PageAllocator handed out page {page} twice"
107        );
108        self.used.insert(page);
109        page
110    }
111
112    /// Adds pages to the global freelist. Used to drop pages that the
113    /// caller traversed but didn't end up restaging (e.g., a dropped
114    /// table's leaves; the previous freelist's trunk pages).
115    ///
116    /// Bumps `next_extend` past any added page so the final page_count
117    /// covers freelist trunks even if they live past the highest used
118    /// payload page.
119    pub fn add_to_freelist(&mut self, pages: impl IntoIterator<Item = u32>) {
120        for p in pages {
121            // Skip pages already used (we already restaged them) or
122            // already on the list.
123            if !self.used.contains(&p) && !self.freelist.contains(&p) {
124                self.freelist.push_back(p);
125                if p + 1 > self.next_extend {
126                    self.next_extend = p + 1;
127                }
128            }
129        }
130    }
131
132    /// Page-count to publish in the new header. Equal to
133    /// `1 + max page handed out` after staging.
134    pub fn high_water(&self) -> u32 {
135        self.next_extend
136    }
137
138    /// Every page handed out this save.
139    pub fn used(&self) -> &HashSet<u32> {
140        &self.used
141    }
142
143    /// Snapshot of pages still on the global freelist (i.e., free pages
144    /// that need to be persisted into trunk pages). Sorted ascending so
145    /// the encoded freelist trunks are deterministic.
146    pub fn drain_freelist(&mut self) -> Vec<u32> {
147        let mut v: Vec<u32> = self.freelist.drain(..).collect();
148        v.sort_unstable();
149        v.dedup();
150        v
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn allocate_extends_when_pools_empty() {
160        let mut a = PageAllocator::new(VecDeque::new(), 1);
161        assert_eq!(a.allocate(), 1);
162        assert_eq!(a.allocate(), 2);
163        assert_eq!(a.allocate(), 3);
164        assert_eq!(a.high_water(), 4);
165    }
166
167    #[test]
168    fn preferred_pool_drains_first() {
169        let mut a = PageAllocator::new(VecDeque::from([8, 9]), 1);
170        a.set_preferred(vec![3, 4]);
171        assert_eq!(a.allocate(), 3);
172        assert_eq!(a.allocate(), 4);
173        // After preferred drains, freelist takes over.
174        assert_eq!(a.allocate(), 8);
175        assert_eq!(a.allocate(), 9);
176        // Then extend.
177        assert_eq!(a.allocate(), 10);
178    }
179
180    #[test]
181    fn freelist_drains_after_preferred() {
182        let mut a = PageAllocator::new(VecDeque::from([5, 7]), 1);
183        assert_eq!(a.allocate(), 5);
184        assert_eq!(a.allocate(), 7);
185        assert_eq!(a.allocate(), 8); // extend bumped because max free was 7
186    }
187
188    #[test]
189    fn finish_preferred_returns_leftovers_to_freelist() {
190        let mut a = PageAllocator::new(VecDeque::new(), 1);
191        a.set_preferred(vec![3, 4, 5]);
192        assert_eq!(a.allocate(), 3); // used 3
193        a.finish_preferred();
194        // Now 4 and 5 should be on the freelist.
195        assert_eq!(a.allocate(), 4);
196        assert_eq!(a.allocate(), 5);
197    }
198
199    #[test]
200    fn add_to_freelist_skips_already_used() {
201        let mut a = PageAllocator::new(VecDeque::new(), 1);
202        let p = a.allocate(); // 1
203        a.add_to_freelist([p, 5, 6]);
204        let drained = a.drain_freelist();
205        assert!(
206            !drained.contains(&p),
207            "used page should not land on freelist"
208        );
209        assert_eq!(drained, vec![5, 6]);
210    }
211
212    #[test]
213    fn next_extend_respects_max_free() {
214        // High pages on the freelist should bump next_extend so the
215        // allocator never collides with them on extend.
216        let mut a = PageAllocator::new(VecDeque::from([100]), 1);
217        // First alloc draws from freelist.
218        assert_eq!(a.allocate(), 100);
219        // Subsequent extend lands at 101, not 1.
220        assert_eq!(a.allocate(), 101);
221    }
222}