Skip to main content

chisel/
defrag.rs

1// defrag.rs — Maintenance pass that rewrites live values to compact storage.
2//
3// Role in system: a top-layer utility built entirely on the public
4// `TransactionManager` surface (`handles`, `read`, `update`). It owns no
5// page-level logic; all compaction is expressed as reads and updates that
6// the transaction layer then routes through shadow paging.
7//
8// Why this is safe — the stable-handle invariant:
9//   The whole point of the handle-table indirection is that a handle's u64
10//   identity is decoupled from the (page, slot) it currently lives in. An
11//   `update(handle, value)` is guaranteed to preserve `handle` while moving
12//   the value to a freshly allocated slot and rewriting the handle-table
13//   entry via COW. Therefore, reading a value and immediately writing it
14//   back under the same handle is an observable no-op for callers but
15//   relocates the physical storage — exactly what a compactor needs.
16//   This module relies on that invariant from `transaction.rs` /
17//   `handle_table.rs`; if it were ever weakened, defrag would silently
18//   corrupt references.
19//
20// Transactionality:
21//   Defrag runs *inside* the caller's active transaction. It does not
22//   begin, commit, or rollback. This means (a) a defrag pass is atomic
23//   with any other work in the same transaction, (b) a crash or rollback
24//   mid-defrag leaves the previous committed state intact — the shadow
25//   pages written during defrag simply become unreachable, and (c) the
26//   caller is responsible for calling `commit()` to actually shrink the
27//   live working set.
28//
29// Selective defrag (ISSUES.md R3):
30//   Since R1 lets data pages pack multiple values, an `update(handle, value)`
31//   under R1 naturally relocates the value into the transaction's insert
32//   cursor — compacting as a side effect. R3 uses that property by only
33//   targeting handles that live in SPARSE data pages (pages whose live-slot
34//   count is a small fraction of the densest page), leaving dense pages
35//   alone. Non-selective defrag still works correctly but wastes I/O
36//   moving values that are already well-packed.
37//
38// Stat accuracy (ISSUES.md I17):
39//   `pages_examined` now counts UNIQUE sparse pages touched during the
40//   sweep (via a HashSet) rather than per-value. `pages_freed` is the
41//   count of pages that held a live slot at the START of the sweep but
42//   none at the END — the set difference of the `data_page_ids_snapshot`
43//   taken before and after, NOT a net count delta. Net count is the wrong
44//   metric here: a relocation drains a sparse source page while filling a
45//   dense destination, so the live-page count can barely move even though a
46//   page was genuinely emptied and returned to the freemap by the R2 commit
47//   path.
48
49use std::collections::HashSet;
50
51use crate::error::Result;
52use crate::page::PAGE_ID_NONE;
53use crate::transaction::TransactionManager;
54
55/// Knobs for a defrag pass.
56///
57/// `sparse_threshold`: fill-density fraction (live_slots / stored_slots)
58/// below which a page is treated as sparse and its values are relocated
59/// (e.g. 0.25 = pages less than 25% dense are compacted). Consumed by
60/// `TransactionManager::sparse_data_pages` during step 2 of the sweep.
61/// Dense pages above the threshold are left alone — per R3, the cost of
62/// re-packing a mostly-full page exceeds the benefit. Values <= 0
63/// disable the sweep entirely (the sparse set comes back empty).
64///
65/// `max_values`: soft cap on work per call, so a very large database can
66/// be defragged incrementally across several transactions. `0` means no
67/// limit. The cap counts values relocated, not pages touched. Breaking
68/// the loop early leaves the transaction in a valid state; the caller
69/// chooses commit vs rollback.
70///
71/// I36 (ISSUES.md, 2026-05-22): `#[non_exhaustive]` so a future
72/// tuning knob (e.g. a per-page time cap, a pages-vs-values-priority
73/// toggle) is not a breaking change. External callers construct via
74/// `DefragOptions { ..Default::default() }` rather than a full struct
75/// literal.
76#[non_exhaustive]
77#[derive(Debug, Clone)]
78pub struct DefragOptions {
79    pub sparse_threshold: f64,
80    pub max_values: usize,
81}
82
83impl Default for DefragOptions {
84    fn default() -> DefragOptions {
85        DefragOptions {
86            sparse_threshold: 0.25,
87            max_values: 0,
88        }
89    }
90}
91
92// I36: chained setters paired with #[non_exhaustive]. Same shape as
93// `Options`'s builder; method names match field names. External callers
94// build via `DefragOptions::default().sparse_threshold(0.1).max_values(100)`.
95impl DefragOptions {
96    pub fn sparse_threshold(mut self, threshold: f64) -> Self {
97        self.sparse_threshold = threshold;
98        self
99    }
100    pub fn max_values(mut self, cap: usize) -> Self {
101        self.max_values = cap;
102        self
103    }
104}
105
106/// I36: `#[non_exhaustive]` for symmetry with `DefragOptions` and so a
107/// future bench-friendly stat (e.g. wall-time elapsed inside the sweep)
108/// is not a breaking change.
109// I121 (ISSUES.md, 2026-06-21): #[must_use] so `db.defrag(opts)?;` cannot
110// silently discard the report (pages_examined / pages_freed). Callers who
111// genuinely don't care bind it with `let _ = ...`.
112#[must_use = "DefragStats reports what the sweep did (pages_examined/pages_freed); inspect it or bind with `let _`"]
113#[non_exhaustive]
114#[derive(Debug, Clone)]
115pub struct DefragStats {
116    pub pages_examined: u64,
117    pub pages_freed: u64,
118    pub values_moved: u64,
119    /// Freemap-typed pages reclaimed by the orphan-sweep — pages a prior crash
120    /// stranded when it lost the in-memory structural recycle pool (see
121    /// `TransactionManager::reclaim_freemap_orphans`). 0 on a clean database.
122    /// Reported, not gating: a non-zero value means a past crash leaked freemap
123    /// pages that this pass returned to the bitmap as reusable space.
124    pub freemap_orphans_reclaimed: u64,
125}
126
127/// Run defragmentation.
128///
129/// Preconditions (caller's responsibility):
130/// - `txm` has an active transaction. `read`/`update` will error otherwise,
131///   but we rely on that check rather than duplicating it here.
132/// - No other references into the page cache are held across this call;
133///   `&mut TransactionManager` enforces that at the type level.
134///
135/// Algorithm (R3 selective):
136///   1. Short-circuit if the handle-table root is empty — nothing to do.
137///   2. Compute the set of SPARSE data pages up front via
138///      `txm.sparse_data_pages(sparse_threshold)`. A page is sparse if
139///      its live-slot count is at or below `threshold × max_observed`.
140///      Dense pages are left alone.
141///   3. Snapshot the initial set of data-page IDs (not a count) so we can
142///      report `pages_freed` at the end as the set difference against the
143///      final set — a net count delta is the wrong metric here, see the
144///      header (I17).
145///   4. Snapshot the handle list up front so the iteration walks a
146///      stable set rather than a handle table we are concurrently
147///      rewriting via update().
148///   5. For each handle, look up its current data page. If that page
149///      is in the sparse set, relocate the value via `update()` — under
150///      R1, `update` packs the value into the transaction's insert
151///      cursor, which is a fresh, densely-packed page. Repeat until
152///      the max-work cap (if any) is reached.
153///   6. Track UNIQUE sparse pages touched via a HashSet (for accurate
154///      `pages_examined`) and compute `pages_freed` as the set difference
155///      between the initial and final data-page-ID sets (NOT a net count
156///      delta — see header).
157///   7. Reclaim freemap pages orphaned by a prior crash that lost the
158///      in-memory structural recycle pool, via
159///      `txm.reclaim_freemap_orphans` (reported as
160///      `freemap_orphans_reclaimed`). Runs unconditionally — even when
161///      steps 2-6 found no sparse data pages — since freemap orphans are
162///      an independent reclamation channel. EXCEPTION: the orphan sweep is
163///      skipped while a savepoint is active (it returns 0), because the sweep
164///      COWs the freemap and `rollback_to` does not rewind the structural
165///      recycle streams; see `reclaim_freemap_orphans`. Run defrag outside a
166///      savepoint scope to reclaim crash orphans.
167///
168/// What this does NOT do (v1):
169/// - No fancy ordering of handle visits (e.g., group by page). A
170///   handle-order traversal is simple and correct.
171/// - No merging of adjacent sparse pages into one target. Relocated
172///   values just go into whatever the insert cursor currently points at.
173/// - Handle-table and overflow-chain COW garbage is not reclaimed by
174///   this pass — only data pages are compacted, and (step 7) freemap
175///   pages orphaned by a crash are swept back into the bitmap. Handle-
176///   table and overflow spine cleanup still requires a separate mechanism.
177pub fn defrag(txm: &mut TransactionManager, options: &DefragOptions) -> Result<DefragStats> {
178    // Defrag mutates through `txm.update`, which requires an active
179    // transaction. Without this check, a caller who forgot to `begin()`
180    // would do some reads (those fall back to committed_roots), start
181    // relocating values, and then hit `NoActiveTransaction` on the
182    // first `update` — leaving the sweep in a half-done state with
183    // confusing stats. Fail fast instead.
184    if !txm.is_active() {
185        return Err(crate::error::ChiselError::NoActiveTransaction);
186    }
187
188    let mut stats = DefragStats {
189        pages_examined: 0,
190        pages_freed: 0,
191        values_moved: 0,
192        freemap_orphans_reclaimed: 0,
193    };
194
195    // Step 1: empty-database fast path.
196    // I39: single-field accessor replaces the pre-I39 positional
197    // `(u64, u64, u64)` tuple return; only the handle-table root is
198    // consulted here.
199    // Skipping the freemap orphan-sweep (step 7) here is safe: the
200    // handle-table root materialises permanently on the first `allocate`
201    // and never reverts to PAGE_ID_NONE, so an empty handle table implies
202    // no allocation has ever succeeded — therefore no freemap tree exists
203    // (the tree is seeded only when a freed page needs a bitmap bit) and
204    // no orphans are possible.
205    if txm.current_handle_table_root_page() == PAGE_ID_NONE {
206        return Ok(stats);
207    }
208
209    // Step 2: identify sparse pages. If none qualify, there is no data-page
210    // compaction to do and we skip the (potentially expensive) handle scan —
211    // but we still fall through to the freemap orphan-sweep below, which is
212    // independent of data-page density (a crash can leave freemap orphans even
213    // in a database with no sparse pages). This step loads each candidate data
214    // page once to read its stored-slot count from the header, so it can fail
215    // with a fatal I/O or checksum error (which will poison the manager via the
216    // normal path).
217    let sparse_pages: HashSet<u64> = txm.sparse_data_pages(options.sparse_threshold)?;
218    if !sparse_pages.is_empty() {
219        // Step 3: snapshot the set of data page ids at the start so we can
220        // report `pages_freed` accurately. Net change in page count is the
221        // wrong metric: a relocation simultaneously drains a sparse page
222        // and creates a dense destination, so net change is ~0 even when
223        // a page genuinely got reclaimed. The right metric is "pages that
224        // existed at the start and are gone at the end", which we compute
225        // via set difference below.
226        let initial_page_ids = txm.data_page_ids_snapshot();
227
228        // Step 4: snapshot the handle set.
229        let handles = txm.handles()?;
230
231        // Step 5: relocate handles living on sparse pages.
232        let mut examined_pages: HashSet<u64> = HashSet::new();
233        for &handle in &handles {
234            if options.max_values > 0 && stats.values_moved >= options.max_values as u64 {
235                break;
236            }
237            let page_id = match txm.handle_live_page_id(handle)? {
238                Some(id) => id,
239                None => continue, // Overflow or Deleted — nothing to compact.
240            };
241            if !sparse_pages.contains(&page_id) {
242                continue; // Dense page — leave it alone.
243            }
244            examined_pages.insert(page_id);
245
246            // Read-then-write under the same handle. The stable-handle
247            // invariant guarantees `handle` still refers to this value
248            // after update; R1's insert cursor packs the re-insertion
249            // into a dense destination.
250            let value = txm.read(handle)?;
251            txm.update(handle, &value)?;
252            stats.values_moved += 1;
253        }
254
255        // Step 6: accurate stats (I17). `pages_freed` counts pages that
256        // were tracked at the start and are gone now — i.e., pages the
257        // sweep fully drained and returned to the freemap.
258        let final_page_ids = txm.data_page_ids_snapshot();
259        stats.pages_examined = examined_pages.len() as u64;
260        stats.pages_freed = initial_page_ids.difference(&final_page_ids).count() as u64;
261    }
262
263    // Step 7: reclaim freemap pages orphaned by a prior crash that lost the
264    // in-memory recycle pool. Off the hot path (defrag is scheduled
265    // maintenance), so this is exactly the place for the O(total_pages) scan.
266    // Runs even when there was no data-page compaction above — the two are
267    // independent reclamation channels.
268    stats.freemap_orphans_reclaimed = txm.reclaim_freemap_orphans()?;
269
270    Ok(stats)
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::page::PAGE_SIZE;
277    use crate::page_cache::PageCache;
278    use crate::page_io::PageIo;
279
280    // Build a committed-once manager, mirroring transaction.rs's `fresh_manager`
281    // (private to that module, hence duplicated here).
282    fn fresh_manager() -> TransactionManager {
283        let file = tempfile::NamedTempFile::new().unwrap();
284        let io = PageIo::open(file.path(), false).unwrap();
285        let cache = PageCache::new(
286            io,
287            1024 * PAGE_SIZE as u64,
288            0,
289            crate::DrainInsertion::LruTail,
290            crate::SpillwayLocation::InMemory,
291        );
292        let mut tm = TransactionManager::create_new(cache, 2, None, None).unwrap();
293        tm.begin().unwrap();
294        tm.commit().unwrap();
295        tm
296    }
297
298    // A defrag pass reports any freemap orphan it reclaimed. Forge an orphan (a
299    // crash-stranded freemap page), run defrag inside a transaction, and assert
300    // the new `freemap_orphans_reclaimed` stat counts it. This is the public,
301    // stat-level contract of the crash-recovery sweep wired into step 7.
302    #[test]
303    fn defrag_reports_freemap_orphans_reclaimed() {
304        let mut tm = fresh_manager();
305
306        // Establish a real freemap tree (a value + its delete frees a whole page,
307        // materializing a freemap leaf), so defrag's empty-DB fast path is not
308        // taken and the sweep has a live root to walk.
309        let big: Vec<u8> = vec![0xCD; 1 << 14];
310        tm.begin().unwrap();
311        let h = tm.allocate(&big).unwrap();
312        tm.commit().unwrap();
313        tm.begin().unwrap();
314        tm.delete(h).unwrap();
315        tm.commit().unwrap();
316
317        // Forge the orphan, then run defrag in a fresh transaction.
318        let orphan = tm.test_forge_freemap_orphan().unwrap();
319        tm.begin().unwrap();
320        let stats = defrag(&mut tm, &DefragOptions::default()).unwrap();
321        tm.commit().unwrap();
322
323        assert!(
324            stats.freemap_orphans_reclaimed >= 1,
325            "defrag must report the forged orphan as reclaimed, got {}",
326            stats.freemap_orphans_reclaimed
327        );
328
329        // Idempotent: a second defrag finds nothing (the orphan was already
330        // reclaimed into the bitmap on the first pass).
331        tm.begin().unwrap();
332        let again = defrag(&mut tm, &DefragOptions::default()).unwrap();
333        tm.commit().unwrap();
334        assert_eq!(
335            again.freemap_orphans_reclaimed, 0,
336            "second sweep must find no orphans (idempotent)"
337        );
338        let _ = orphan;
339    }
340
341    // Cover the `structural_superseded` exclusion in `reclaim_freemap_orphans`.
342    //
343    // When defrag step 5 (data relocation) and step 7 (orphan sweep) run in the
344    // SAME transaction, step-5 `update()` calls allocate fresh data pages via
345    // `cow_alloc` → `allocate_first`, which claims a free bit from the freemap
346    // bitmap and COWs the containing leaf.  The OLD (pre-COW) leaf id lands in
347    // `structural_superseded`.  That page is unreachable from the current tree
348    // and is NOT marked free in the bitmap — exactly the shape of an orphan —
349    // yet it is NOT an orphan: it is live recycling state that commit will hand
350    // to the next transaction.  If `reclaim_freemap_orphans` didn't exclude
351    // `structural_superseded`, step 7 would wrongly reclaim those pages, marking
352    // their adjacent data pages reusable and corrupting any live value that still
353    // references them.
354    //
355    // Counterfactual: temporarily removing the `structural_superseded` term from
356    // the exclusion set causes this test to fail — the reclaimed count exceeds 1
357    // (the COW'd leaf is swept as an orphan) and subsequent reads return wrong
358    // data.  With the exclusion in place, exactly the one forged orphan is
359    // reclaimed and all surviving handles read back their original values.
360    #[test]
361    fn defrag_compact_and_sweep_excludes_structural_superseded() {
362        let mut tm = fresh_manager();
363
364        // Use values large enough that each takes a significant fraction of a
365        // data page (~400 bytes), so ~20 fit per page.  Allocate 60 handles
366        // across ~3 data pages, then delete 45 of them (every third is kept),
367        // making the source pages ~33% full — below the default 0.25 threshold
368        // only when the kept fraction is low enough.  Use a harsher threshold
369        // (0.5) so the pages reliably qualify as sparse.
370        //
371        // The deletes free ~3 data pages back into the freemap bitmap, giving
372        // `allocate_first` free bits to claim during step-5 updates.  Each
373        // claim COWs the freemap leaf, pushing the old leaf id into
374        // `structural_superseded`.
375        let value: Vec<u8> = vec![0xAB; 400];
376        let mut all_handles: Vec<u64> = Vec::new();
377        tm.begin().unwrap();
378        for _ in 0..60 {
379            all_handles.push(tm.allocate(&value).unwrap());
380        }
381        tm.commit().unwrap();
382
383        // Delete 45 of 60 handles (keep every 4th) so 3 of the ~3 data pages
384        // become heavily sparse.
385        let kept: Vec<u64> = all_handles
386            .iter()
387            .copied()
388            .enumerate()
389            .filter(|(i, _)| i % 4 == 0)
390            .map(|(_, h)| h)
391            .collect();
392        let deleted: Vec<u64> = all_handles
393            .iter()
394            .copied()
395            .enumerate()
396            .filter(|(i, _)| i % 4 != 0)
397            .map(|(_, h)| h)
398            .collect();
399
400        tm.begin().unwrap();
401        for h in &deleted {
402            tm.delete(*h).unwrap();
403        }
404        tm.commit().unwrap();
405
406        // Forge exactly one freemap orphan before the defrag transaction.
407        let orphan = tm.test_forge_freemap_orphan().unwrap();
408
409        // Run defrag with a generous sparse threshold (0.5) so the source pages
410        // definitely qualify.  Steps 5 and 7 execute inside the SAME transaction.
411        //
412        // Step 5 triggers at least one data-page allocation → `cow_alloc` →
413        // `allocate_first` call, which COWs a freemap leaf and pushes its old id into
414        // `structural_superseded`.  Step 7 must then exclude that id so only the
415        // forged orphan is reclaimed.
416        tm.begin().unwrap();
417        let opts = DefragOptions::default().sparse_threshold(0.5);
418        let stats = defrag(&mut tm, &opts).unwrap();
419
420        // Step 5 must have actually relocated values (otherwise structural_superseded
421        // would be empty and this test is vacuous).
422        assert!(
423            stats.values_moved > 0,
424            "precondition: step 5 must have relocated at least one value (got 0); \
425             increase the value count or lower the kept fraction so pages are sparse"
426        );
427
428        // Exactly one orphan reclaimed — the forged one.  If a COW'd leaf were
429        // wrongly swept, the count would exceed 1.
430        assert_eq!(
431            stats.freemap_orphans_reclaimed, 1,
432            "exactly the forged orphan must be reclaimed (not a COW'd leaf from \
433             step 5); got {}",
434            stats.freemap_orphans_reclaimed
435        );
436
437        tm.commit().unwrap();
438
439        // Every surviving handle must still read back its original value — a
440        // live freemap page wrongly reclaimed would corrupt the bitmap and could
441        // cause a data-page double-hand-out, breaking subsequent reads.
442        tm.begin().unwrap();
443        for h in &kept {
444            let v = tm.read(*h).unwrap();
445            assert_eq!(
446                v, value,
447                "handle {h} returned wrong data after compact+sweep defrag"
448            );
449        }
450        tm.commit().unwrap();
451
452        let _ = orphan;
453    }
454}