Skip to main content

clt_database/mvcc/database/
checkpoint_state_machine.rs

1use crate::alloc::{
2    ConcurrentAllocator, TryReserveError, TursoAllocator, TursoIteratorExt, TursoVecExt, Vec,
3    ALLOC_ERR_MSG,
4};
5use crate::mvcc::clock::LogicalClock;
6use crate::mvcc::database::{
7    DeleteRowStateMachine, MVTableId, MvStore, Row, RowID, RowKey, RowVersion, SortableIndexKey,
8    TxTimestampOrID, WalPos, WriteRowStateMachine, MVCC_META_KEY_PERSISTENT_TX_TS_MAX,
9    MVCC_META_TABLE_NAME, SQLITE_SCHEMA_MVCC_TABLE_ID,
10};
11#[cfg(any(clt_turso_tests, injected_yields))]
12use crate::mvcc::yield_hooks::{ProvidesYieldContext, YieldContext, YieldPointMarker};
13use crate::mvcc::yield_points::{inject_transition_failure, inject_transition_yield};
14use crate::schema::{Index, Schema};
15use crate::state_machine::{StateMachine, StateTransition, TransitionResult};
16use crate::storage::btree::{BTreeCursor, CursorTrait};
17use crate::storage::pager::CreateBTreeFlags;
18use crate::storage::sqlite3_ondisk::DatabaseHeader;
19use crate::storage::wal::{CheckpointMode, TursoRwLock, WalAutoActions};
20use crate::sync::atomic::Ordering;
21use crate::sync::Arc;
22use crate::sync::RwLock;
23use crate::types::{IOCompletions, IOResult, ImmutableRecord, ImmutableRecordRef};
24use crate::{turso_assert, turso_assert_eq};
25use crate::{
26    CheckpointResult, Completion, Connection, IOExt, LimboError, Numeric, Pager, Result, SyncMode,
27    TransactionState, Value, ValueRef,
28};
29use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
30use std::num::NonZeroU64;
31use std::ops::Bound;
32#[cfg(any(clt_turso_tests, injected_yields))]
33use strum::EnumCount;
34
35use super::lookup_tx_state;
36const COLLECT_PREEMPTION_THRESHOLD: usize = 1024;
37
38macro_rules! with_mvcc_checkpoint_allocation_site {
39    ($site:ident, $expr:expr) => {{
40        #[cfg(clt_turso_feature = "allocation_metric")]
41        let _turso_allocation_site_guard =
42            crate::alloc::enter_allocation_site(crate::alloc::MvccCheckpointAllocationSite::$site);
43        $expr
44    }};
45}
46
47/// Root page of the `sqlite_schema` B-tree in the database file.
48const SQLITE_SCHEMA_ROOT_PAGE: i64 = 1;
49/// Column count of a `sqlite_schema` record (type, name, tbl_name, rootpage, sql).
50const SQLITE_SCHEMA_COLUMN_COUNT: usize = 5;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum CheckpointState {
54    PrepareCheckpoint,
55    AcquireLock,
56    BuildLocalSchemaView,
57    CollectTableRows,
58    CollectIndexRows,
59    BeginPagerTxn,
60    WriteRow {
61        write_set_index: usize,
62        requires_seek: bool,
63    },
64    WriteRowStateMachine {
65        write_set_index: usize,
66    },
67    DeleteRowStateMachine {
68        write_set_index: usize,
69    },
70    WriteIndexRow {
71        index_write_set_index: usize,
72        requires_seek: bool,
73    },
74    WriteIndexRowStateMachine {
75        index_write_set_index: usize,
76    },
77    DeleteIndexRowStateMachine {
78        index_write_set_index: usize,
79    },
80    /// Compact each non-CYCLE sequence backing table down to a single
81    /// watermark row. CYCLE seqs are skipped — they manage wrap
82    /// correctness via inline compaction in the nextval bytecode and
83    /// already stay at one row in steady state. Non-CYCLE seqs grow
84    /// monotonically (one row per nextval) since inline compaction
85    /// was removed from the hot path to eliminate shared-row WW
86    /// conflicts; checkpoint reclaims the historical rows here, via
87    /// `SeqCompactDriver` which drives `BTreeCursor` ops with normal
88    /// `IOResult` propagation (no `io.block` / `wait_for_completion`).
89    CompactSequences,
90    CommitPagerTxn,
91    CheckpointWal,
92    /// Fsync the database file after checkpoint, before truncating WAL.
93    /// This ensures durability: if we crash after WAL truncation but before DB fsync,
94    /// the data would be lost.
95    SyncDbFile,
96    TruncateLogicalLog,
97    FsyncLogicalLog,
98    /// Truncate the WAL file after DB file and logical-log cleanup are safely durable.
99    TruncateWal,
100    GcTableRows {
101        next_index: usize,
102        lwm: u64,
103    },
104    GcIndexRows {
105        next_index: usize,
106        lwm: u64,
107    },
108    Finalize,
109}
110
111#[cfg(any(clt_turso_tests, injected_yields))]
112#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumCount)]
113#[repr(u8)]
114pub(crate) enum CheckpointYieldPoint {
115    BeforeAcquireLock,
116    AfterDurableBoundaryAdvanced,
117    AfterCollectTableRows,
118}
119
120#[cfg(any(clt_turso_tests, injected_yields))]
121impl YieldPointMarker for CheckpointYieldPoint {
122    const POINT_COUNT: u8 = Self::COUNT as u8;
123
124    fn ordinal(self) -> u8 {
125        self as u8
126    }
127}
128
129#[cfg(any(clt_turso_tests, injected_yields))]
130fn checkpoint_yield_key() -> u64 {
131    const CHECKPOINT_SELECTION_TAG: u64 = 0xC4EC_9011_C4EC_9011;
132    CHECKPOINT_SELECTION_TAG
133}
134
135/// Root-map mutation staged during collection; applied in the publish window.
136enum RootMapOp {
137    /// Insert a new (checkpointed) root binding for `id` at `root` (STAGED → published).
138    Alloc { id: MVTableId, root: u64 },
139    /// Set the `end` ts of `id`'s binding (DROP of a checkpointed object).
140    Retire { id: MVTableId, end_ts: u64 },
141    /// Remove `id`'s binding entirely (DROP of a never-checkpointed object).
142    Remove { id: MVTableId },
143}
144
145/// The states of the locks held by the state machine - these are tracked for error handling so that they are
146/// released if the state machine fails.
147pub struct LockStates {
148    blocking_checkpoint_lock_held: bool,
149    pager_read_tx: bool,
150    pager_write_tx: bool,
151}
152
153/// A state machine that performs a complete checkpoint operation on the MVCC store.
154///
155/// The checkpoint process:
156/// 1. Takes a blocking lock on the database so that no other transactions can run during the checkpoint.
157/// 2. Determines which row versions should be written to the B-tree.
158/// 3. Begins a pager transaction
159/// 4. Writes all the selected row versions to the B-tree.
160/// 5. Commits the pager transaction, effectively flushing to the WAL
161/// 6. Immediately does a TRUNCATE checkpoint from the WAL to the DB
162/// 7. Fsync the DB file
163/// 8. Truncate logical log to 0 (salt regenerated in memory), fsync, then truncate WAL
164/// 9. Releases the blocking_checkpoint_lock
165///
166/// Passive mode defers step 1 until publish and runs collection/write concurrently; the durable
167/// outcome (WAL backfill, log truncate, metadata) is the same.
168pub struct CheckpointStateMachine<Clock: LogicalClock, A: ConcurrentAllocator = TursoAllocator> {
169    /// The current state of the state machine
170    state: CheckpointState,
171    /// The states of the locks held by the state machine - these are tracked for error handling so that they are
172    /// released if the state machine fails.
173    lock_states: LockStates,
174    /// The highest transaction ID that has been made durable in the WAL in a previous checkpoint.
175    durable_txid_max_old: Option<NonZeroU64>,
176    /// The highest transaction ID that will be made durable in the WAL in the current checkpoint.
177    durable_txid_max_new: u64,
178    /// Pager used for writing to the B-tree
179    pager: Arc<Pager>,
180    /// MVCC store containing the row versions.
181    mvstore: Arc<MvStore<Clock, A>>,
182    /// Connection to the database
183    connection: Arc<Connection>,
184    /// Database whose pager and schema this checkpoint is writing.
185    database_id: usize,
186    #[cfg(any(clt_turso_tests, injected_yields))]
187    yield_instance_id: u64,
188    /// Lock used to block other transactions from running during the checkpoint
189    checkpoint_lock: Arc<TursoRwLock>,
190    /// All committed versions to write to the B-tree.
191    /// In the case of CREATE TABLE / DROP TABLE ops, contains a [SpecialWrite] to create/destroy the B-tree.
192    write_set: Vec<(RowVersion, Option<SpecialWrite>)>,
193    /// State machine for writing rows to the B-tree
194    write_row_state_machine: Option<StateMachine<WriteRowStateMachine>>,
195    /// State machine for deleting rows from the B-tree
196    delete_row_state_machine: Option<StateMachine<DeleteRowStateMachine>>,
197    /// Cursors for the B-trees
198    cursors: HashMap<u64, Arc<RwLock<BTreeCursor>>>,
199    /// Tables or indexes that were created in this checkpoint
200    /// key is the rowid in the sqlite_schema table
201    created_btrees: HashMap<i64, (MVTableId, RowVersion)>,
202    /// Tables that were destroyed in this checkpoint
203    destroyed_tables: HashSet<MVTableId>,
204    /// Indexes that were destroyed in this checkpoint
205    destroyed_indexes: HashSet<MVTableId>,
206    /// Index row changes to write: (index_id, row_version, is_delete)
207    index_write_set: Vec<(MVTableId, RowVersion, bool)>,
208    /// Map from index_id to Index struct (for creating cursors)
209    /// This is populated when we process sqlite_schema rows for indexes
210    index_id_to_index: HashMap<MVTableId, Arc<Index>>,
211    /// Result of the checkpoint
212    checkpoint_result: Option<CheckpointResult>,
213    /// Update connection's transaction state on checkpoint. If checkpoint was called as automatic
214    /// process in a transaction we don't want to change the state as we assume we are already on a
215    /// write transaction and any failure will be cleared on vdbe error handling.
216    update_transaction_state: bool,
217    /// The synchronous mode for fsync operations. When set to Off, fsync is skipped.
218    sync_mode: SyncMode,
219    /// Checkpoint mode. `should_restart_log()` (Truncate/Restart) gates the WAL
220    /// truncation in `TruncateWal`; Passive leaves the WAL non-empty (restart-on-write).
221    mode: CheckpointMode,
222    /// Internal metadata table info for persisting `persistent_tx_ts_max` atomically with pager commit.
223    mvcc_meta_table: Option<(MVTableId, usize)>,
224    /// File-backed databases must persist replay boundary durably.
225    durable_mvcc_metadata: bool,
226    /// Header staged into pager page 1 before commit; published to global_header on success.
227    staged_checkpoint_header: Option<DatabaseHeader>,
228    /// Guard to avoid restaging page 1 across CommitPagerTxn async retries.
229    header_staged_for_commit: bool,
230    /// Set after `pager.commit_tx` succeeds; the publish window may still be pending (auto passive
231    /// retries acquiring the brief write lock without re-committing).
232    pager_commit_done: bool,
233    /// Root-map ops staged during collection; applied at publish.
234    pending_rootmap_ops: Vec<RootMapOp>,
235    /// Roots allocated this checkpoint; resolved until publish.
236    pending_alloc_roots: std::collections::HashMap<MVTableId, u64>,
237    collect_table_cursor: Option<RowID>,
238    collect_index_tableid_cursor: Option<MVTableId>,
239    collect_index_key_cursor: Option<Arc<SortableIndexKey>>,
240    /// Async driver for `CheckpointState::CompactSequences`. Lazily set
241    /// on first entry to that state; cleared when the driver completes.
242    seq_compact: Option<SeqCompactDriver<Clock, A>>,
243    /// Sequence deletes recorded in passive mode; applied in the publish window.
244    pending_seq_deletes: Vec<(RowID, usize)>,
245    /// Collection upper bound (`last_committed_tx_ts` at snapshot). `u64::MAX` = no bound.
246    snapshot_ts: u64,
247    build_local_schema_sm: Option<StateMachine<BuildLocalSchemaViewStateMachine<Clock, A>>>,
248    build_local_schema_began_read_tx: bool,
249    /// Snapshot-consistent schema built at `snapshot_ts`; drives `index_id_to_index` in PASSIVE mode.
250    local_schema: Option<Arc<Schema>>,
251    owns_checkpoint_in_progress: bool,
252    /// Roots allocated this checkpoint; published with `visible_from = durable_txid_max_new`.
253    staged_roots: Vec<MVTableId>,
254}
255
256/// One pending compaction job in the per-checkpoint sequence sweep.
257#[derive(Debug, Clone, Copy)]
258struct SeqCompaction {
259    backing_root: i64,
260    backing_num_cols: usize,
261    /// MVCC table id derived from `backing_root` at sweep-plan time.
262    /// Cached here so the per-row purge in `ScanDelete` doesn't re-scan
263    /// `mvstore.table_id_to_rootpage` on every deletion. The driver uses
264    /// it together with the deleted row's `value` (= rowid alias) to
265    /// build the `RowID` it passes to
266    /// `MvStore::purge_row_versions_during_checkpoint`.
267    table_id: MVTableId,
268    /// `true` for ascending sequences (watermark = `Last`), `false` for
269    /// descending (watermark = `Rewind`). Direction-aware because the
270    /// "current value" of a sequence is the max for ascending and the
271    /// min for descending — keeping the wrong end as the watermark
272    /// after compaction would lose the last emitted value across
273    /// restart.
274    increment_positive: bool,
275}
276
277/// Per-row scan phase within `SeqCompactDriver`. Each backing table is
278/// walked end-to-end: a watermark seek (Last/Rewind) followed by a
279/// from-start scan that deletes every row whose key (= value = rowid,
280/// since `value` is `INTEGER PRIMARY KEY`) differs from the watermark.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282enum SeqCompactPhase {
283    /// `cursor.last()` for ascending, `cursor.rewind()` for descending.
284    /// Yields IOResult::IO on page reads.
285    SeekWatermark,
286    /// `cursor.rowid()` to capture the watermark key. Yields IO on
287    /// further reads. If the cursor has no record (empty backing
288    /// table), advances to next sequence without scanning.
289    ReadWatermarkRowid,
290    /// `cursor.rewind()` to start the from-start scan. Yields IO.
291    ScanRewind,
292    /// `cursor.rowid()` on the current scan row. Yields IO.
293    ScanReadRowid,
294    /// `cursor.delete()` when the current row's key differs from the
295    /// watermark. Yields IO.
296    ScanDelete,
297    /// `cursor.next()` to advance the scan. Yields IO. Re-enters
298    /// `ScanReadRowid` when the cursor still has a record, otherwise
299    /// advances to the next sequence.
300    ScanNext,
301}
302
303/// Walks each pending sequence backing table and deletes every row
304/// that is not the current watermark. Pure `IOResult` plumbing — every
305/// cursor op yields up to the caller on page IO, so a `step()` call
306/// from inside the checkpoint state machine can propagate a yield
307/// upward without ever blocking the executor.
308///
309/// Generic over `Clock` so it can hold an `Arc<MvStore<Clock, A>>` — the
310/// driver paired-deletes from the B-tree (via the cursor) AND from the
311/// MVCC version chain (via `purge_row_versions_during_checkpoint`) so
312/// the two layers stay consistent. Skipping the version-chain purge
313/// would leave entries with `btree_resident: true` pointing at B-tree
314/// rows that no longer exist, surviving until `drop_unused_row_versions`
315/// Rule 3 catches up.
316struct SeqCompactDriver<Clock: LogicalClock, A: ConcurrentAllocator = TursoAllocator> {
317    /// Remaining backing tables to compact, in arbitrary order.
318    pending: Vec<SeqCompaction>,
319    /// Index of the in-flight compaction within `pending`.
320    current_idx: usize,
321    /// Cursor on the in-flight backing table. Constructed on entry to
322    /// `SeekWatermark`, dropped on transition to the next sequence.
323    cursor: Option<BTreeCursor>,
324    /// Current scan phase for the in-flight backing table.
325    phase: SeqCompactPhase,
326    /// The watermark key captured in `ReadWatermarkRowid`. The scan
327    /// keeps the row at this key and deletes all others.
328    watermark_key: Option<i64>,
329    /// Row key (= `value` column, since `value INTEGER PRIMARY KEY`)
330    /// captured in `ScanReadRowid` when we decide the current row must
331    /// go. Consumed by `ScanDelete` AFTER `cursor.delete()` returns
332    /// `Done` to build the matching `RowID` for the MVCC purge call.
333    /// Stored on the driver (not as a phase payload) so a yield mid-
334    /// `cursor.delete()` doesn't lose the rowid across re-entry.
335    pending_delete_rowid: Option<i64>,
336    /// Cached pager handle so cursor construction matches the original
337    /// `BTreeCursor::new_table` signature.
338    pager: Arc<Pager>,
339    /// MVCC store used to purge version-chain entries paired with each
340    /// B-tree delete. See the struct-level comment for the invariant.
341    mvstore: Arc<MvStore<Clock, A>>,
342    /// Passive mode: record deletes for `seqcompact_commit_delete` instead of purging inline.
343    passive: bool,
344    /// Rows recorded for deletion in passive mode (drained by the checkpoint
345    /// state machine into its publish window).
346    compacted: Vec<(RowID, usize)>,
347}
348
349#[cfg(any(clt_turso_tests, injected_yields))]
350impl<Clock: LogicalClock, A: ConcurrentAllocator> ProvidesYieldContext
351    for CheckpointStateMachine<Clock, A>
352{
353    fn yield_context(&self) -> YieldContext {
354        YieldContext::new(
355            self.connection.yield_injector(),
356            self.connection.failure_injector(),
357            self.yield_instance_id,
358            checkpoint_yield_key(),
359        )
360    }
361}
362
363#[derive(Debug, PartialEq, Eq, Clone, Copy)]
364/// Special writes for CREATE TABLE / DROP TABLE / CREATE INDEX / DROP INDEX ops.
365/// These are used to create/destroy B-trees during pager ops.
366pub enum SpecialWrite {
367    BTreeCreate {
368        table_id: MVTableId,
369        sqlite_schema_rowid: i64,
370    },
371    BTreeDestroy {
372        table_id: MVTableId,
373        root_page: u64,
374        num_columns: usize,
375    },
376    BTreeCreateIndex {
377        index_id: MVTableId,
378        sqlite_schema_rowid: i64,
379    },
380    BTreeDestroyIndex {
381        index_id: MVTableId,
382        root_page: u64,
383        num_columns: usize,
384    },
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388enum SqliteSchemaBtreeKind {
389    Table,
390    Index,
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub struct SqliteSchemaBtreeIdentity {
395    kind: SqliteSchemaBtreeKind,
396    pub root_page: i64,
397}
398
399/// Identity of a sqlite_schema row version that refers to a B-tree-backed object.
400/// Schema rewrites that preserve this identity are metadata-only and should not be
401/// treated as create/drop lifecycle changes.
402pub fn sqlite_schema_btree_identity(version: &RowVersion) -> Option<SqliteSchemaBtreeIdentity> {
403    if version.row.id.table_id != SQLITE_SCHEMA_MVCC_TABLE_ID {
404        return None;
405    }
406
407    // Recovery can synthesize payload-less sqlite_schema tombstones when the
408    // pre-delete record is no longer available. Those versions do not carry
409    // enough information to recover B-tree identity.
410    if version.row.payload().is_empty() {
411        return None;
412    }
413
414    let row_data = ImmutableRecordRef::from_bin_record(version.row.payload());
415    let Ok((col0, col3)) = row_data.get_two_values(0, 3) else {
416        return None;
417    };
418
419    let kind = match col0 {
420        ValueRef::Text(type_str) => match type_str.as_str() {
421            "table" => SqliteSchemaBtreeKind::Table,
422            "index" => SqliteSchemaBtreeKind::Index,
423            _ => return None,
424        },
425        _ => panic!("sqlite_schema.type column must be TEXT, got {col0:?}"),
426    };
427
428    let ValueRef::Numeric(Numeric::Integer(root_page)) = col3 else {
429        panic!("sqlite_schema.rootpage column must be INTEGER, got {col3:?}");
430    };
431
432    if root_page == 0 {
433        return None;
434    }
435
436    Some(SqliteSchemaBtreeIdentity { kind, root_page })
437}
438
439fn sqlite_schema_versions_refer_to_btree(lhs: &RowVersion, rhs: &RowVersion) -> bool {
440    sqlite_schema_btree_identity(lhs)
441        .zip(sqlite_schema_btree_identity(rhs))
442        .is_some_and(|(lhs_id, rhs_id)| lhs_id == rhs_id)
443}
444
445/// A single `sqlite_schema` rowid can be reused across multiple row versions. Some of those
446/// transitions are metadata-only rewrites of the same B-tree object, while others represent a
447/// real change that mutates the BTREE.
448///
449/// Checkpoint needs to preserve ended schema versions only for the types of changes so it can
450/// register destroyed tables/indexes and skip stale recovered rows. Same-object rewrites, such as
451/// `ALTER TABLE ... RENAME COLUMN`, must collapse to the latest version; otherwise checkpoint
452/// treats one schema row chain as a DROP+CREATE pair and emits duplicate work for the same rowid.
453fn is_schema_metadata_only_rewrite(current: &RowVersion, next: Option<&RowVersion>) -> bool {
454    if current.end().is_none() {
455        return false;
456    }
457
458    let Some(_current_identity) = sqlite_schema_btree_identity(current) else {
459        return false;
460    };
461
462    match next {
463        Some(next) => !sqlite_schema_versions_refer_to_btree(current, next),
464        None => true,
465    }
466}
467
468impl<Clock: LogicalClock, A: ConcurrentAllocator> SeqCompactDriver<Clock, A> {
469    /// Drive one step of the compaction sweep. Returns `IOResult::IO` on
470    /// any cursor page IO so the caller can yield up; returns
471    /// `IOResult::Done(())` when every pending backing table has been
472    /// compacted to its single watermark row.
473    fn step(&mut self) -> Result<IOResult<()>> {
474        loop {
475            let Some(seq) = self.pending.get(self.current_idx).copied() else {
476                return Ok(IOResult::Done(()));
477            };
478            if self.cursor.is_none() {
479                self.cursor = Some(BTreeCursor::new_table(
480                    self.pager.clone(),
481                    seq.backing_root,
482                    seq.backing_num_cols,
483                ));
484                self.phase = SeqCompactPhase::SeekWatermark;
485                self.watermark_key = None;
486                self.pending_delete_rowid = None;
487            }
488            let cursor = self
489                .cursor
490                .as_mut()
491                .expect("cursor must be set in active compaction");
492            match self.phase {
493                SeqCompactPhase::SeekWatermark => {
494                    let r = if seq.increment_positive {
495                        cursor.last()?
496                    } else {
497                        cursor.rewind()?
498                    };
499                    if let IOResult::IO(io) = r {
500                        return Ok(IOResult::IO(io));
501                    }
502                    self.phase = SeqCompactPhase::ReadWatermarkRowid;
503                }
504                SeqCompactPhase::ReadWatermarkRowid => {
505                    let r = cursor.rowid()?;
506                    let key = match r {
507                        IOResult::IO(io) => return Ok(IOResult::IO(io)),
508                        IOResult::Done(opt) => opt,
509                    };
510                    match key {
511                        Some(k) => {
512                            self.watermark_key = Some(k);
513                            self.phase = SeqCompactPhase::ScanRewind;
514                        }
515                        None => {
516                            // Empty backing table — nothing to compact.
517                            self.advance_to_next_sequence();
518                        }
519                    }
520                }
521                SeqCompactPhase::ScanRewind => {
522                    let r = cursor.rewind()?;
523                    if let IOResult::IO(io) = r {
524                        return Ok(IOResult::IO(io));
525                    }
526                    self.phase = SeqCompactPhase::ScanReadRowid;
527                }
528                SeqCompactPhase::ScanReadRowid => {
529                    let r = cursor.rowid()?;
530                    let key = match r {
531                        IOResult::IO(io) => return Ok(IOResult::IO(io)),
532                        IOResult::Done(opt) => opt,
533                    };
534                    match key {
535                        Some(k) if Some(k) == self.watermark_key => {
536                            self.phase = SeqCompactPhase::ScanNext;
537                        }
538                        Some(k) => {
539                            if self.passive {
540                                // Passive: don't touch the B-tree or purge the chain here (the
541                                // purge contract needs the lock). Record the row; the publish
542                                // window turns it into a proper end-stamped delete and a later
543                                // checkpoint materializes the physical delete.
544                                self.compacted.push((
545                                    RowID {
546                                        table_id: seq.table_id,
547                                        row_id: RowKey::Int(k),
548                                    },
549                                    seq.backing_num_cols,
550                                ));
551                                self.phase = SeqCompactPhase::ScanNext;
552                            } else {
553                                // Stash the key for the paired MVCC purge that
554                                // ScanDelete performs after `cursor.delete()`
555                                // returns Done. This branch and the transition
556                                // are synchronous (no yield between them), so
557                                // ScanDelete is guaranteed to observe
558                                // `pending_delete_rowid = Some(k)` on the
559                                // current iteration.
560                                self.pending_delete_rowid = Some(k);
561                                self.phase = SeqCompactPhase::ScanDelete;
562                            }
563                        }
564                        None => {
565                            self.advance_to_next_sequence();
566                        }
567                    }
568                }
569                SeqCompactPhase::ScanDelete => {
570                    let r = cursor.delete()?;
571                    if let IOResult::IO(io) = r {
572                        return Ok(IOResult::IO(io));
573                    }
574                    // Pair the B-tree delete with the MVCC version-chain
575                    // purge so a snapshot reader can't observe the row
576                    // via `RowVersion.row` after the B-tree row is gone.
577                    // `pending_delete_rowid` was set in ScanReadRowid for
578                    // the row the cursor was on when we entered this
579                    // phase; consume it with `take()` so it doesn't leak
580                    // into the next scan iteration. See the
581                    // `purge_row_versions_during_checkpoint` doc comment
582                    // for the caller contract this depends on
583                    // (pager_commit_lock serializing nextval allocators).
584                    let rowid = self
585                        .pending_delete_rowid
586                        .take()
587                        .expect("pending_delete_rowid must be set when ScanDelete completes");
588                    self.mvstore.purge_row_versions_during_checkpoint(RowID {
589                        table_id: seq.table_id,
590                        row_id: RowKey::Int(rowid),
591                    });
592                    // After Delete the cursor is positioned at the slot
593                    // the deleted row used to occupy; the next Next
594                    // advances to the following row.
595                    self.phase = SeqCompactPhase::ScanNext;
596                }
597                SeqCompactPhase::ScanNext => {
598                    let r = cursor.next()?;
599                    if let IOResult::IO(io) = r {
600                        return Ok(IOResult::IO(io));
601                    }
602                    // `cursor.next()` leaves `has_record()` false at EOF
603                    // and the next `rowid()` returns Done(None); rely on
604                    // ScanReadRowid's None branch to advance.
605                    self.phase = SeqCompactPhase::ScanReadRowid;
606                }
607            }
608        }
609    }
610
611    fn advance_to_next_sequence(&mut self) {
612        self.cursor = None;
613        self.watermark_key = None;
614        self.pending_delete_rowid = None;
615        self.current_idx += 1;
616        self.phase = SeqCompactPhase::SeekWatermark;
617    }
618}
619
620impl<Clock: LogicalClock, A: ConcurrentAllocator> CheckpointStateMachine<Clock, A> {
621    /// Build the per-checkpoint list of non-CYCLE sequence backing tables
622    /// to compact. CYCLE seqs are skipped — they keep themselves at one
623    /// row via inline compaction in the nextval bytecode and using
624    /// MAX/MIN-based compaction after a wrap would lose the post-wrap
625    /// "current" value. Reads the live root page from the MVCC store
626    /// when the schema still carries the uncheckpointed-negative
627    /// sentinel; tables that have no real root yet (created and
628    /// immediately dropped in this checkpoint) are filtered out.
629    fn pending_sequence_compactions(&self) -> Result<Vec<SeqCompaction>> {
630        let resolve_root = |schema_root: i64| -> Option<i64> {
631            if schema_root > 0 {
632                return Some(schema_root);
633            }
634            if schema_root == 0 {
635                return None;
636            }
637            let table_id = self.mvstore.get_table_id_from_root_page(schema_root);
638            self.mvstore
639                .current_root_page(&table_id)
640                .map(|rp| rp as i64)
641        };
642        let db_id = crate::MAIN_DB_ID;
643        Ok(self
644            .connection
645            .with_schema(db_id, |schema| {
646                crate::without_allocation_faults!(
647                    // Checkpoint has already written table/index rows by the time sequence
648                    // compaction setup runs. An injected fault here can abort before the
649                    // checkpoint reaches its normal pager cleanup/retry path.
650                    // TODO: make sequence compaction setup resumable before re-enabling
651                    // fault injection for this collection.
652                    schema
653                        .sequences
654                        .values()
655                        .filter(|seq| !seq.cycle)
656                        .filter_map(|seq| {
657                            let backing_name =
658                                crate::translate::sequence::sequence_backing_table_name(&seq.name);
659                            let bt = schema.get_btree_table(&backing_name)?;
660                            let backing_root = resolve_root(bt.root_page)?;
661                            // Resolve the MVCC table_id once per sequence so the
662                            // per-row purge in `ScanDelete` doesn't re-scan
663                            // `table_id_to_rootpage` on every deletion. Uses the
664                            // schema-side root (pre-resolve) because
665                            // `get_table_id_from_root_page` already understands
666                            // the negative uncheckpointed-sentinel encoding.
667                            let table_id = self.mvstore.get_table_id_from_root_page(bt.root_page);
668                            Some(SeqCompaction {
669                                backing_root,
670                                backing_num_cols: bt.columns().len(),
671                                table_id,
672                                increment_positive: seq.increment_by >= 0,
673                            })
674                        })
675                        .try_collect()
676                )
677            })
678            .expect(ALLOC_ERR_MSG))
679    }
680
681    fn refresh_checkpoint_bounds(&mut self) {
682        let durable_tx_max = self.mvstore.durable_txid_max.load(Ordering::SeqCst);
683        self.durable_txid_max_old = NonZeroU64::new(durable_tx_max);
684        self.durable_txid_max_new = durable_tx_max;
685    }
686
687    fn refresh_schema_metadata(&mut self) {
688        let schema = self.connection.clone_shared_schema(self.database_id);
689        self.index_id_to_index = schema
690            .indexes
691            .values()
692            .flatten()
693            .map(|index| {
694                turso_assert!(index.root_page != 0, "index root_page must be non-zero");
695                (
696                    self.mvstore.get_table_id_from_root_page(index.root_page),
697                    index.clone(),
698                )
699            })
700            .collect();
701        self.mvcc_meta_table = schema.get_btree_table(MVCC_META_TABLE_NAME).map(|table| {
702            turso_assert!(
703                table.root_page != 0,
704                "mvcc meta table root_page must be non-zero"
705            );
706            (
707                self.mvstore.get_table_id_from_root_page(table.root_page),
708                table.columns().len(),
709            )
710        });
711        self.durable_mvcc_metadata =
712            !self.connection.db.is_in_memory_db() && self.mvcc_meta_table.is_some();
713    }
714
715    pub fn new(
716        pager: Arc<Pager>,
717        mvstore: Arc<MvStore<Clock, A>>,
718        connection: Arc<Connection>,
719        update_transaction_state: bool,
720        sync_mode: SyncMode,
721        database_id: usize,
722        mode: CheckpointMode,
723    ) -> Self {
724        assert!(
725            !matches!(mode, CheckpointMode::Passive { .. })
726                || connection.experimental_mvcc_passive_checkpoint_enabled(),
727            "passive checkpoint mode requires experimental_mvcc_passive_checkpoint"
728        );
729        // MVCC supports only Passive (no blocking lock, requires the experimental flag) and
730        // Truncate (blocking). Full/Restart map to Truncate — the pre-feature baseline
731        // always checkpointed via TRUNCATE.
732        let mode = match mode {
733            CheckpointMode::Passive { .. } | CheckpointMode::Truncate { .. } => mode,
734            CheckpointMode::Full | CheckpointMode::Restart => CheckpointMode::Truncate {
735                upper_bound_inclusive: None,
736            },
737        };
738        let checkpoint_lock = mvstore.blocking_checkpoint_lock.clone();
739        // Use the shared DB schema (not the per-connection cache, which may be
740        // stale) for the database whose pager we're checkpointing. Unlike WAL
741        // mode, MVCC checkpoint writes from the mv store back to the pager —
742        // so the schema must match the pager being checkpointed.
743        let schema = connection.clone_shared_schema(database_id);
744        let index_id_to_index = if connection.experimental_mvcc_passive_checkpoint_enabled() {
745            HashMap::default()
746        } else {
747            schema
748                .indexes
749                .values()
750                .flatten()
751                .map(|index| {
752                    turso_assert!(index.root_page != 0, "index root_page must be non-zero");
753                    (
754                        mvstore.get_table_id_from_root_page(index.root_page),
755                        index.clone(),
756                    )
757                })
758                .collect()
759        };
760
761        let mvcc_meta_table = schema.get_btree_table(MVCC_META_TABLE_NAME).map(|table| {
762            turso_assert!(
763                table.root_page != 0,
764                "mvcc meta table root_page must be non-zero"
765            );
766            (
767                mvstore.get_table_id_from_root_page(table.root_page),
768                table.columns().len(),
769            )
770        });
771        let durable_mvcc_metadata = !connection.db.is_in_memory_db() && mvcc_meta_table.is_some();
772        let durable_tx_max = mvstore.durable_txid_max.load(Ordering::SeqCst);
773        let durable_txid_max_old = NonZeroU64::new(durable_tx_max);
774        #[cfg(any(clt_turso_tests, injected_yields))]
775        let yield_instance_id = connection.next_yield_instance_id();
776        Self {
777            state: CheckpointState::PrepareCheckpoint,
778            lock_states: LockStates {
779                blocking_checkpoint_lock_held: false,
780                pager_read_tx: false,
781                pager_write_tx: false,
782            },
783            pager,
784            durable_txid_max_old,
785            durable_txid_max_new: durable_tx_max,
786            mvstore,
787            connection,
788            database_id,
789            #[cfg(any(clt_turso_tests, injected_yields))]
790            yield_instance_id,
791            checkpoint_lock,
792            write_set: crate::alloc::vec![],
793            write_row_state_machine: None,
794            delete_row_state_machine: None,
795            cursors: HashMap::default(),
796            created_btrees: HashMap::default(),
797            destroyed_tables: HashSet::default(),
798            destroyed_indexes: HashSet::default(),
799            index_write_set: crate::alloc::vec![],
800            index_id_to_index,
801            checkpoint_result: None,
802            update_transaction_state,
803            sync_mode,
804            mode,
805            mvcc_meta_table,
806            durable_mvcc_metadata,
807            staged_checkpoint_header: None,
808            header_staged_for_commit: false,
809            pager_commit_done: false,
810            pending_rootmap_ops: crate::alloc::vec![],
811            pending_alloc_roots: std::collections::HashMap::new(),
812            collect_table_cursor: None,
813            collect_index_tableid_cursor: None,
814            collect_index_key_cursor: None,
815            seq_compact: None,
816            pending_seq_deletes: crate::alloc::vec![],
817            // Set in PrepareCheckpoint once the collection snapshot is taken; until
818            // then `u64::MAX` disables the upper-bound filter (collect everything).
819            snapshot_ts: u64::MAX,
820            build_local_schema_sm: None,
821            build_local_schema_began_read_tx: false,
822            local_schema: None,
823            owns_checkpoint_in_progress: false,
824            staged_roots: crate::alloc::vec![],
825        }
826    }
827
828    #[cfg(clt_turso_tests)]
829    pub(crate) fn state_for_test(&self) -> CheckpointState {
830        self.state
831    }
832
833    #[cfg(clt_turso_tests)]
834    pub(crate) fn checkpoint_bounds_for_test(&self) -> (Option<u64>, u64) {
835        (
836            self.durable_txid_max_old.map(u64::from),
837            self.durable_txid_max_new,
838        )
839    }
840
841    /// Cleanup path for I/O errors that happen while waiting on completions outside
842    /// of `step()`. This mirrors `step()` error handling and also resets pager/WAL
843    /// checkpoint bookkeeping.
844    pub fn cleanup_after_external_io_error(&mut self, err: LimboError) -> Result<()> {
845        // run storage cleanup within proper checkpoint context (e.g. pager has pending read/write txn)
846        let result = self.mvstore.storage.on_checkpoint_end(Err(err));
847
848        // Drop this checkpoint's staged (not-yet-applied) root-map mutations. They were never
849        // written to the shared map (deferred to the post-commit publish window), so a failed
850        // checkpoint simply discards them — no revert, no divergence. A post-commit failure
851        // finds these already drained, so nothing is dropped.
852        self.pending_rootmap_ops.clear();
853        self.pending_alloc_roots.clear();
854
855        if self.lock_states.pager_write_tx {
856            self.pager.rollback_tx(self.connection.as_ref());
857            if self.update_transaction_state {
858                self.connection.set_tx_state(TransactionState::None);
859            }
860            self.lock_states.pager_write_tx = false;
861            self.lock_states.pager_read_tx = false;
862        } else if self.lock_states.pager_read_tx {
863            self.pager.end_read_tx();
864            if self.update_transaction_state {
865                self.connection.set_tx_state(TransactionState::None);
866            }
867            self.lock_states.pager_read_tx = false;
868        }
869
870        // MVCC checkpointing drives WAL checkpoint directly; on errors we must
871        // explicitly reset both pager and WAL checkpoint states.
872        self.pager.clear_checkpoint_state();
873        if let Some(wal) = self.pager.wal.as_ref() {
874            wal.abort_checkpoint();
875        }
876
877        // Release the checkpoint lock only after checkpoint state has been reset.
878        if self.lock_states.blocking_checkpoint_lock_held {
879            self.checkpoint_lock.unlock();
880            self.lock_states.blocking_checkpoint_lock_held = false;
881        }
882        // Release the single-orchestrator gate so a future checkpoint can run.
883        if self.owns_checkpoint_in_progress {
884            self.mvstore
885                .checkpoint_in_progress
886                .store(false, Ordering::Release);
887            self.owns_checkpoint_in_progress = false;
888        }
889
890        result
891    }
892
893    /// Returns all checkpointable [RowVersion]s for that `table_id`
894    fn maybe_get_checkpointable_versions(
895        &self,
896        versions: &[RowVersion],
897        table_id: MVTableId,
898    ) -> smallvec::SmallVec<[RowVersion; 1]> {
899        let mut versions_to_checkpoint: smallvec::SmallVec<[_; 1]> =
900            smallvec::SmallVec::with_capacity(1);
901        let mut exists_in_db_file = false;
902        // Iterate versions from oldest-to-newest to determine if the row exists in the database file and whether the newest version should be checkpointed.
903        for version in versions.iter() {
904            // A row is in the database file if:
905            // There is a version whose begin timestamp is <= than the last checkpoint timestamp, AND
906            // There is NO version whose END timestamp is <= than the last checkpoint timestamp.
907            // Resolve in-flight TxID begin/end markers to the owning tx's true state
908            // (concurrent collection may not have rewritten the chain to a Timestamp yet).
909            // Only a Committed tx contributes a timestamp; others resolve to None.
910            let begin_ts = match version.begin() {
911                Some(TxTimestampOrID::Timestamp(e)) => Some(e),
912                Some(TxTimestampOrID::TxID(t)) => {
913                    match lookup_tx_state(&self.mvstore.txs, &self.mvstore.finalized_tx_states, t) {
914                        Some(crate::mvcc::database::TransactionState::Committed(ts)) => Some(ts),
915                        _ => None,
916                    }
917                }
918                None => None,
919            };
920            let mut end_ts = match version.end() {
921                Some(TxTimestampOrID::Timestamp(e)) => Some(e),
922                Some(TxTimestampOrID::TxID(t)) => {
923                    match lookup_tx_state(&self.mvstore.txs, &self.mvstore.finalized_tx_states, t) {
924                        Some(crate::mvcc::database::TransactionState::Committed(ts)) => Some(ts),
925                        _ => None,
926                    }
927                }
928                None => None,
929            };
930            // Insert not visible at our snapshot (committed during the collection
931            // phase): defer to the next pass and don't let it affect DB-file existence now.
932            if begin_ts.is_some_and(|b| b > self.snapshot_ts) {
933                continue;
934            }
935            // Tombstone committed after our snapshot: clamp to "live" (end=None) so the
936            // row is checkpointed as PRESENT, not stranded; a later pass (delete <=
937            // snapshot) checkpoints the deletion. Fixes the future-tombstone orphan bug.
938            let future_committed_tombstone = end_ts.is_some_and(|e| e > self.snapshot_ts);
939            if future_committed_tombstone {
940                end_ts = None;
941            }
942            if begin_ts.is_none() && end_ts.is_none() {
943                // Rolled-back garbage and active TxID-only placeholders are not part of
944                // the durable row history, so they must not influence DB-file existence.
945                continue;
946            }
947            // Rows marked btree_resident existed in the DB file before MVCC tracked them.
948            // This also applies to synthetic tombstones that use begin=None.
949            if version.btree_resident {
950                exists_in_db_file = true;
951            }
952            // A row exists in the DB file if it was checkpointed in a previous checkpoint.
953            // For btree_resident rows we seed exists_in_db_file above, regardless of begin encoding.
954            // These timestamp-derived transitions must run after the btree_resident seed so a
955            // checkpointed tombstone can clear DB-file existence on a retry checkpoint.
956            if self
957                .durable_txid_max_old
958                .is_some_and(|txid_max_old| begin_ts.is_some_and(|b| b <= u64::from(txid_max_old)))
959            {
960                exists_in_db_file = true;
961            }
962            if self
963                .durable_txid_max_old
964                .is_some_and(|txid_max_old| end_ts.is_some_and(|e| e <= u64::from(txid_max_old)))
965            {
966                exists_in_db_file = false;
967            }
968            // Should checkpoint the newest version if:
969            // - It is not a delete and it hasn't been checkpointed yet OR (begin_ts > max_old)
970            // We need the `self.durable_txid_max_old.is_none()` check because before
971            // the first checkpoint there is no persisted MVCC watermark.
972            let is_uncheckpointed_insert = end_ts.is_none()
973                && self.durable_txid_max_old.is_none_or(|txid_max_old| {
974                    begin_ts.is_some_and(|b| b > u64::from(txid_max_old))
975                });
976            // - It is a delete, AND some version of the row exists in the database file.
977            let is_delete_and_exists_in_db_file = end_ts.is_some() && exists_in_db_file;
978            // - It is a delete of an uncheckpointed sqlite_schema row for a
979            //   table or index. The schema row itself is not in the DB file, but
980            //   checkpoint still needs it so it can remember that the table or
981            //   index was destroyed and skip that object's data/index rows.
982            //   Views and triggers have rootpage=0, so their uncheckpointed
983            //   deletes can be ignored here: there is no B-tree to destroy, and
984            //   deleting a missing sqlite_schema row would be wrong.
985            let is_schema_delete = table_id == SQLITE_SCHEMA_MVCC_TABLE_ID
986                && !exists_in_db_file
987                && sqlite_schema_btree_identity(version).is_some()
988                && self
989                    .durable_txid_max_old
990                    .is_none_or(|txid_max_old| end_ts.is_some_and(|e| e > u64::from(txid_max_old)));
991            let should_checkpoint =
992                is_uncheckpointed_insert || is_delete_and_exists_in_db_file || is_schema_delete;
993            if should_checkpoint {
994                // Future tombstone: push a clamped clone so the B-tree write sees a live insert.
995                let checkpoint_version = if future_committed_tombstone {
996                    let mut v = version.clone();
997                    v.set_end(None);
998                    v
999                } else {
1000                    let mut version = version.clone();
1001                    // Normalize the clone's end to the resolved end_ts so downstream
1002                    // is_delete is consistent (in-flight/aborted end -> live insert).
1003                    version.set_end(end_ts.map(TxTimestampOrID::Timestamp));
1004                    version
1005                };
1006                if table_id != SQLITE_SCHEMA_MVCC_TABLE_ID {
1007                    if versions_to_checkpoint.is_empty() {
1008                        versions_to_checkpoint.push(checkpoint_version)
1009                    } else {
1010                        versions_to_checkpoint[0] = checkpoint_version
1011                    }
1012                    continue;
1013                }
1014
1015                if let Some(previous_version) = versions_to_checkpoint.last() {
1016                    let should_drop_previous = previous_version.end().is_some()
1017                        && !is_schema_metadata_only_rewrite(previous_version, Some(version));
1018                    if should_drop_previous {
1019                        versions_to_checkpoint.pop();
1020                    }
1021                }
1022
1023                versions_to_checkpoint.push(checkpoint_version);
1024            }
1025        }
1026
1027        versions_to_checkpoint
1028    }
1029
1030    /// Resolve the table/index id whose B-tree binding owned `root_page` at the moment of the
1031    /// drop carried by `version` (a `sqlite_schema` DELETE). Selects the binding that COVERS the
1032    /// drop timestamp (`begin < drop_ts <= end`) rather than "any live binding at this root":
1033    /// under concurrent page reuse a freed root page can already belong to a newer live object, and
1034    /// destroying that one would corrupt a live btree. Returns `None` when no binding owned the
1035    /// page at the drop ts — the object was already destroyed by an earlier checkpoint and this
1036    /// schema-delete version merely lingered in the store and got recollected.
1037    fn resolve_dropped_binding(&self, root_page: u64, version: &RowVersion) -> Option<MVTableId> {
1038        let drop_ts = match version.end() {
1039            Some(TxTimestampOrID::Timestamp(t)) => t,
1040            _ => self.snapshot_ts,
1041        };
1042        self.mvstore
1043            .table_id_to_rootpage
1044            .iter()
1045            .find(|entry| {
1046                let e = entry.value();
1047                e.root_page == Some(root_page) && e.begin < drop_ts && drop_ts <= e.end
1048            })
1049            .map(|entry| *entry.key())
1050    }
1051
1052    /// Resolve a table/index root during collection (`pending_alloc_roots` or shared map).
1053    fn resolve_checkpoint_root(&self, table_id: MVTableId) -> Option<u64> {
1054        self.pending_alloc_roots
1055            .get(&table_id)
1056            .copied()
1057            .or_else(|| self.mvstore.current_root_page(&table_id))
1058    }
1059
1060    fn table_exists_for_snapshot(&self, table_id: MVTableId) -> bool {
1061        if self.pending_alloc_roots.contains_key(&table_id) {
1062            return true;
1063        }
1064        self.mvstore
1065            .table_id_to_rootpage
1066            .get(&table_id)
1067            .is_some_and(|entry| {
1068                let e = entry.value();
1069                e.root_page.is_some() && e.covers(self.snapshot_ts)
1070            })
1071    }
1072
1073    /// Stage a newly-created root for deferred publication. The write phase resolves it via
1074    /// [`Self::resolve_checkpoint_root`]; it is inserted into the shared map only at commit.
1075    fn ckpt_rootmap_alloc(&mut self, table_id: MVTableId, root_page: u64) {
1076        self.pending_alloc_roots.insert(table_id, root_page);
1077        self.pending_rootmap_ops.push(RootMapOp::Alloc {
1078            id: table_id,
1079            root: root_page,
1080        });
1081    }
1082
1083    /// Stage a root-binding retirement for deferred application at commit.
1084    fn ckpt_rootmap_retire(&mut self, table_id: MVTableId, end_ts: u64) {
1085        self.pending_rootmap_ops.push(RootMapOp::Retire {
1086            id: table_id,
1087            end_ts,
1088        });
1089    }
1090
1091    /// Stage a root-binding removal for deferred application at commit.
1092    fn ckpt_rootmap_remove(&mut self, table_id: MVTableId) {
1093        self.pending_rootmap_ops
1094            .push(RootMapOp::Remove { id: table_id });
1095    }
1096
1097    /// Collect all committed versions that need to be written to the B-tree.
1098    /// We must only write to the B-tree if:
1099    /// 1. The row has not already been checkpointed in a previous checkpoint.
1100    ///    TODO: garbage collect row versions after checkpointing.
1101    /// 2. Either:
1102    ///    * The row is not a delete (we inserted or changed an existing row), OR
1103    ///    * The row is a delete AND it exists in the database file already.
1104    ///      If the row didn't exist in the database file and was deleted, we can simply not write it.
1105    fn collect_table_rows(&mut self) -> Result<Option<IOCompletions>> {
1106        // Invariant: RowID ordering is (table_id, row_id) with table_id ascending.
1107        // Since MV table IDs are negative and sqlite_schema is table_id=-1, iterating
1108        // in reverse visits sqlite_schema first so CREATE/DROP metadata is applied
1109        // before user-table rows in this checkpoint pass.
1110        let bounds: (Bound<RowID>, Bound<RowID>) = match self.collect_table_cursor.clone() {
1111            None => (Bound::Unbounded, Bound::Unbounded),
1112            Some(last) => (Bound::Unbounded, Bound::Excluded(last)),
1113        };
1114        let mut processed = 0;
1115        for entry in self.mvstore.rows.range(bounds).rev() {
1116            let key = entry.key();
1117            tracing::trace!("collecting {key:?}");
1118            self.collect_table_cursor = Some(key.clone());
1119            if self.destroyed_tables.contains(&key.table_id) {
1120                // We won't checkpoint rows for tables that will be destroyed in this checkpoint.
1121                // There's two forms of destroyed table:
1122                // 1. A non-checkpointed table that was created in the logical log and then destroyed. We don't need to do anything about this table in the pager/btree layer.
1123                // 2. A checkpointed table that was destroyed in the logical log. We need to destroy the btree in the pager/btree layer.
1124                tracing::trace!("skipping {key:?}");
1125                continue;
1126            }
1127
1128            let row_versions = entry.value().read();
1129
1130            for version in self.maybe_get_checkpointable_versions(&row_versions, key.table_id) {
1131                let is_delete = version.end().is_some();
1132
1133                let mut special_write = None;
1134                // Set to true for schema deletes of never-checkpointed tables/indexes.
1135                // These don't need to be written to the B-tree, we just need to track them.
1136                let mut skip_write = false;
1137
1138                if let Some(schema_identity) = sqlite_schema_btree_identity(&version) {
1139                    let root_page = schema_identity.root_page;
1140                    match schema_identity.kind {
1141                        SqliteSchemaBtreeKind::Index => {
1142                            // This is an index schema change
1143                            if is_delete {
1144                                // DROP INDEX
1145                                if root_page < 0 {
1146                                    // Index was never checkpointed - derive index_id directly from root_page.
1147                                    // No BTreeDestroyIndex needed since there's no physical B-tree.
1148                                    let index_id = MVTableId(root_page);
1149                                    self.destroyed_indexes.insert(index_id);
1150                                    // Defer the removal to the publish window. Pushing to the
1151                                    // staged op list borrows only that field, so it is allowed
1152                                    // inside the `self.mvstore.rows` iteration.
1153                                    self.pending_rootmap_ops
1154                                        .push(RootMapOp::Remove { id: index_id });
1155                                    skip_write = true;
1156                                } else if let Some(index_id) =
1157                                    self.resolve_dropped_binding(root_page as u64, &version)
1158                                {
1159                                    // DROP INDEX - index was checkpointed. Resolve the dropped
1160                                    // index from the binding that owned this root at the drop ts.
1161                                    self.destroyed_indexes.insert(index_id);
1162
1163                                    // DROP INDEX during checkpoint: schema may no longer contain the index definition.
1164                                    // Fixes DROP INDEX during checkpoint when the schema cache no longer
1165                                    // contains the index metadata; we only need a cursor to destroy pages so num_columns is not important.
1166                                    let num_columns = self
1167                                        .index_id_to_index
1168                                        .get(&index_id)
1169                                        .map(|index| index.columns.len())
1170                                        .unwrap_or(0);
1171
1172                                    special_write = Some(SpecialWrite::BTreeDestroyIndex {
1173                                        index_id,
1174                                        root_page: root_page as u64,
1175                                        num_columns,
1176                                    });
1177                                } else {
1178                                    // No binding owned this root at the drop ts: the index was
1179                                    // already destroyed by an earlier checkpoint and this
1180                                    // schema-delete lingered in the store. Nothing to destroy.
1181                                    skip_write = true;
1182                                }
1183                            } else if root_page < 0 {
1184                                // CREATE INDEX (root page is negative so the index has not been checkpointed yet).
1185                                let index_id = MVTableId::from(root_page);
1186                                let sqlite_schema_rowid = version.row.id.row_id.to_int_or_panic();
1187                                special_write = Some(SpecialWrite::BTreeCreateIndex {
1188                                    index_id,
1189                                    sqlite_schema_rowid,
1190                                });
1191                            } else {
1192                                // Index schema row update (e.g. ALTER TABLE RENAME COLUMN propagates
1193                                // to index SQL). No B-tree creation needed; the row itself is written
1194                                // to sqlite_schema below. See: test_checkpoint_allows_index_schema_update_after_rename_column.
1195                            }
1196                        }
1197                        SqliteSchemaBtreeKind::Table => {
1198                            // This is a table schema change (existing logic)
1199                            tracing::trace!(
1200                                "table schema change with root page {root_page}, is_delete={is_delete}"
1201                            );
1202                            if is_delete {
1203                                if root_page < 0 {
1204                                    // Table was never checkpointed - derive table_id directly from root_page.
1205                                    // No BTreeDestroy needed since there's no physical B-tree.
1206                                    let table_id = MVTableId::from(root_page);
1207                                    self.destroyed_tables.insert(table_id);
1208                                    // Defer the removal to the publish window (push borrows only
1209                                    // the staged-op field, allowed inside the rows iteration).
1210                                    self.pending_rootmap_ops
1211                                        .push(RootMapOp::Remove { id: table_id });
1212                                    skip_write = true;
1213                                } else if let Some(table_id) =
1214                                    self.resolve_dropped_binding(root_page as u64, &version)
1215                                {
1216                                    // Table was checkpointed - resolve from the binding that owned
1217                                    // this root at the drop ts (snapshot-consistent under reuse).
1218                                    self.destroyed_tables.insert(table_id);
1219
1220                                    // Destroy the B-tree in the pager during checkpoint
1221                                    special_write = Some(SpecialWrite::BTreeDestroy {
1222                                        table_id,
1223                                        root_page: root_page as u64,
1224                                        num_columns: version.row.column_count,
1225                                    });
1226                                }
1227                            } else if root_page < 0 {
1228                                // CREATE TABLE (root page is negative so the table has not been checkpointed yet).
1229                                let table_id = MVTableId::from(root_page);
1230                                let sqlite_schema_rowid = version.row.id.row_id.to_int_or_panic();
1231                                special_write = Some(SpecialWrite::BTreeCreate {
1232                                    table_id,
1233                                    sqlite_schema_rowid,
1234                                });
1235                            } else {
1236                                // ALTER TABLE. No "special write is needed"; we'll just update the row in sqlite_schema.
1237                            }
1238                        }
1239                    }
1240                } else if is_delete
1241                    && version.row.id.table_id == SQLITE_SCHEMA_MVCC_TABLE_ID
1242                    && !version.btree_resident
1243                {
1244                    // Schema row without a B-tree identity (e.g. sequence, trigger, view).
1245                    // If it was never checkpointed to the B-tree, skip the delete — there
1246                    // is nothing to remove from the pager.
1247                    let begin_ts = match &version.begin() {
1248                        Some(TxTimestampOrID::Timestamp(ts)) => Some(*ts),
1249                        _ => None,
1250                    };
1251                    let was_checkpointed = self.durable_txid_max_old.is_some_and(|txid_max_old| {
1252                        begin_ts.is_some_and(|b| b <= u64::from(txid_max_old))
1253                    });
1254                    if !was_checkpointed {
1255                        skip_write = true;
1256                    }
1257                } else if key.table_id != SQLITE_SCHEMA_MVCC_TABLE_ID
1258                    && is_delete
1259                    && !self.table_exists_for_snapshot(key.table_id)
1260                {
1261                    // B-tree was destroyed in a prior checkpoint; late tombstones are logical-only.
1262                    skip_write = true;
1263                }
1264                if !skip_write {
1265                    tracing::trace!("adding to write_set {:?}", (&version, &special_write));
1266                    with_mvcc_checkpoint_allocation_site!(CheckpointWriteSet, {
1267                        self.write_set.try_push((version, special_write))?;
1268                    });
1269                }
1270            }
1271            processed += 1;
1272            if processed >= COLLECT_PREEMPTION_THRESHOLD {
1273                return Ok(Some(IOCompletions::Single(Completion::new_yield())));
1274            }
1275        }
1276        // Writing in ascending order of rowid gives us a better chance of using balance-quick algorithm
1277        // in case of an insert-heavy checkpoint.
1278        self.write_set.sort_by_key(|version| {
1279            (
1280                // Sort by table_id descending (schema changes first)
1281                std::cmp::Reverse(version.0.row.id.table_id),
1282                // Then by row_id ascending
1283                version.0.row.id.row_id.clone(),
1284            )
1285        });
1286        Ok(None)
1287    }
1288
1289    /// Collect all committed index row versions that need to be written to the B-tree.
1290    /// Index rows are stored separately from table rows and must be checkpointed independently.
1291    /// We must only write to the B-tree if:
1292    /// 1. The row has not already been checkpointed in a previous checkpoint.
1293    /// 2. Either:
1294    ///    * The row is not a delete (we inserted or changed an existing row), OR
1295    ///    * The row is a delete AND it exists in the database file already.
1296    fn collect_index_rows(&mut self) -> Result<Option<IOCompletions>> {
1297        let outer_bounds: (Bound<MVTableId>, Bound<MVTableId>) =
1298            match self.collect_index_tableid_cursor {
1299                None => (Bound::Unbounded, Bound::Unbounded),
1300                Some(last) if self.collect_index_key_cursor.is_none() => {
1301                    (Bound::Excluded(last), Bound::Unbounded)
1302                }
1303                Some(last) => (Bound::Included(last), Bound::Unbounded),
1304            };
1305        let mut processed = 0;
1306        for entry in self.mvstore.index_rows.range(outer_bounds) {
1307            let index_id = *entry.key();
1308
1309            // Skip destroyed indexes - we won't checkpoint rows for indexes that will be destroyed
1310            if self.destroyed_indexes.contains(&index_id) {
1311                self.collect_index_tableid_cursor = Some(index_id);
1312                self.collect_index_key_cursor = None;
1313                continue;
1314            }
1315
1316            let index_rows_map = entry.value();
1317            let inner_bounds: (Bound<Arc<SortableIndexKey>>, Bound<Arc<SortableIndexKey>>) =
1318                match self.collect_index_key_cursor.clone() {
1319                    None => (Bound::Unbounded, Bound::Unbounded),
1320                    Some(last) => (Bound::Excluded(last), Bound::Unbounded),
1321                };
1322            for entry in index_rows_map.range(inner_bounds) {
1323                let versions = entry.value().read();
1324                self.collect_index_tableid_cursor = Some(index_id);
1325                self.collect_index_key_cursor = Some(entry.key().clone());
1326
1327                for version in self.maybe_get_checkpointable_versions(&versions, index_id) {
1328                    let is_delete = version.end().is_some();
1329                    if is_delete && !self.table_exists_for_snapshot(index_id) {
1330                        continue;
1331                    }
1332
1333                    // Only write the row to the B-tree if it is not a delete, or if it is a delete and it exists in
1334                    // the database file.
1335                    with_mvcc_checkpoint_allocation_site!(CheckpointIndexWriteSet, {
1336                        self.index_write_set
1337                            .try_push((index_id, version, is_delete))?;
1338                    });
1339                }
1340                processed += 1;
1341                if processed >= COLLECT_PREEMPTION_THRESHOLD {
1342                    return Ok(Some(IOCompletions::Single(Completion::new_yield())));
1343                }
1344            }
1345            self.collect_index_tableid_cursor = Some(index_id);
1346            self.collect_index_key_cursor = None;
1347        }
1348        Ok(None)
1349    }
1350
1351    #[cfg(any(clt_turso_tests, debug_assertions))]
1352    fn max_collected_version_timestamp(&self) -> u64 {
1353        fn max_version_timestamp(version: &RowVersion) -> u64 {
1354            [version.begin().as_ref(), version.end().as_ref()]
1355                .into_iter()
1356                .filter_map(|ts| match ts {
1357                    Some(TxTimestampOrID::Timestamp(ts)) => Some(*ts),
1358                    _ => None,
1359                })
1360                .max()
1361                .unwrap_or_default()
1362        }
1363
1364        let table_max = self
1365            .write_set
1366            .iter()
1367            .map(|(version, _)| max_version_timestamp(version))
1368            .max()
1369            .unwrap_or_default();
1370        let index_max = self
1371            .index_write_set
1372            .iter()
1373            .map(|(_, version, _)| max_version_timestamp(version))
1374            .max()
1375            .unwrap_or_default();
1376        table_max.max(index_max)
1377    }
1378
1379    /// Get the current row version to write to the B-tree
1380    fn get_current_row_version(
1381        &self,
1382        write_set_index: usize,
1383    ) -> Option<&(RowVersion, Option<SpecialWrite>)> {
1384        self.write_set.get(write_set_index)
1385    }
1386
1387    /// Mutably get the current row version to write to the B-tree
1388    fn get_current_row_version_mut(
1389        &mut self,
1390        write_set_index: usize,
1391    ) -> Option<&mut (RowVersion, Option<SpecialWrite>)> {
1392        self.write_set.get_mut(write_set_index)
1393    }
1394
1395    /// Check if we have more rows to write
1396    fn has_more_rows(&self, write_set_index: usize) -> bool {
1397        write_set_index < self.write_set.len()
1398    }
1399
1400    fn next_requires_seek_after_insert(&self, current_idx: usize) -> bool {
1401        let Some(curr) = self.write_set.get(current_idx) else {
1402            return true;
1403        };
1404        let Some(next) = self.write_set.get(current_idx + 1) else {
1405            return true;
1406        };
1407        // Table not the same, then seek
1408        if curr.0.row.id.table_id != next.0.row.id.table_id {
1409            return true;
1410        }
1411        // If we have special write then seek
1412        if curr.1.is_some() || next.1.is_some() {
1413            return true;
1414        }
1415        let (RowKey::Int(prev_id), RowKey::Int(next_id)) =
1416            (&curr.0.row.id.row_id, &next.0.row.id.row_id)
1417        else {
1418            return true;
1419        };
1420        // if next id is strictly prev_id + 1 then we don't need to seek
1421        if next_id.checked_sub(*prev_id) != Some(1) {
1422            return true;
1423        }
1424        false
1425    }
1426
1427    /// Fsync the logical log file
1428    fn fsync_logical_log(&self) -> Result<Completion> {
1429        self.mvstore.storage.sync(self.pager.get_sync_type())
1430    }
1431
1432    fn truncate_logical_log(&self) -> Result<Completion> {
1433        let boundary = if self.mode.should_restart_log() {
1434            u64::MAX
1435        } else {
1436            self.durable_txid_max_new
1437        };
1438        self.mvstore.storage.truncate(boundary)
1439    }
1440
1441    /// Perform a TRUNCATE checkpoint on the WAL
1442    fn checkpoint_wal(&self) -> Result<IOResult<CheckpointResult>> {
1443        let Some(wal) = &self.pager.wal else {
1444            panic!("No WAL to checkpoint");
1445        };
1446        match wal.checkpoint(&self.pager, self.mode)? {
1447            IOResult::Done(result) => Ok(IOResult::Done(result)),
1448            IOResult::IO(io) => Ok(IOResult::IO(io)),
1449        }
1450    }
1451
1452    fn has_unpublished_schema_changes(&self) -> bool {
1453        let schema = self.connection.db.schema.lock();
1454        if !schema.dropped_root_pages.is_empty() {
1455            return true;
1456        }
1457        // A negative root page is "unpublished" only if THIS checkpoint materialized the
1458        // object (has a real root page in `table_id_to_rootpage` not yet written back to
1459        // the schema). Objects created after our snapshot are deferred to a later
1460        // checkpoint and have no mapping yet, so their placeholders aren't our work —
1461        // counting them was the false-positive that panicked the TruncateWal assert.
1462        let owned_negative = |root_page: i64| -> bool {
1463            root_page < 0
1464                && self
1465                    .mvstore
1466                    .table_id_to_rootpage
1467                    .get(&MVTableId::from(root_page))
1468                    .and_then(|entry| entry.value().root_page)
1469                    .is_some()
1470        };
1471        schema.tables.values().any(|table| {
1472            table
1473                .btree()
1474                .is_some_and(|btree| owned_negative(btree.root_page))
1475        }) || schema
1476            .indexes
1477            .values()
1478            .flatten()
1479            .any(|index| owned_negative(index.root_page))
1480    }
1481
1482    fn has_pending_root_publication(&self) -> bool {
1483        !self.created_btrees.is_empty() || self.has_unpublished_schema_changes()
1484    }
1485
1486    fn publish_checkpointed_schema_roots(&mut self) -> Result<(), TryReserveError> {
1487        if !self.has_pending_root_publication() {
1488            return Ok(());
1489        }
1490
1491        // Patch sqlite_schema rows in the MV store to use positive rootpages for the btrees
1492        // flushed to the physical database.
1493        for (sqlite_schema_rowid, (_, row_version)) in self.created_btrees.drain() {
1494            let key = RowID {
1495                table_id: SQLITE_SCHEMA_MVCC_TABLE_ID,
1496                row_id: RowKey::Int(sqlite_schema_rowid),
1497            };
1498            let sqlite_schema_row = self
1499                .mvstore
1500                .rows
1501                .get(&key)
1502                .expect("sqlite_schema row not found");
1503            let mut row_versions = sqlite_schema_row.value().write();
1504            // Replace in place (same version id), don't append: a duplicate (id, begin, end)
1505            // would leave a phantom current version after a later DELETE (which ends only the
1506            // first match), causing spurious write-write conflicts at commit time.
1507            let vid = row_version.id;
1508            if let Some(existing) = row_versions.iter_mut().find(|rv| rv.id == vid) {
1509                *existing = row_version;
1510            } else {
1511                self.mvstore
1512                    .insert_version_raw(&mut row_versions, row_version)?;
1513            }
1514        }
1515
1516        if !self.has_unpublished_schema_changes() {
1517            return Ok(());
1518        }
1519
1520        // Patch the live db.schema (not local_schema, the checkpoint's private snapshot) so
1521        // clone_schema() propagates real root pages; negative placeholders would orphan the
1522        // new btree pages.
1523        let mut schema_ref = self.connection.db.schema.lock();
1524        let schema = Schema::try_make_mut(&mut schema_ref)?;
1525        for (name, table) in schema.tables.iter_mut() {
1526            #[cfg(not(clt_turso_feature = "conn_raw_api"))]
1527            let _ = name;
1528            let table = Arc::get_mut(table).expect("this should be the only reference");
1529            let Some(btree_table) = table.btree_mut() else {
1530                continue;
1531            };
1532            let btree_table = Arc::make_mut(btree_table);
1533            if btree_table.root_page < 0 {
1534                #[cfg(clt_turso_feature = "conn_raw_api")]
1535                let old_root_page = btree_table.root_page;
1536                let table_id = MVTableId::from(btree_table.root_page);
1537                // Only tables this pass materialized; ones created after our snapshot stay
1538                // negative for a later pass.
1539                if let Some(root_page) = self
1540                    .mvstore
1541                    .table_id_to_rootpage
1542                    .get(&table_id)
1543                    .and_then(|entry| entry.value().root_page)
1544                {
1545                    btree_table.root_page = root_page as i64;
1546                    #[cfg(clt_turso_feature = "conn_raw_api")]
1547                    {
1548                        schema.table_names_by_root_page.remove(&old_root_page);
1549                        schema
1550                            .table_names_by_root_page
1551                            .insert(btree_table.root_page, name.clone());
1552                    }
1553                }
1554            }
1555        }
1556        for table_index_list in schema.indexes.values_mut() {
1557            for index in table_index_list.iter_mut() {
1558                if index.root_page < 0 {
1559                    let table_id = MVTableId::from(index.root_page);
1560                    // Same as tables: skip indexes not materialized by this pass.
1561                    if let Some(root_page) = self
1562                        .mvstore
1563                        .table_id_to_rootpage
1564                        .get(&table_id)
1565                        .and_then(|entry| entry.value().root_page)
1566                    {
1567                        let index = Arc::make_mut(index);
1568                        index.root_page = root_page as i64;
1569                    }
1570                }
1571            }
1572        }
1573
1574        // Clear dropped root pages now that the pager commit contains the btree frees.
1575        // integrity_check should now follow the committed freelist state instead.
1576        schema.dropped_root_pages.clear();
1577        drop(schema_ref);
1578        *self.connection.schema.write() = self.connection.db.clone_schema();
1579        self.connection.bump_prepare_context_generation();
1580        self.mvstore.bump_schema_generation();
1581        Ok(())
1582    }
1583
1584    /// Apply deferred root-map mutations, advance `durable_txid_max`, and publish the staged
1585    /// page-1 header + schema roots. Caller must either hold `blocking_checkpoint_lock` write
1586    /// (truncate / explicit passive) or run inside `MvStore::finish_passive_publish_window`.
1587    fn apply_checkpoint_publish_window(
1588        &mut self,
1589        materialized_at: WalPos,
1590        seq_delete_ts: Option<u64>,
1591    ) -> Result<()> {
1592        // PASSIVE sequence compaction (Option B): turn the rows the sweep recorded into proper
1593        // end-stamped deletes, using the publish-window timestamp. It is allocated under the same
1594        // clock as concurrent `begin_tx`, so it is > every concurrent reader's begin (they see the
1595        // row live and conflict, never re-synthesize a tombstone) and > durable_txid_max_new (the
1596        // boundary stored below), so the next checkpoint still materializes the physical delete.
1597        if let Some(ts) = seq_delete_ts {
1598            for (rowid, num_cols) in
1599                std::mem::replace(&mut self.pending_seq_deletes, crate::alloc::vec![])
1600            {
1601                self.mvstore.seqcompact_commit_delete(rowid, num_cols, ts);
1602            }
1603        }
1604        let ops = std::mem::replace(&mut self.pending_rootmap_ops, crate::alloc::vec![]);
1605        for op in &ops {
1606            match op {
1607                RootMapOp::Retire { id, end_ts } => self.mvstore.retire_rootpage(*id, *end_ts),
1608                RootMapOp::Remove { id } => self.mvstore.remove_table_id_to_rootpage(id),
1609                RootMapOp::Alloc { .. } => {}
1610            }
1611        }
1612        for op in &ops {
1613            if let RootMapOp::Alloc { id, root } = op {
1614                self.mvstore
1615                    .record_rootpage_alloc(*id, *root, self.snapshot_ts, WalPos::STAGED);
1616            }
1617        }
1618        self.pending_alloc_roots.clear();
1619        for table_id in std::mem::replace(&mut self.staged_roots, crate::alloc::vec![]) {
1620            self.mvstore
1621                .publish_rootpage_visible(table_id, materialized_at);
1622        }
1623        self.mvstore
1624            .durable_txid_max
1625            .store(self.durable_txid_max_new, Ordering::SeqCst);
1626        self.state = CheckpointState::CheckpointWal;
1627        self.lock_states.pager_read_tx = false;
1628        self.lock_states.pager_write_tx = false;
1629        let header = self.staged_checkpoint_header.take().ok_or_else(|| {
1630            LimboError::InternalError(
1631                "checkpoint header was not staged before pager commit".to_string(),
1632            )
1633        })?;
1634        self.mvstore.global_header.write().replace(header);
1635        crate::without_allocation_faults!(self
1636            .publish_checkpointed_schema_roots()
1637            .expect(crate::alloc::ALLOC_ERR_MSG));
1638        Ok(())
1639    }
1640
1641    /// Passive auto-checkpoint publish: clock-ordered apply (no RW lock). Caller must have
1642    /// already acquired the publish drain bit via `try_begin_passive_publish_window`.
1643    fn apply_passive_publish_window_ordered(&mut self, materialized_at: WalPos) -> Result<()> {
1644        let mut publish_err = None;
1645        // SAFETY: `apply_checkpoint_publish_window` mutates only this state machine and the
1646        // mvstore. The clock lock excludes concurrent `begin_tx` publication; the publish
1647        // drain bit excludes new begins before they reach the clock lock.
1648        let sm = self as *mut Self;
1649        self.mvstore.clock.get_timestamp(|ts| {
1650            // SAFETY: see above. `ts` is this publish's clock timestamp; pass it as the commit ts
1651            // for passive sequence-compaction deletes (see apply_checkpoint_publish_window).
1652            if let Err(err) =
1653                unsafe { (*sm).apply_checkpoint_publish_window(materialized_at, Some(ts)) }
1654            {
1655                publish_err = Some(err);
1656            }
1657        });
1658        self.mvstore.end_passive_publish_window();
1659        if let Some(err) = publish_err {
1660            return Err(err);
1661        }
1662        Ok(())
1663    }
1664
1665    /// The version-store GC floor mark for the passive checkpoint: the lowest of the published
1666    /// MVCC readers' marks AND any reader pinned at the pager/WAL level (via `begin_read_tx`) that
1667    /// has not yet published an MVCC transaction. The latter closes the begin-tx publish window —
1668    /// without it, a reader that pinned an old WAL frame but is not yet in `txs` is invisible to
1669    /// `compute_min_reader_mark`, so its still-needed versions get reclaimed and it then reads a
1670    /// stale btree (the delete/index desync). The WAL read lock is held from `begin_read_tx`, so
1671    /// this catches it.
1672    fn gc_floor_reader_mark(&self) -> WalPos {
1673        let mvcc = self.mvstore.compute_min_reader_mark();
1674        let readers = match self.pager.min_pinned_read_frame() {
1675            Some(frame) => {
1676                let (seq, _) = self.pager.wal_pos();
1677                mvcc.min(WalPos::from_pair((seq, frame)))
1678            }
1679            None => mvcc,
1680        };
1681        // Bound by the backfill boundary: a version still materialized in un-backfilled WAL
1682        // frames is unreachable by a db-file reader (present or future), so never reclaim it
1683        // until backfilled. This is the true floor and subsumes the live-reader marks.
1684        readers.min(*self.mvstore.backfill_floor.read())
1685    }
1686
1687    fn gc_checkpointed_table_versions(&mut self) -> Option<IOCompletions> {
1688        // Empty-slot removal after dropping the version-chain write lock has a TOCTOU
1689        // gap — a concurrent writer could `get_or_insert_with` between the emptiness
1690        // check and `remove()`. It is only safe under the stop-the-world blocking lock,
1691        // which the Truncate/Restart path holds. The passive path runs lazily
1692        // (slots are reclaimed by a later insert or a future blocking checkpoint), exactly
1693        // like the inline commit-path GC (`gc_incremental`).
1694        let remove_empty_slots = self.lock_states.blocking_checkpoint_lock_held;
1695        let ckpt_max = self.durable_txid_max_new;
1696        // Reader floor for the per-version GC, including readers pinned at the pager/WAL level
1697        // that have not yet published an MVCC transaction (the begin-tx publish window).
1698        let min_reader_mark = self.gc_floor_reader_mark();
1699        // The WAL position this checkpoint's pages reached durability at; what we stamp the
1700        // just-materialized versions with. (Unchanged since CommitPagerTxn — single orchestrator.)
1701        let materialized_frame = WalPos::from_pair(self.pager.wal_pos());
1702        let snapshot_ts = self.snapshot_ts;
1703        let CheckpointState::GcTableRows { next_index, lwm } = self.state else {
1704            unreachable!("gc_checkpointed_table_versions runs only in GcTableRows");
1705        };
1706        let mut index = next_index;
1707        let mut processed = 0;
1708        while index < self.write_set.len() {
1709            let current = index;
1710            index += 1;
1711            if current > 0
1712                && self.write_set[current - 1].0.row.id == self.write_set[current].0.row.id
1713            {
1714                continue;
1715            }
1716            let row_id = &self.write_set[current].0.row.id;
1717            if let Some(entry) = self.mvstore.rows.get(row_id) {
1718                let is_now_empty = {
1719                    let mut versions = entry.value().write();
1720                    self.mvstore.stamp_chain_materialized(
1721                        &mut versions,
1722                        materialized_frame,
1723                        snapshot_ts,
1724                    );
1725                    MvStore::<Clock, A>::gc_version_chain(
1726                        &mut versions,
1727                        lwm,
1728                        ckpt_max,
1729                        self.mvstore.experimental_mvcc_passive_checkpoint,
1730                        min_reader_mark,
1731                    );
1732                    versions.is_empty()
1733                };
1734                if is_now_empty && remove_empty_slots {
1735                    self.mvstore.rows.remove(row_id);
1736                }
1737            } else {
1738                // The MVCC metadata table row (persistent_tx_ts_max) is staged
1739                // directly into the write set by maybe_stage_mvcc_metadata_write() and do not
1740                // have a backing in-memory MVCC version chain. Skip GC for these.
1741                assert!(
1742                    self.mvcc_meta_table
1743                        .is_some_and(|(tid, _)| tid == row_id.table_id),
1744                    "row {row_id:?} missing from MVCC store but is not an MVCC metadata table row"
1745                );
1746            }
1747            processed += 1;
1748            if processed >= COLLECT_PREEMPTION_THRESHOLD {
1749                break;
1750            }
1751        }
1752        if index < self.write_set.len() {
1753            let CheckpointState::GcTableRows { next_index, .. } = &mut self.state else {
1754                unreachable!("gc_checkpointed_table_versions runs only in GcTableRows");
1755            };
1756            *next_index = index;
1757
1758            Some(IOCompletions::Single(Completion::new_yield()))
1759        } else {
1760            None
1761        }
1762    }
1763
1764    fn gc_checkpointed_index_versions(&mut self) -> Option<IOCompletions> {
1765        // See gc_checkpointed_table_versions: slot removal only under the blocking lock
1766        // (Truncate/Restart); the passive path is lazy.
1767        let remove_empty_slots = self.lock_states.blocking_checkpoint_lock_held;
1768        let ckpt_max = self.durable_txid_max_new;
1769        let min_reader_mark = self.gc_floor_reader_mark();
1770        let materialized_frame = WalPos::from_pair(self.pager.wal_pos());
1771        let snapshot_ts = self.snapshot_ts;
1772        let CheckpointState::GcIndexRows { next_index, lwm } = self.state else {
1773            unreachable!("gc_checkpointed_index_versions runs only in GcIndexRows");
1774        };
1775        let mut index = next_index;
1776        let mut processed = 0;
1777        while index < self.index_write_set.len() {
1778            let current = index;
1779            index += 1;
1780            {
1781                let (index_id, row_version, _is_delete) = &self.index_write_set[current];
1782                let index_id = *index_id;
1783                let RowKey::Record(sortable_key) = &row_version.row.id.row_id else {
1784                    unreachable!("index row versions always have Record keys");
1785                };
1786                let outer_entry = self
1787                    .mvstore
1788                    .index_rows
1789                    .get(&index_id)
1790                    .expect("index_id from write set must exist in index_rows");
1791                let inner_map = outer_entry.value();
1792                let is_now_empty = {
1793                    let inner_entry = inner_map
1794                        .get(sortable_key)
1795                        .expect("index row from write set must exist in inner map");
1796                    let mut versions = inner_entry.value().write();
1797                    self.mvstore.stamp_chain_materialized(
1798                        &mut versions,
1799                        materialized_frame,
1800                        snapshot_ts,
1801                    );
1802                    MvStore::<Clock, A>::gc_version_chain(
1803                        &mut versions,
1804                        lwm,
1805                        ckpt_max,
1806                        self.mvstore.experimental_mvcc_passive_checkpoint,
1807                        min_reader_mark,
1808                    );
1809                    versions.is_empty()
1810                };
1811                if is_now_empty && remove_empty_slots {
1812                    inner_map.remove(sortable_key);
1813                }
1814            }
1815            processed += 1;
1816            if processed >= COLLECT_PREEMPTION_THRESHOLD {
1817                break;
1818            }
1819        }
1820        if index < self.index_write_set.len() {
1821            let CheckpointState::GcIndexRows { next_index, .. } = &mut self.state else {
1822                unreachable!("gc_checkpointed_index_versions runs only in GcIndexRows");
1823            };
1824            *next_index = index;
1825            Some(IOCompletions::Single(Completion::new_yield()))
1826        } else {
1827            None
1828        }
1829    }
1830
1831    /// Stages synthetic `persistent_tx_ts_max` row into the checkpoint write set
1832    /// so it is committed atomically with all other data in the same pager transaction.
1833    /// This is the mechanism that advances the durable replay boundary; on recovery, only
1834    /// logical-log frames with `commit_ts > persistent_tx_ts_max` are replayed.
1835    /// No-op when metadata hasn't advanced or when running in-memory (no durable metadata).
1836    fn maybe_stage_mvcc_metadata_write(&mut self) -> Result<()> {
1837        if !self.durable_mvcc_metadata {
1838            return Ok(());
1839        }
1840        let old = self.durable_txid_max_old.map(u64::from).unwrap_or_default();
1841        let new = self.durable_txid_max_new;
1842        if new <= old {
1843            return Ok(());
1844        }
1845
1846        let (table_id, num_columns) = self.mvcc_meta_table.ok_or_else(|| {
1847            LimboError::Corrupt(format!(
1848                "Missing required internal metadata table {MVCC_META_TABLE_NAME}"
1849            ))
1850        })?;
1851        let new_i64 = i64::try_from(new).map_err(|_| {
1852            LimboError::Corrupt(format!("MVCC checkpoint timestamp does not fit i64: {new}"))
1853        })?;
1854        let record = with_mvcc_checkpoint_allocation_site!(
1855            CheckpointMetadataPayload,
1856            ImmutableRecord::from_values(
1857                &[
1858                    Value::build_text(MVCC_META_KEY_PERSISTENT_TX_TS_MAX),
1859                    Value::from_i64(new_i64),
1860                ],
1861                2,
1862            )?
1863        );
1864        let row = with_mvcc_checkpoint_allocation_site!(
1865            CheckpointMetadataPayload,
1866            Row::new_table_row_in(
1867                RowID::new(table_id, RowKey::Int(1)),
1868                record.get_payload(),
1869                num_columns,
1870                self.mvstore.allocator(),
1871            )?
1872        );
1873        with_mvcc_checkpoint_allocation_site!(CheckpointWriteSet, {
1874            self.write_set.try_push((
1875                RowVersion {
1876                    id: 0,
1877                    begin: crate::mvcc::database::PackedTs::pack(Some(TxTimestampOrID::Timestamp(
1878                        new,
1879                    ))),
1880                    end: crate::mvcc::database::PackedTs::pack(None),
1881                    row,
1882                    btree_resident: true,
1883                    materialized_at: crate::mvcc::database::WalPos::ORIGIN,
1884                },
1885                None,
1886            ))?;
1887        });
1888        Ok(())
1889    }
1890
1891    fn step_inner(&mut self, _context: &()) -> Result<TransitionResult<CheckpointResult>> {
1892        match &self.state {
1893            CheckpointState::PrepareCheckpoint => {
1894                let passive = self
1895                    .connection
1896                    .experimental_mvcc_passive_checkpoint_enabled();
1897                if passive {
1898                    // The passive checkpoint acquires the blocking lock only after
1899                    // collection, so it needs an explicit single-orchestrator gate. The
1900                    // blocking (flag-off) path takes the lock up front and gets that
1901                    // invariant — plus Busy-on-contention — from the lock itself, so it
1902                    // must NOT use this gate, which would turn a contended explicit
1903                    // TRUNCATE into a silent no-op.
1904                    if self
1905                        .mvstore
1906                        .checkpoint_in_progress
1907                        .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1908                        .is_err()
1909                    {
1910                        // Another checkpoint is already running: no-op (no work, no resources).
1911                        self.state = CheckpointState::Finalize;
1912                        return Ok(TransitionResult::Done(CheckpointResult::default()));
1913                    }
1914                    self.owns_checkpoint_in_progress = true;
1915                }
1916
1917                if passive {
1918                    self.snapshot_ts = self.mvstore.checkpoint_snapshot_ts();
1919                    // Checkpoint state machines can be created before they are run.
1920                    // Resample after serializing so already-durable index deletes are not replayed.
1921                    self.refresh_checkpoint_bounds();
1922                    self.state = CheckpointState::BuildLocalSchemaView;
1923                } else {
1924                    self.state = CheckpointState::AcquireLock;
1925                }
1926                Ok(TransitionResult::Continue)
1927            }
1928            CheckpointState::AcquireLock => {
1929                inject_transition_yield!(self, CheckpointYieldPoint::BeforeAcquireLock);
1930
1931                tracing::debug!("Acquiring blocking checkpoint lock");
1932                if !self.lock_states.blocking_checkpoint_lock_held {
1933                    let locked = self.checkpoint_lock.write();
1934                    if !locked {
1935                        return Err(crate::LimboError::Busy);
1936                    }
1937                    self.lock_states.blocking_checkpoint_lock_held = true;
1938                }
1939
1940                // Sample the snapshot only after the stop-the-world lock: no concurrent
1941                // commits can land between snapshot_ts and collection on this path.
1942                self.snapshot_ts = self.mvstore.checkpoint_snapshot_ts();
1943                // Checkpoint state machines can be created before they are run.
1944                // Resample after serializing with other checkpoints so already-durable
1945                // index deletes are not replayed, and keep schema-derived index metadata
1946                // aligned with the refreshed durable boundary.
1947                self.refresh_checkpoint_bounds();
1948                self.refresh_schema_metadata();
1949                self.state = CheckpointState::CollectTableRows;
1950                Ok(TransitionResult::Continue)
1951            }
1952            CheckpointState::BuildLocalSchemaView => {
1953                if self.build_local_schema_sm.is_none() {
1954                    let began = !self
1955                        .pager
1956                        .wal
1957                        .as_ref()
1958                        .is_some_and(|wal| wal.holds_read_lock());
1959                    if began {
1960                        self.pager.begin_read_tx()?;
1961                    }
1962                    self.build_local_schema_began_read_tx = began;
1963                    let cursor = BTreeCursor::new_table(
1964                        self.pager.clone(),
1965                        SQLITE_SCHEMA_ROOT_PAGE,
1966                        SQLITE_SCHEMA_COLUMN_COUNT,
1967                    );
1968                    self.build_local_schema_sm =
1969                        Some(StateMachine::new(BuildLocalSchemaViewStateMachine::new(
1970                            cursor,
1971                            self.mvstore.clone(),
1972                            self.connection.clone(),
1973                            self.snapshot_ts,
1974                        )));
1975                }
1976                let sm = self
1977                    .build_local_schema_sm
1978                    .as_mut()
1979                    .expect("build_local_schema_sm just set");
1980                match sm.step(&())? {
1981                    IOResult::IO(io) => Ok(TransitionResult::Io(io)),
1982                    IOResult::Done(schema) => {
1983                        self.local_schema = Some(schema);
1984                        let local = self
1985                            .local_schema
1986                            .as_ref()
1987                            .expect("local_schema just set")
1988                            .clone();
1989                        // Key each index by the binding that owns its root page AT snapshot_ts —
1990                        // the same id collect_index_rows uses (mvstore.index_rows is keyed by the
1991                        // owning id). Resolving at the *current* owner (u64::MAX) instead would,
1992                        // under concurrent page reuse, key a present index under a different (reused)
1993                        // id, so WriteIndexRow would fail to find its Index struct and drop real
1994                        // entries ("row N missing from index"). filter_map: an index whose root has
1995                        // no binding covering the snapshot is not part of this snapshot.
1996                        self.index_id_to_index = local
1997                            .indexes
1998                            .values()
1999                            .flatten()
2000                            .filter_map(|index| {
2001                                self.mvstore
2002                                    .try_get_table_id_from_root_page_at(
2003                                        index.root_page,
2004                                        self.snapshot_ts,
2005                                    )
2006                                    .map(|id| (id, index.clone()))
2007                            })
2008                            .collect();
2009                        self.build_local_schema_sm = None;
2010                        if self.build_local_schema_began_read_tx {
2011                            self.pager.end_read_tx();
2012                            self.build_local_schema_began_read_tx = false;
2013                        }
2014                        self.state = CheckpointState::CollectTableRows;
2015                        Ok(TransitionResult::Continue)
2016                    }
2017                }
2018            }
2019            CheckpointState::CollectTableRows => {
2020                if let Some(io) = self.collect_table_rows()? {
2021                    return Ok(TransitionResult::Io(io));
2022                }
2023                tracing::debug!("Collected {} committed versions", self.write_set.len());
2024                self.state = CheckpointState::CollectIndexRows;
2025                inject_transition_yield!(self, CheckpointYieldPoint::AfterCollectTableRows);
2026                Ok(TransitionResult::Continue)
2027            }
2028            CheckpointState::CollectIndexRows => {
2029                if let Some(io) = self.collect_index_rows()? {
2030                    return Ok(TransitionResult::Io(io));
2031                }
2032                tracing::debug!("Collected {} index row changes", self.index_write_set.len());
2033
2034                let passive = self
2035                    .connection
2036                    .experimental_mvcc_passive_checkpoint_enabled();
2037                if passive {
2038                    inject_transition_yield!(self, CheckpointYieldPoint::BeforeAcquireLock);
2039                    // Passive path: collection AND the btree write phase run without the
2040                    // blocking lock. The only serialized point is the brief publish window in
2041                    // CommitPagerTxn.
2042                }
2043
2044                let durable_old = self.durable_txid_max_old.map(u64::from).unwrap_or_default();
2045                #[cfg(any(clt_turso_tests, debug_assertions))]
2046                {
2047                    let collected_max = self.max_collected_version_timestamp();
2048                    turso_assert!(
2049                        self.snapshot_ts >= collected_max,
2050                        "MVCC checkpoint collected version timestamp above snapshot",
2051                        { "collected_max": collected_max, "snapshot_ts": self.snapshot_ts }
2052                    );
2053                }
2054                self.durable_txid_max_new = durable_old.max(self.snapshot_ts);
2055                self.maybe_stage_mvcc_metadata_write()?;
2056
2057                self.mvstore.storage.on_checkpoint_start()?;
2058
2059                if self.write_set.is_empty() && self.index_write_set.is_empty() {
2060                    // Nothing to checkpoint, skip pager txn and go straight to WAL checkpoint.
2061                    self.state = CheckpointState::CheckpointWal;
2062                } else {
2063                    self.state = CheckpointState::BeginPagerTxn;
2064                }
2065                Ok(TransitionResult::Continue)
2066            }
2067            CheckpointState::BeginPagerTxn => {
2068                tracing::debug!("Beginning pager transaction");
2069                // Start a pager transaction to write committed versions to B-tree
2070                let read_tx_active = self
2071                    .pager
2072                    .wal
2073                    .as_ref()
2074                    .is_some_and(|wal| wal.holds_read_lock());
2075                if !read_tx_active {
2076                    self.pager.begin_read_tx()?;
2077                    self.lock_states.pager_read_tx = true;
2078                }
2079
2080                self.pager
2081                    .io
2082                    .block(|| self.pager.begin_write_tx(WalAutoActions::all_enabled()))?;
2083                if self.update_transaction_state {
2084                    self.connection.set_tx_state(TransactionState::Write {
2085                        schema_did_change: false,
2086                    }); // TODO: schema_did_change??
2087                }
2088                self.lock_states.pager_write_tx = true;
2089                self.state = CheckpointState::WriteRow {
2090                    write_set_index: 0,
2091                    requires_seek: true,
2092                };
2093                Ok(TransitionResult::Continue)
2094            }
2095
2096            CheckpointState::WriteRow {
2097                write_set_index,
2098                requires_seek,
2099            } => {
2100                let write_set_index = *write_set_index;
2101                let requires_seek = *requires_seek;
2102
2103                if !self.has_more_rows(write_set_index) {
2104                    // Done writing all table rows, now process index rows
2105                    if self.index_write_set.is_empty() {
2106                        // No index rows to write, compact sequence
2107                        // backing tables, then commit.
2108                        self.state = CheckpointState::CompactSequences;
2109                    } else {
2110                        // Start writing index rows
2111                        self.state = CheckpointState::WriteIndexRow {
2112                            index_write_set_index: 0,
2113                            requires_seek: true,
2114                        };
2115                    }
2116                    return Ok(TransitionResult::Continue);
2117                }
2118
2119                let (num_columns, table_id, special_write, drop_ts) = {
2120                    let (row_version, special_write) = self
2121                        .get_current_row_version(write_set_index)
2122                        .ok_or_else(|| {
2123                            LimboError::InternalError(
2124                                "row version not found in write set".to_string(),
2125                            )
2126                        })?;
2127                    tracing::trace!("checkpointing row {row_version:?} ");
2128                    // Commit ts of the tombstone driving a destroy, so a dropped checkpointed
2129                    // object can be retired into `retired_rootpages` for readers still at an
2130                    // older snapshot (see the BTreeDestroy/BTreeDestroyIndex arms below).
2131                    let drop_ts = match row_version.end() {
2132                        Some(TxTimestampOrID::Timestamp(ts)) => Some(ts),
2133                        _ => None,
2134                    };
2135                    (
2136                        row_version.row.column_count,
2137                        row_version.row.id.table_id,
2138                        *special_write,
2139                        drop_ts,
2140                    )
2141                };
2142                tracing::debug!(
2143                    "WriteRow: num_columns={num_columns}, table_id={table_id:?}, special_write={special_write:?}"
2144                );
2145
2146                // Handle CREATE TABLE / DROP TABLE / CREATE INDEX / DROP INDEX ops
2147                if let Some(special_write) = special_write {
2148                    match special_write {
2149                        SpecialWrite::BTreeCreate { table_id, .. } => {
2150                            let created_root_page: u32 = self.pager.io.block(|| {
2151                                self.pager.btree_create(&CreateBTreeFlags::new_table())
2152                            })?;
2153                            // STAGE the binding: the checkpoint must resolve table_id -> root while
2154                            // writing rows below, but the pages are not durable until
2155                            // CommitPagerTxn, so it stays physically invisible to readers
2156                            // (visible_from = u64::MAX) until the post-commit publish window.
2157                            // Undo-logged: reverted if the checkpoint fails before commit.
2158                            self.ckpt_rootmap_alloc(table_id, created_root_page as u64);
2159                            self.staged_roots.push(table_id);
2160                        }
2161                        SpecialWrite::BTreeDestroy {
2162                            table_id,
2163                            root_page,
2164                            num_columns,
2165                        } => {
2166                            let known_root_page = self
2167                                .mvstore
2168                                .current_root_page(&table_id)
2169                                .expect("Table ID does not have a root page");
2170                            turso_assert_eq!(
2171                                known_root_page,
2172                                root_page,
2173                                "checkpoint root page mismatch for BTreeDestroy",
2174                                { "known_root_page": known_root_page, "schema_root_page": root_page }
2175                            );
2176                            let cursor = if let Some(cursor) = self.cursors.get(&known_root_page) {
2177                                cursor.clone()
2178                            } else {
2179                                let cursor = BTreeCursor::new_table(
2180                                    self.pager.clone(),
2181                                    known_root_page as i64,
2182                                    num_columns,
2183                                );
2184                                let cursor = Arc::new(RwLock::new(cursor));
2185                                self.cursors.insert(root_page, cursor.clone());
2186                                cursor
2187                            };
2188                            self.pager.io.block(|| cursor.write().btree_destroy())?;
2189                            // Evict stale cursor.
2190                            self.cursors.remove(&root_page);
2191                            self.destroyed_tables.insert(table_id);
2192                            // Deferred destroy: retire the binding (set its `end` to the drop ts)
2193                            // but keep it, so a transaction still scanning this table at an older
2194                            // snapshot resolves the (read-mark-protected) root page. GC'd once
2195                            // `lwm` passes the drop. Defensively remove if the drop ts is unknown.
2196                            if let Some(drop_ts) = drop_ts {
2197                                self.ckpt_rootmap_retire(table_id, drop_ts);
2198                            } else {
2199                                self.ckpt_rootmap_remove(table_id);
2200                            }
2201                        }
2202                        SpecialWrite::BTreeCreateIndex { index_id, .. } => {
2203                            let created_root_page: u32 = self.pager.io.block(|| {
2204                                self.pager.btree_create(&CreateBTreeFlags::new_index())
2205                            })?;
2206                            // Staged (see BTreeCreate); published in the post-commit window.
2207                            // Undo-logged: reverted if the checkpoint fails before commit.
2208                            self.ckpt_rootmap_alloc(index_id, created_root_page as u64);
2209                            self.staged_roots.push(index_id);
2210                            // Index struct should already be stored in index_id_to_index from collect_committed_versions
2211                            turso_assert!(
2212                                self.index_id_to_index.contains_key(&index_id),
2213                                "checkpoint index struct missing before BTreeCreateIndex",
2214                                { "index_id": i64::from(index_id) }
2215                            );
2216                        }
2217                        SpecialWrite::BTreeDestroyIndex {
2218                            index_id,
2219                            root_page,
2220                            num_columns,
2221                        } => {
2222                            let known_root_page = self
2223                                .mvstore
2224                                .current_root_page(&index_id)
2225                                .expect("Index ID does not have a root page");
2226                            turso_assert_eq!(
2227                                known_root_page,
2228                                root_page,
2229                                "checkpoint root page mismatch for BTreeDestroyIndex",
2230                                { "known_root_page": known_root_page, "schema_root_page": root_page }
2231                            );
2232
2233                            let cursor = if let Some(cursor) = self.cursors.get(&known_root_page) {
2234                                cursor.clone()
2235                            } else if let Some(index) = self.index_id_to_index.get(&index_id) {
2236                                let cursor = BTreeCursor::new_index(
2237                                    self.pager.clone(),
2238                                    known_root_page as i64,
2239                                    index.as_ref(),
2240                                    num_columns,
2241                                )?;
2242                                let cursor = Arc::new(RwLock::new(cursor));
2243                                self.cursors.insert(root_page, cursor.clone());
2244                                cursor
2245                            } else {
2246                                // DROP INDEX destroy path: schema may no longer contain the index definition.
2247                                // We only need a cursor to destroy pages so num_columns is not important.
2248                                Arc::new(RwLock::new(BTreeCursor::new_table(
2249                                    self.pager.clone(),
2250                                    known_root_page as i64,
2251                                    num_columns,
2252                                )))
2253                            };
2254                            self.pager.io.block(|| cursor.write().btree_destroy())?;
2255                            // Evict stale cursor.
2256                            self.cursors.remove(&root_page);
2257                            self.destroyed_indexes.insert(index_id);
2258                            // Deferred destroy: retire the binding (set its `end`) but keep it so
2259                            // a transaction still scanning this index at an older snapshot resolves
2260                            // the (read-mark-protected) root page. GC'd once `lwm` passes the drop.
2261                            if let Some(drop_ts) = drop_ts {
2262                                self.ckpt_rootmap_retire(index_id, drop_ts);
2263                            } else {
2264                                self.ckpt_rootmap_remove(index_id);
2265                            }
2266                        }
2267                    }
2268                }
2269
2270                if self.destroyed_tables.contains(&table_id) {
2271                    // Don't write rows for tables that will be destroyed in this checkpoint.
2272                    self.state = CheckpointState::WriteRow {
2273                        write_set_index: write_set_index + 1,
2274                        requires_seek: true,
2275                    };
2276                    return Ok(TransitionResult::Continue);
2277                }
2278
2279                let is_delete = self
2280                    .get_current_row_version(write_set_index)
2281                    .is_some_and(|(v, _)| v.end().is_some());
2282                if is_delete && !self.table_exists_for_snapshot(table_id) {
2283                    self.state = CheckpointState::WriteRow {
2284                        write_set_index: write_set_index + 1,
2285                        requires_seek: true,
2286                    };
2287                    return Ok(TransitionResult::Continue);
2288                }
2289
2290                let root_page = self.resolve_checkpoint_root(table_id).unwrap_or_else(|| {
2291                    panic!(
2292                        "Table ID does not have a root page: {table_id}, row_version: {:?}",
2293                        self.get_current_row_version(write_set_index)
2294                            .expect("row version should exist")
2295                    )
2296                });
2297
2298                tracing::debug!("WriteRow: resolved root page: root_page={root_page}");
2299
2300                // If a table was created, it now has a real root page allocated for it, but the 'root_page' field in the sqlite_schema record is still the table id.
2301                // So we need to rewrite the row version to use the real root page.
2302                if let Some(SpecialWrite::BTreeCreate {
2303                    table_id,
2304                    sqlite_schema_rowid,
2305                }) = special_write
2306                {
2307                    let root_page = self
2308                        .resolve_checkpoint_root(table_id)
2309                        .expect("Table ID does not have a root page");
2310                    let row_version = {
2311                        let alloc = self.mvstore.allocator();
2312                        let (row_version, _) = self
2313                            .get_current_row_version_mut(write_set_index)
2314                            .ok_or_else(|| {
2315                                LimboError::InternalError(
2316                                    "row version not found in write set".to_string(),
2317                                )
2318                            })?;
2319                        let record = ImmutableRecordRef::from_bin_record(row_version.row.payload());
2320
2321                        let mut values = record.get_values_owned()?;
2322                        values[3] = Value::from_i64(root_page as i64);
2323                        let record = ImmutableRecord::from_values(&values, values.len())?;
2324                        // Btree creation has already happened by this point; an injected fault
2325                        // while publishing the sqlite_schema root page can leave retry state with
2326                        // a durable btree and a stale rootpage=0 schema row.
2327                        // TODO: make this rewrite resumable before re-enabling fault injection.
2328                        row_version.row.data = Some(crate::without_allocation_faults!(
2329                            crate::alloc::try_arc_slice_from_slice_in(record.get_payload(), alloc)?
2330                        ));
2331                        row_version.clone()
2332                    };
2333                    self.created_btrees
2334                        .insert(sqlite_schema_rowid, (table_id, row_version));
2335                } else if let Some(SpecialWrite::BTreeCreateIndex {
2336                    index_id,
2337                    sqlite_schema_rowid,
2338                }) = special_write
2339                {
2340                    // Same for index btrees.
2341                    let root_page = self
2342                        .resolve_checkpoint_root(index_id)
2343                        .expect("Index ID does not have a root page");
2344                    let row_version = {
2345                        let alloc = self.mvstore.allocator();
2346                        let (row_version, _) = self
2347                            .get_current_row_version_mut(write_set_index)
2348                            .ok_or_else(|| {
2349                                LimboError::InternalError(
2350                                    "row version not found in write set".to_string(),
2351                                )
2352                            })?;
2353                        let record = ImmutableRecordRef::from_bin_record(row_version.row.payload());
2354                        let mut values = record.get_values_owned()?;
2355                        values[3] = Value::from_i64(root_page as i64);
2356                        let record = ImmutableRecord::from_values(&values, values.len())?;
2357                        // Btree creation has already happened by this point; an injected fault
2358                        // while publishing the sqlite_schema root page can leave retry state with
2359                        // a durable btree and a stale rootpage=0 schema row.
2360                        // TODO: make this rewrite resumable before re-enabling fault injection.
2361                        row_version.row.data = Some(crate::without_allocation_faults!(
2362                            crate::alloc::try_arc_slice_from_slice_in(record.get_payload(), alloc)?
2363                        ));
2364                        row_version.clone()
2365                    };
2366
2367                    self.created_btrees
2368                        .insert(sqlite_schema_rowid, (index_id, row_version));
2369                }
2370
2371                // Get or create cursor for this table
2372                let cursor = if let Some(cursor) = self.cursors.get(&root_page) {
2373                    cursor.clone()
2374                } else {
2375                    let cursor =
2376                        BTreeCursor::new_table(self.pager.clone(), root_page as i64, num_columns);
2377                    let cursor = Arc::new(RwLock::new(cursor));
2378                    self.cursors.insert(root_page, cursor.clone());
2379                    cursor
2380                };
2381
2382                let (row_version, _) =
2383                    self.get_current_row_version(write_set_index)
2384                        .ok_or_else(|| {
2385                            LimboError::InternalError(
2386                                "row version not found in write set".to_string(),
2387                            )
2388                        })?;
2389
2390                // Check if this is an insert or delete
2391                if row_version.end().is_some() {
2392                    // This is a delete operation.
2393                    // Don't write the deletion record to the b-tree if the b-tree was just created; we can no-op in this case,
2394                    // since there is no existing row to delete.
2395                    if self
2396                        .created_btrees
2397                        .values()
2398                        .any(|(table_id, _)| *table_id == row_version.row.id.table_id)
2399                    {
2400                        self.state = CheckpointState::WriteRow {
2401                            write_set_index: write_set_index + 1,
2402                            requires_seek: true,
2403                        };
2404                        return Ok(TransitionResult::Continue);
2405                    }
2406                    let state_machine = self
2407                        .mvstore
2408                        .delete_row_from_pager(row_version.row.id.clone(), cursor)?;
2409                    self.delete_row_state_machine = Some(state_machine);
2410                    self.state = CheckpointState::DeleteRowStateMachine { write_set_index };
2411                } else {
2412                    // This is an insert/update operation
2413                    let state_machine =
2414                        self.mvstore
2415                            .write_row_to_pager(&row_version.row, cursor, requires_seek)?;
2416                    self.write_row_state_machine = Some(state_machine);
2417                    self.state = CheckpointState::WriteRowStateMachine { write_set_index };
2418                }
2419
2420                Ok(TransitionResult::Continue)
2421            }
2422
2423            CheckpointState::WriteRowStateMachine { write_set_index } => {
2424                let write_set_index = *write_set_index;
2425                let write_row_state_machine =
2426                    self.write_row_state_machine.as_mut().ok_or_else(|| {
2427                        LimboError::InternalError(
2428                            "write_row_state_machine not initialized".to_string(),
2429                        )
2430                    })?;
2431
2432                match write_row_state_machine.step(&())? {
2433                    IOResult::IO(io) => Ok(TransitionResult::Io(io)),
2434                    IOResult::Done(_) => {
2435                        let requires_seek = self.next_requires_seek_after_insert(write_set_index);
2436                        self.state = CheckpointState::WriteRow {
2437                            write_set_index: write_set_index + 1,
2438                            requires_seek,
2439                        };
2440                        Ok(TransitionResult::Continue)
2441                    }
2442                }
2443            }
2444
2445            CheckpointState::DeleteRowStateMachine { write_set_index } => {
2446                let write_set_index = *write_set_index;
2447                let delete_row_state_machine =
2448                    self.delete_row_state_machine.as_mut().ok_or_else(|| {
2449                        LimboError::InternalError(
2450                            "delete_row_state_machine not initialized".to_string(),
2451                        )
2452                    })?;
2453
2454                match delete_row_state_machine.step(&())? {
2455                    IOResult::IO(io) => Ok(TransitionResult::Io(io)),
2456                    IOResult::Done(_) => {
2457                        self.state = CheckpointState::WriteRow {
2458                            write_set_index: write_set_index + 1,
2459                            requires_seek: true,
2460                        };
2461                        Ok(TransitionResult::Continue)
2462                    }
2463                }
2464            }
2465
2466            CheckpointState::WriteIndexRow {
2467                index_write_set_index,
2468                requires_seek,
2469            } => {
2470                let index_write_set_index = *index_write_set_index;
2471                let requires_seek = *requires_seek;
2472
2473                if index_write_set_index >= self.index_write_set.len() {
2474                    // Done writing all index rows, compact sequence
2475                    // backing tables, then commit.
2476                    self.state = CheckpointState::CompactSequences;
2477                    return Ok(TransitionResult::Continue);
2478                }
2479
2480                let (index_id, row_version, is_delete) =
2481                    &self.index_write_set[index_write_set_index];
2482
2483                // Skip destroyed indexes
2484                if self.destroyed_indexes.contains(index_id) {
2485                    self.state = CheckpointState::WriteIndexRow {
2486                        index_write_set_index: index_write_set_index + 1,
2487                        requires_seek: true,
2488                    };
2489                    return Ok(TransitionResult::Continue);
2490                }
2491
2492                // The index is absent from the snapshot schema (index_id_to_index is built from
2493                // local_schema at snapshot_ts). That means the index does not exist at the
2494                // checkpoint snapshot — it was dropped — so its whole btree is (or will be)
2495                // destroyed wholesale. DROP does not tombstone each in-memory index-entry
2496                // version, so collect_index_rows still picks them up as live inserts/tombstones;
2497                // materializing them into the (possibly reused) root page would corrupt. Skip
2498                // every entry for a snapshot-absent index, mirroring the destroyed_indexes skip.
2499                let Some(index) = self.index_id_to_index.get(index_id) else {
2500                    self.state = CheckpointState::WriteIndexRow {
2501                        index_write_set_index: index_write_set_index + 1,
2502                        requires_seek: true,
2503                    };
2504                    return Ok(TransitionResult::Continue);
2505                };
2506
2507                if *is_delete && !self.table_exists_for_snapshot(*index_id) {
2508                    self.state = CheckpointState::WriteIndexRow {
2509                        index_write_set_index: index_write_set_index + 1,
2510                        requires_seek: true,
2511                    };
2512                    return Ok(TransitionResult::Continue);
2513                }
2514
2515                // Get root page for this index
2516                let root_page = self
2517                    .resolve_checkpoint_root(*index_id)
2518                    .unwrap_or_else(|| panic!("Index ID {index_id} does not have a root page"));
2519
2520                // Get or create cursor for this index
2521                let cursor = if let Some(cursor) = self.cursors.get(&root_page) {
2522                    cursor.clone()
2523                } else {
2524                    let cursor = BTreeCursor::new_index(
2525                        self.pager.clone(),
2526                        root_page as i64,
2527                        index.as_ref(),
2528                        index.columns.len(),
2529                    )?;
2530                    let cursor = Arc::new(RwLock::new(cursor));
2531                    self.cursors.insert(root_page, cursor.clone());
2532                    cursor
2533                };
2534
2535                // Check if this is an insert or delete
2536                if *is_delete {
2537                    // This is a delete operation. Don't write the deletion record to the b-tree if the b-tree was just created; we can no-op in this case,
2538                    // since there is no existing row to delete.
2539                    if self
2540                        .created_btrees
2541                        .values()
2542                        .any(|(table_id, _)| *table_id == row_version.row.id.table_id)
2543                    {
2544                        self.state = CheckpointState::WriteIndexRow {
2545                            index_write_set_index: index_write_set_index + 1,
2546                            requires_seek: true,
2547                        };
2548                        return Ok(TransitionResult::Continue);
2549                    }
2550                    let state_machine = self
2551                        .mvstore
2552                        .delete_row_from_pager(row_version.row.id.clone(), cursor)?;
2553                    self.delete_row_state_machine = Some(state_machine);
2554                    self.state = CheckpointState::DeleteIndexRowStateMachine {
2555                        index_write_set_index,
2556                    };
2557                } else {
2558                    // This is an insert/update operation
2559                    let state_machine =
2560                        self.mvstore
2561                            .write_row_to_pager(&row_version.row, cursor, requires_seek)?;
2562                    self.write_row_state_machine = Some(state_machine);
2563                    self.state = CheckpointState::WriteIndexRowStateMachine {
2564                        index_write_set_index,
2565                    };
2566                }
2567
2568                Ok(TransitionResult::Continue)
2569            }
2570
2571            CheckpointState::WriteIndexRowStateMachine {
2572                index_write_set_index,
2573            } => {
2574                let index_write_set_index = *index_write_set_index;
2575                let write_row_state_machine =
2576                    self.write_row_state_machine.as_mut().ok_or_else(|| {
2577                        LimboError::InternalError(
2578                            "write_row_state_machine not initialized".to_string(),
2579                        )
2580                    })?;
2581
2582                match write_row_state_machine.step(&())? {
2583                    IOResult::IO(io) => Ok(TransitionResult::Io(io)),
2584                    IOResult::Done(_) => {
2585                        self.state = CheckpointState::WriteIndexRow {
2586                            index_write_set_index: index_write_set_index + 1,
2587                            requires_seek: true,
2588                        };
2589                        Ok(TransitionResult::Continue)
2590                    }
2591                }
2592            }
2593
2594            CheckpointState::DeleteIndexRowStateMachine {
2595                index_write_set_index,
2596            } => {
2597                let index_write_set_index = *index_write_set_index;
2598                let delete_row_state_machine =
2599                    self.delete_row_state_machine.as_mut().ok_or_else(|| {
2600                        LimboError::InternalError(
2601                            "delete_row_state_machine not initialized".to_string(),
2602                        )
2603                    })?;
2604
2605                match delete_row_state_machine.step(&())? {
2606                    IOResult::IO(io) => Ok(TransitionResult::Io(io)),
2607                    IOResult::Done(_) => {
2608                        self.state = CheckpointState::WriteIndexRow {
2609                            index_write_set_index: index_write_set_index + 1,
2610                            requires_seek: true,
2611                        };
2612                        Ok(TransitionResult::Continue)
2613                    }
2614                }
2615            }
2616
2617            CheckpointState::CompactSequences => {
2618                if self.seq_compact.is_none() {
2619                    let pending = self.pending_sequence_compactions()?;
2620                    if pending.is_empty() {
2621                        self.state = CheckpointState::CommitPagerTxn;
2622                        return Ok(TransitionResult::Continue);
2623                    }
2624                    self.seq_compact = Some(SeqCompactDriver {
2625                        pending,
2626                        current_idx: 0,
2627                        cursor: None,
2628                        phase: SeqCompactPhase::SeekWatermark,
2629                        watermark_key: None,
2630                        pending_delete_rowid: None,
2631                        pager: self.pager.clone(),
2632                        mvstore: self.mvstore.clone(),
2633                        passive: matches!(self.mode, CheckpointMode::Passive { .. }),
2634                        compacted: crate::alloc::vec![],
2635                    });
2636                }
2637                let driver = self.seq_compact.as_mut().expect("seq_compact set above");
2638                match driver.step()? {
2639                    IOResult::IO(io) => Ok(TransitionResult::Io(io)),
2640                    IOResult::Done(()) => {
2641                        // Passive recorded its deletes instead of applying them; carry them to the
2642                        // clock-ordered publish window. Blocking applied them directly (empty).
2643                        let driver = self.seq_compact.take().expect("seq_compact set above");
2644                        self.pending_seq_deletes = driver.compacted;
2645                        self.state = CheckpointState::CommitPagerTxn;
2646                        Ok(TransitionResult::Continue)
2647                    }
2648                }
2649            }
2650            CheckpointState::CommitPagerTxn => {
2651                let passive = matches!(self.mode, CheckpointMode::Passive { .. });
2652                let passive_auto_publish_retry = passive && !self.update_transaction_state;
2653                // Passive: btree commit and publish run off the RW lock (drain bit only).
2654                let lock_before_commit = !passive;
2655                if lock_before_commit && !self.lock_states.blocking_checkpoint_lock_held {
2656                    if !self.checkpoint_lock.write() {
2657                        return Err(crate::LimboError::Busy);
2658                    }
2659                    self.lock_states.blocking_checkpoint_lock_held = true;
2660                }
2661                if !self.pager_commit_done {
2662                    if !self.header_staged_for_commit {
2663                        let mut checkpoint_header =
2664                            *self.mvstore.global_header.read().as_ref().ok_or_else(|| {
2665                                LimboError::InternalError(
2666                                    "global_header not initialized during checkpoint".to_string(),
2667                                )
2668                            })?;
2669                        checkpoint_header.schema_cookie =
2670                            self.connection.db.schema.lock().schema_version.into();
2671                        let staged_header = self.pager.io.block(|| {
2672                            self.pager.with_header_mut(|header| {
2673                                // Keep pager-maintained fields (for example database_size/change_counter)
2674                                // intact, and apply only MVCC header mutations that are authored via
2675                                // SetCookie/PRAGMA paths.
2676                                header.schema_cookie = checkpoint_header.schema_cookie;
2677                                header.user_version = checkpoint_header.user_version;
2678                                header.application_id = checkpoint_header.application_id;
2679                                header.vacuum_mode_largest_root_page =
2680                                    checkpoint_header.vacuum_mode_largest_root_page;
2681                                header.incremental_vacuum_enabled =
2682                                    checkpoint_header.incremental_vacuum_enabled;
2683                                *header
2684                            })
2685                        })?;
2686                        self.staged_checkpoint_header = Some(staged_header);
2687                        self.header_staged_for_commit = true;
2688                    }
2689                    // On commit_tx failure the `?` rolls back the pager txn; durable_txid_max and
2690                    // the log offset stay put, so a retry re-stages from the previous boundary.
2691                    tracing::debug!("Committing pager transaction");
2692                    match self
2693                        .pager
2694                        .commit_tx(&self.connection, self.update_transaction_state)?
2695                    {
2696                        IOResult::Done(_) => {
2697                            self.pager_commit_done = true;
2698                        }
2699                        IOResult::IO(io) => return Ok(TransitionResult::Io(io)),
2700                    }
2701                }
2702                if passive {
2703                    if !self.mvstore.try_begin_passive_publish_window() {
2704                        if passive_auto_publish_retry {
2705                            tracing::debug!(
2706                                "passive checkpoint publish contended; yielding for retry"
2707                            );
2708                            return Ok(TransitionResult::Io(IOCompletions::Single(
2709                                Completion::new_yield(),
2710                            )));
2711                        }
2712                        return Err(crate::LimboError::Busy);
2713                    }
2714                    let materialized_at = WalPos::from_pair(self.pager.wal_pos());
2715                    self.apply_passive_publish_window_ordered(materialized_at)?;
2716                } else if !self.lock_states.blocking_checkpoint_lock_held {
2717                    if !self.checkpoint_lock.write() {
2718                        return Err(crate::LimboError::Busy);
2719                    }
2720                    self.lock_states.blocking_checkpoint_lock_held = true;
2721                    let materialized_at = WalPos::from_pair(self.pager.wal_pos());
2722                    // Blocking holds the lock: SeqCompact applied its deletes+purge directly under
2723                    // the contract, so there are no recorded passive deletes to publish (None).
2724                    self.apply_checkpoint_publish_window(materialized_at, None)?;
2725                } else {
2726                    let materialized_at = WalPos::from_pair(self.pager.wal_pos());
2727                    self.apply_checkpoint_publish_window(materialized_at, None)?;
2728                }
2729                inject_transition_failure!(
2730                    self,
2731                    CheckpointYieldPoint::AfterDurableBoundaryAdvanced
2732                );
2733                inject_transition_yield!(self, CheckpointYieldPoint::AfterDurableBoundaryAdvanced);
2734                Ok(TransitionResult::Continue)
2735            }
2736
2737            CheckpointState::TruncateLogicalLog => {
2738                tracing::debug!("Truncating logical log file");
2739                let c = self.truncate_logical_log()?;
2740                self.state = CheckpointState::FsyncLogicalLog;
2741                // if Completion Completed without errors we can continue
2742                if c.succeeded() {
2743                    if self.mode.should_restart_log() {
2744                        turso_assert!(
2745                            self.mvstore.storage.logical_log_offset() == 0,
2746                            "TRUNCATE checkpoint must reset logical log offset to 0"
2747                        );
2748                        turso_assert!(
2749                            self.mvstore
2750                                .get_logical_log_file()
2751                                .size()
2752                                .expect("logical log file size should be readable after truncate")
2753                                == 0,
2754                            "TRUNCATE checkpoint must zero the logical log file"
2755                        );
2756                    }
2757                    Ok(TransitionResult::Continue)
2758                } else {
2759                    Ok(TransitionResult::Io(IOCompletions::Single(c)))
2760                }
2761            }
2762
2763            CheckpointState::FsyncLogicalLog => {
2764                // Skip fsync when synchronous mode is off
2765                if self.sync_mode == SyncMode::Off {
2766                    tracing::debug!("Skipping fsync of logical log file (synchronous=off)");
2767                    self.state = CheckpointState::TruncateWal;
2768                    return Ok(TransitionResult::Continue);
2769                }
2770                tracing::debug!("Fsyncing logical log file");
2771                let c = self.fsync_logical_log()?;
2772                self.state = CheckpointState::TruncateWal;
2773                // if Completion Completed without errors we can continue
2774                if c.succeeded() {
2775                    Ok(TransitionResult::Continue)
2776                } else {
2777                    Ok(TransitionResult::Io(IOCompletions::Single(c)))
2778                }
2779            }
2780
2781            CheckpointState::CheckpointWal => {
2782                tracing::debug!("Performing TRUNCATE checkpoint on WAL");
2783                match self.checkpoint_wal()? {
2784                    IOResult::Done(result) => {
2785                        self.checkpoint_result = Some(result);
2786                        self.state = CheckpointState::SyncDbFile;
2787                        Ok(TransitionResult::Continue)
2788                    }
2789                    IOResult::IO(io) => Ok(TransitionResult::Io(io)),
2790                }
2791            }
2792
2793            CheckpointState::SyncDbFile => {
2794                // Fsync database file before truncating WAL.
2795                // This ensures durability: if we crash after WAL truncation but before DB fsync,
2796                // the checkpointed data would be lost.
2797                if self.sync_mode == SyncMode::Off {
2798                    tracing::debug!("Skipping fsync of database file (synchronous=off)");
2799                    self.state = CheckpointState::TruncateLogicalLog;
2800                    return Ok(TransitionResult::Continue);
2801                }
2802
2803                let checkpoint_result = self
2804                    .checkpoint_result
2805                    .as_mut()
2806                    .expect("checkpoint_result should be set");
2807
2808                // Only sync if we actually backfilled any frames
2809                if checkpoint_result.wal_checkpoint_backfilled == 0 {
2810                    self.state = CheckpointState::TruncateLogicalLog;
2811                    return Ok(TransitionResult::Continue);
2812                }
2813
2814                // Check if we already sent the sync
2815                if checkpoint_result.db_sync_sent {
2816                    self.state = CheckpointState::TruncateLogicalLog;
2817                    return Ok(TransitionResult::Continue);
2818                }
2819
2820                tracing::debug!("Fsyncing database file before WAL truncation");
2821                let c = self
2822                    .pager
2823                    .db_file
2824                    .sync(Completion::new_sync(|_| {}), self.pager.get_sync_type())?;
2825                checkpoint_result.db_sync_sent = true;
2826                Ok(TransitionResult::Io(IOCompletions::Single(c)))
2827            }
2828
2829            CheckpointState::TruncateWal => {
2830                if self.mode.should_restart_log() {
2831                    // Truncate/Restart renumbers WAL frames — only safe stop-the-world. Acquire
2832                    // the lock if the blocking path didn't already. Passive never restarts the
2833                    // log, so it skips this branch and stays lock-free.
2834                    if !self.lock_states.blocking_checkpoint_lock_held {
2835                        if !self.checkpoint_lock.write() {
2836                            return Err(crate::LimboError::Busy);
2837                        }
2838                        self.lock_states.blocking_checkpoint_lock_held = true;
2839                    }
2840                    // Zero the WAL file explicitly: MVCC calls wal.checkpoint() directly,
2841                    // bypassing the pager's TruncateWalFile. Resumable on IO until Done.
2842                    let Some(wal) = &self.pager.wal else {
2843                        panic!("No WAL to truncate");
2844                    };
2845                    let checkpoint_result = self
2846                        .checkpoint_result
2847                        .as_mut()
2848                        .expect("checkpoint_result should be set");
2849                    if let IOResult::IO(io) =
2850                        wal.truncate_wal(checkpoint_result, self.pager.get_sync_type())?
2851                    {
2852                        return Ok(TransitionResult::Io(io));
2853                    }
2854                }
2855                // Passive leaves the WAL non-empty; the logical log is already truncated, so
2856                // recovery sees NoLog + committed WAL — the normal passive steady state.
2857                // Scope to THIS checkpoint's own staged work: a no-op (nothing-to-write) pass
2858                // staged nothing, and a real publish drains both. A global schema scan would
2859                // false-trip on owned-negative leftovers from an earlier checkpoint that mapped
2860                // a root positive but couldn't patch the (not-yet-adopted) live schema — benign,
2861                // since cursors resolve negative->positive via table_id_to_rootpage.
2862                turso_assert!(
2863                    self.created_btrees.is_empty() && self.staged_roots.is_empty(),
2864                    "checkpoint finalized with un-published staged schema roots"
2865                );
2866                self.mvstore
2867                    .durable_txid_max
2868                    .store(self.durable_txid_max_new, Ordering::SeqCst);
2869                // Publish the WAL backfill boundary as the passive checkpoint GC floor: a version
2870                // materialized at or below it is durable in the DB file, hence reachable by
2871                // every snapshot. Un-backfilled ones stay retained for low-frame readers.
2872                let (seq, _) = self.pager.wal_pos();
2873                let backfill =
2874                    WalPos::from_pair((seq, self.pager.wal_backfill_frame().unwrap_or(0)));
2875                *self.mvstore.backfill_floor.write() = backfill;
2876                let lwm = self.mvstore.compute_lwm();
2877                // Reclaim retired root-page bindings no transaction can still see (end <= lwm).
2878                self.mvstore.gc_rootpage_entries(lwm);
2879                self.state = CheckpointState::GcTableRows { next_index: 0, lwm };
2880                Ok(TransitionResult::Continue)
2881            }
2882
2883            CheckpointState::GcTableRows { .. } => {
2884                if let Some(io) = self.gc_checkpointed_table_versions() {
2885                    return Ok(TransitionResult::Io(io));
2886                }
2887                let CheckpointState::GcTableRows { lwm, .. } = self.state else {
2888                    unreachable!("state is GcTableRows here");
2889                };
2890                self.state = CheckpointState::GcIndexRows { next_index: 0, lwm };
2891                Ok(TransitionResult::Continue)
2892            }
2893
2894            CheckpointState::GcIndexRows { .. } => {
2895                if let Some(io) = self.gc_checkpointed_index_versions() {
2896                    return Ok(TransitionResult::Io(io));
2897                }
2898                self.state = CheckpointState::Finalize;
2899                Ok(TransitionResult::Continue)
2900            }
2901
2902            CheckpointState::Finalize => {
2903                if self.lock_states.blocking_checkpoint_lock_held {
2904                    // Blocking lock held: the slot-removing GC variant is safe (no writer races
2905                    // the empty-slot removal). Then release.
2906                    tracing::debug!("Releasing blocking checkpoint lock");
2907                    self.mvstore.drop_unused_row_versions_and_slots();
2908                    self.checkpoint_lock.unlock();
2909                    self.lock_states.blocking_checkpoint_lock_held = false;
2910                } else {
2911                    // Passive: GC chains in place only; empty slots reclaimed on next insert.
2912                    self.mvstore.drop_unused_row_versions();
2913                }
2914                // Release the single-orchestrator gate so the next checkpoint can run.
2915                if self.owns_checkpoint_in_progress {
2916                    self.mvstore
2917                        .checkpoint_in_progress
2918                        .store(false, Ordering::Release);
2919                    self.owns_checkpoint_in_progress = false;
2920                }
2921                self.finalize(&())?;
2922                Ok(TransitionResult::Done(
2923                    self.checkpoint_result.take().ok_or_else(|| {
2924                        LimboError::InternalError("checkpoint_result not set".to_string())
2925                    })?,
2926                ))
2927            }
2928        }
2929    }
2930}
2931
2932impl<Clock: LogicalClock, A: ConcurrentAllocator> StateTransition
2933    for CheckpointStateMachine<Clock, A>
2934{
2935    type Context = ();
2936    type SMResult = CheckpointResult;
2937
2938    fn step(&mut self, _context: &Self::Context) -> Result<TransitionResult<Self::SMResult>> {
2939        let res = self.step_inner(&());
2940        match res {
2941            Err(ref err) => {
2942                tracing::debug!("Error in checkpoint state machine: {err}");
2943                // `cleanup_after_external_io_error` already emits the paired
2944                // `on_checkpoint_end(Err(..))`, so don't call it here too — doing both
2945                // double-fires the hook for a single failure.
2946                self.cleanup_after_external_io_error(err.clone())?;
2947                res
2948            }
2949            Ok(TransitionResult::Done(ref result)) => {
2950                self.mvstore.storage.on_checkpoint_end(Ok(result))?;
2951                res
2952            }
2953            Ok(result) => Ok(result),
2954        }
2955    }
2956
2957    fn finalize(&mut self, _context: &Self::Context) -> Result<()> {
2958        Ok(())
2959    }
2960
2961    fn is_finalized(&self) -> bool {
2962        matches!(self.state, CheckpointState::Finalize)
2963    }
2964}
2965
2966/// Re-entrant state machine that builds a snapshot-consistent `Schema` for the
2967/// checkpoint: scans the on-disk `sqlite_schema` B-tree (root page 1) and overlays the
2968/// MVCC delta visible at `snapshot_ts`, matching exactly the rows the checkpoint
2969/// collects. The live schema would include post-snapshot objects and mis-map index ids.
2970enum BuildLocalSchemaViewState {
2971    Rewind,
2972    ReadRowid,
2973    ReadRecord { rowid: i64 },
2974    Advance,
2975    MergeMvccDelta,
2976    Done,
2977}
2978
2979pub struct BuildLocalSchemaViewStateMachine<
2980    Clock: LogicalClock,
2981    A: ConcurrentAllocator = TursoAllocator,
2982> {
2983    cursor: BTreeCursor,
2984    mvstore: Arc<MvStore<Clock, A>>,
2985    connection: Arc<Connection>,
2986    snapshot_ts: u64,
2987    state: BuildLocalSchemaViewState,
2988    rows: HashMap<i64, ImmutableRecord>,
2989    finalized: bool,
2990}
2991
2992impl<Clock: LogicalClock, A: ConcurrentAllocator> BuildLocalSchemaViewStateMachine<Clock, A> {
2993    fn new(
2994        cursor: BTreeCursor,
2995        mvstore: Arc<MvStore<Clock, A>>,
2996        connection: Arc<Connection>,
2997        snapshot_ts: u64,
2998    ) -> Self {
2999        Self {
3000            cursor,
3001            mvstore,
3002            connection,
3003            snapshot_ts,
3004            state: BuildLocalSchemaViewState::Rewind,
3005            rows: HashMap::default(),
3006            finalized: false,
3007        }
3008    }
3009
3010    /// Overlay the in-memory MVCC sqlite_schema versions onto the rows read from
3011    /// the B-tree, keeping only the version live at `snapshot_ts` and removing
3012    /// rows whose live-at-snapshot state is a delete.
3013    fn merge_mvcc_delta(&mut self) {
3014        let snapshot_ts = self.snapshot_ts;
3015        for entry in self.mvstore.rows.iter() {
3016            let key = entry.key();
3017            if key.table_id != SQLITE_SCHEMA_MVCC_TABLE_ID {
3018                continue;
3019            }
3020            let rowid = key.row_id.to_int_or_panic();
3021            let versions = entry.value().read();
3022            let present = versions.iter().find(|version| {
3023                let begin_committed = matches!(
3024                    version.begin(),
3025                    Some(TxTimestampOrID::Timestamp(b)) if b <= snapshot_ts
3026                );
3027                if !begin_committed {
3028                    return false;
3029                }
3030                match version.end() {
3031                    None => true,
3032                    Some(TxTimestampOrID::Timestamp(e)) => e > snapshot_ts,
3033                    Some(TxTimestampOrID::TxID(_)) => true,
3034                }
3035            });
3036            match present {
3037                Some(version) => {
3038                    let data = version
3039                        .row
3040                        .data
3041                        .as_ref()
3042                        .expect("present schema version must carry row data at snapshot_ts");
3043                    self.rows
3044                        .insert(rowid, ImmutableRecord::from_bin_record(data.to_vec()));
3045                }
3046                None => {
3047                    let existed_and_gone = versions.iter().any(|version| {
3048                        matches!(
3049                            version.begin(),
3050                            Some(TxTimestampOrID::Timestamp(b)) if b <= snapshot_ts
3051                        ) || matches!(
3052                            version.end(),
3053                            Some(TxTimestampOrID::Timestamp(e)) if e <= snapshot_ts
3054                        )
3055                    });
3056                    if existed_and_gone {
3057                        self.rows.remove(&rowid);
3058                    }
3059                }
3060            }
3061        }
3062    }
3063}
3064
3065impl<Clock: LogicalClock, A: ConcurrentAllocator> StateTransition
3066    for BuildLocalSchemaViewStateMachine<Clock, A>
3067{
3068    type Context = ();
3069    type SMResult = Arc<Schema>;
3070
3071    fn step(&mut self, _context: &()) -> Result<TransitionResult<Self::SMResult>> {
3072        match self.state {
3073            BuildLocalSchemaViewState::Rewind => match self.cursor.rewind()? {
3074                IOResult::IO(io) => Ok(TransitionResult::Io(io)),
3075                IOResult::Done(()) => {
3076                    self.state = BuildLocalSchemaViewState::ReadRowid;
3077                    Ok(TransitionResult::Continue)
3078                }
3079            },
3080            BuildLocalSchemaViewState::ReadRowid => {
3081                if !self.cursor.has_record() {
3082                    self.state = BuildLocalSchemaViewState::MergeMvccDelta;
3083                    return Ok(TransitionResult::Continue);
3084                }
3085                match self.cursor.rowid()? {
3086                    IOResult::IO(io) => Ok(TransitionResult::Io(io)),
3087                    IOResult::Done(Some(rowid)) => {
3088                        self.state = BuildLocalSchemaViewState::ReadRecord { rowid };
3089                        Ok(TransitionResult::Continue)
3090                    }
3091                    IOResult::Done(None) => {
3092                        self.state = BuildLocalSchemaViewState::Advance;
3093                        Ok(TransitionResult::Continue)
3094                    }
3095                }
3096            }
3097            BuildLocalSchemaViewState::ReadRecord { rowid } => {
3098                let record = match self.cursor.record()? {
3099                    IOResult::IO(io) => return Ok(TransitionResult::Io(io)),
3100                    IOResult::Done(Some(record)) => Some(record.clone()),
3101                    IOResult::Done(None) => None,
3102                };
3103                if let Some(record) = record {
3104                    self.rows.insert(rowid, record);
3105                }
3106                self.state = BuildLocalSchemaViewState::Advance;
3107                Ok(TransitionResult::Continue)
3108            }
3109            BuildLocalSchemaViewState::Advance => match self.cursor.next()? {
3110                IOResult::IO(io) => Ok(TransitionResult::Io(io)),
3111                IOResult::Done(()) => {
3112                    self.state = BuildLocalSchemaViewState::ReadRowid;
3113                    Ok(TransitionResult::Continue)
3114                }
3115            },
3116            BuildLocalSchemaViewState::MergeMvccDelta => {
3117                self.merge_mvcc_delta();
3118                self.state = BuildLocalSchemaViewState::Done;
3119                Ok(TransitionResult::Continue)
3120            }
3121            BuildLocalSchemaViewState::Done => {
3122                self.finalized = true;
3123                let schema =
3124                    self.mvstore
3125                        .build_schema_from_rows(&self.connection, &self.rows, &[])?;
3126                Ok(TransitionResult::Done(schema))
3127            }
3128        }
3129    }
3130
3131    fn finalize(&mut self, _context: &()) -> Result<()> {
3132        self.finalized = true;
3133        Ok(())
3134    }
3135
3136    fn is_finalized(&self) -> bool {
3137        self.finalized
3138    }
3139}
3140
3141#[cfg(clt_turso_tests)]
3142mod tests {
3143    use super::*;
3144    use crate::alloc::vec;
3145    use crate::mvcc::database::tests::MvccTestDbNoConn;
3146    use crate::mvcc::database::SortableIndexKey;
3147    use crate::translate::collate::CollationSeq;
3148    use crate::types::{IndexInfo, KeyInfo};
3149    use turso_parser::ast::SortOrder;
3150
3151    fn sqlite_schema_row_version(
3152        rowid: i64,
3153        entry_type: &'static str,
3154        name: &'static str,
3155        table_name: &'static str,
3156        root_page: i64,
3157        begin: Option<u64>,
3158        end: Option<u64>,
3159    ) -> RowVersion {
3160        let record = ImmutableRecord::from_values(
3161            &[
3162                Value::build_text(entry_type),
3163                Value::build_text(name),
3164                Value::build_text(table_name),
3165                Value::from_i64(root_page),
3166                Value::build_text(format!("sql:{entry_type}:{name}:{root_page}")),
3167            ],
3168            5,
3169        )
3170        .unwrap();
3171        RowVersion {
3172            id: 1,
3173            begin: crate::mvcc::database::PackedTs::pack(begin.map(TxTimestampOrID::Timestamp)),
3174            end: crate::mvcc::database::PackedTs::pack(end.map(TxTimestampOrID::Timestamp)),
3175            row: Row::new_table_row(
3176                RowID::new(SQLITE_SCHEMA_MVCC_TABLE_ID, RowKey::Int(rowid)),
3177                record.as_blob(),
3178                5,
3179            )
3180            .unwrap(),
3181            btree_resident: false,
3182            materialized_at: crate::mvcc::database::WalPos::ORIGIN,
3183        }
3184    }
3185
3186    #[test]
3187    fn sqlite_schema_identity_treats_index_sql_rewrite_as_same_object() {
3188        let old = sqlite_schema_row_version(3, "index", "idx_t_a", "t", 7, Some(1), Some(2));
3189        let new = sqlite_schema_row_version(3, "index", "idx_t_a", "t", 7, Some(2), None);
3190
3191        assert_eq!(
3192            sqlite_schema_btree_identity(&old),
3193            Some(SqliteSchemaBtreeIdentity {
3194                kind: SqliteSchemaBtreeKind::Index,
3195                root_page: 7,
3196            })
3197        );
3198        assert!(sqlite_schema_versions_refer_to_btree(&old, &new));
3199        assert!(!is_schema_metadata_only_rewrite(&old, Some(&new)));
3200    }
3201
3202    #[test]
3203    fn sqlite_schema_identity_treats_table_sql_rewrite_as_same_object() {
3204        let old = sqlite_schema_row_version(2, "table", "t", "t", 5, Some(1), Some(2));
3205        let new = sqlite_schema_row_version(2, "table", "t", "t", 5, Some(2), None);
3206
3207        assert!(sqlite_schema_versions_refer_to_btree(&old, &new));
3208        assert!(!is_schema_metadata_only_rewrite(&old, Some(&new)));
3209    }
3210
3211    #[test]
3212    fn sqlite_schema_identity_detects_drop_recreate_as_different_objects() {
3213        let dropped = sqlite_schema_row_version(3, "index", "idx_t_v", "t", -4, Some(1), Some(2));
3214        let recreated = sqlite_schema_row_version(3, "index", "idx_t_v", "t", -5, Some(2), None);
3215
3216        assert!(!sqlite_schema_versions_refer_to_btree(&dropped, &recreated));
3217        assert!(is_schema_metadata_only_rewrite(&dropped, Some(&recreated)));
3218    }
3219
3220    #[test]
3221    fn sqlite_schema_identity_detects_drop_without_successor() {
3222        let dropped = sqlite_schema_row_version(3, "index", "idx_t_v", "t", 11, Some(1), Some(2));
3223
3224        assert!(is_schema_metadata_only_rewrite(&dropped, None));
3225    }
3226
3227    #[test]
3228    fn sqlite_schema_identity_ignores_non_btree_schema_entries() {
3229        let trigger = sqlite_schema_row_version(9, "trigger", "trg_t", "t", 0, Some(1), Some(2));
3230        let rewritten_trigger =
3231            sqlite_schema_row_version(9, "trigger", "trg_t", "t", 0, Some(2), None);
3232
3233        assert_eq!(sqlite_schema_btree_identity(&trigger), None);
3234        assert!(!sqlite_schema_versions_refer_to_btree(
3235            &trigger,
3236            &rewritten_trigger
3237        ));
3238        assert!(!is_schema_metadata_only_rewrite(
3239            &trigger,
3240            Some(&rewritten_trigger)
3241        ));
3242    }
3243
3244    #[test]
3245    fn sqlite_schema_identity_ignores_payloadless_tombstones() {
3246        let tombstone = RowVersion {
3247            id: 1,
3248            begin: crate::mvcc::database::PackedTs::pack(None),
3249            end: crate::mvcc::database::PackedTs::pack(Some(TxTimestampOrID::Timestamp(2))),
3250            row: Row::new_table_row(
3251                RowID::new(SQLITE_SCHEMA_MVCC_TABLE_ID, RowKey::Int(9)),
3252                &[],
3253                0,
3254            )
3255            .unwrap(),
3256            btree_resident: false,
3257            materialized_at: crate::mvcc::database::WalPos::ORIGIN,
3258        };
3259
3260        assert_eq!(sqlite_schema_btree_identity(&tombstone), None);
3261        assert!(!is_schema_metadata_only_rewrite(&tombstone, None));
3262    }
3263
3264    fn index_row_version(
3265        index_id: MVTableId,
3266        key_text: &str,
3267        rowid: i64,
3268        version_id: u64,
3269        begin: Option<u64>,
3270        end: Option<u64>,
3271        btree_resident: bool,
3272    ) -> (Arc<SortableIndexKey>, RowVersion) {
3273        let index_info = Arc::new(
3274            IndexInfo::new(
3275                vec![
3276                    KeyInfo {
3277                        sort_order: SortOrder::Asc,
3278                        collation: CollationSeq::Binary,
3279                        nulls_order: None,
3280                    },
3281                    KeyInfo {
3282                        sort_order: SortOrder::Asc,
3283                        collation: CollationSeq::Binary,
3284                        nulls_order: None,
3285                    },
3286                ],
3287                true,
3288                2,
3289                true,
3290            )
3291            .unwrap(),
3292        );
3293        let key_record = ImmutableRecord::from_values(
3294            &[
3295                Value::Text(crate::types::Text::new(key_text.to_string())),
3296                Value::from_i64(rowid),
3297            ],
3298            2,
3299        )
3300        .unwrap();
3301        let sortable_key = SortableIndexKey::new_from_record(key_record, index_info);
3302        let key_arc = Arc::new(sortable_key.clone());
3303        let row = Row::new_index_row(
3304            RowID::new(index_id, RowKey::Record(Arc::new(sortable_key))),
3305            2,
3306        );
3307        let row_version = RowVersion {
3308            id: version_id,
3309            begin: crate::mvcc::database::PackedTs::pack(begin.map(TxTimestampOrID::Timestamp)),
3310            end: crate::mvcc::database::PackedTs::pack(end.map(TxTimestampOrID::Timestamp)),
3311            row,
3312            btree_resident,
3313            materialized_at: crate::mvcc::database::WalPos::ORIGIN,
3314        };
3315        (key_arc, row_version)
3316    }
3317
3318    #[test]
3319    fn checkpoint_retry_does_not_replay_checkpointed_btree_resident_delete() {
3320        let db = MvccTestDbNoConn::new();
3321        let conn = db.connect();
3322        let mvstore = db.get_mvcc_store();
3323        let pager = conn.pager.load().clone();
3324        let mut checkpoint = CheckpointStateMachine::new(
3325            pager,
3326            mvstore.clone(),
3327            conn.clone(),
3328            true,
3329            conn.get_sync_mode(),
3330            crate::MAIN_DB_ID,
3331            CheckpointMode::Truncate {
3332                upper_bound_inclusive: None,
3333            },
3334        );
3335        checkpoint.durable_txid_max_old = std::num::NonZeroU64::new(10);
3336        checkpoint.durable_txid_max_new = 10;
3337
3338        let index_id = MVTableId::from(-42);
3339        let (garbage_key, garbage_version) =
3340            index_row_version(index_id, "blue_river_906", 75, 1, None, None, true);
3341        let (_, tombstone_version) =
3342            index_row_version(index_id, "blue_river_906", 75, 2, None, Some(10), true);
3343
3344        mvstore
3345            .insert_index_version(index_id, garbage_key, garbage_version)
3346            .unwrap();
3347        let entry = mvstore
3348            .index_rows
3349            .get(&index_id)
3350            .expect("index entry should exist after first insert");
3351        let tombstone_key = entry
3352            .value()
3353            .front()
3354            .expect("key bucket should exist after first insert")
3355            .key()
3356            .clone();
3357        mvstore
3358            .insert_index_version(index_id, tombstone_key, tombstone_version)
3359            .unwrap();
3360
3361        while checkpoint.collect_index_rows().unwrap().is_some() {}
3362
3363        assert!(
3364            checkpoint.index_write_set.is_empty(),
3365            "a retry checkpoint must not replay a delete whose btree_resident tombstone was already made durable"
3366        );
3367    }
3368
3369    fn committed_table_row_version(table_id: MVTableId, rowid: i64) -> RowVersion {
3370        table_row_version(table_id, rowid, 1, Some(5), None, false)
3371    }
3372
3373    fn table_row_version(
3374        table_id: MVTableId,
3375        rowid: i64,
3376        version_id: u64,
3377        begin: Option<u64>,
3378        end: Option<u64>,
3379        btree_resident: bool,
3380    ) -> RowVersion {
3381        let record = ImmutableRecord::from_values(&[Value::from_i64(rowid)], 1).unwrap();
3382        RowVersion {
3383            id: version_id,
3384            begin: crate::mvcc::database::PackedTs::pack(begin.map(TxTimestampOrID::Timestamp)),
3385            end: crate::mvcc::database::PackedTs::pack(end.map(TxTimestampOrID::Timestamp)),
3386            row: Row::new_table_row(
3387                RowID::new(table_id, RowKey::Int(rowid)),
3388                record.as_blob(),
3389                1,
3390            )
3391            .unwrap(),
3392            btree_resident,
3393            materialized_at: crate::mvcc::database::WalPos::ORIGIN,
3394        }
3395    }
3396
3397    fn checkpoint_for_collect_tests(
3398    ) -> CheckpointStateMachine<crate::mvcc::clock::MvccClock, crate::alloc::DynAllocator> {
3399        let db = MvccTestDbNoConn::new();
3400        let conn = db.connect();
3401        let mvstore = db.get_mvcc_store();
3402        let pager = conn.pager.load().clone();
3403        let mut checkpoint = CheckpointStateMachine::new(
3404            pager,
3405            mvstore,
3406            conn.clone(),
3407            true,
3408            conn.get_sync_mode(),
3409            crate::MAIN_DB_ID,
3410            CheckpointMode::Truncate {
3411                upper_bound_inclusive: None,
3412            },
3413        );
3414        checkpoint.durable_txid_max_old = NonZeroU64::new(2);
3415        checkpoint
3416    }
3417
3418    #[test]
3419    fn checkpoint_collection_uses_btree_marker_for_existence_but_writes_surviving_replacement() {
3420        let checkpoint = checkpoint_for_collect_tests();
3421        let table_id = MVTableId::from(-2);
3422        let btree_tombstone = table_row_version(table_id, 1, 1, None, Some(5), true);
3423        let replacement = table_row_version(table_id, 1, 2, Some(5), None, false);
3424
3425        let checkpointable =
3426            checkpoint.maybe_get_checkpointable_versions(&[btree_tombstone, replacement], table_id);
3427
3428        assert_eq!(checkpointable.len(), 1);
3429        assert_eq!(checkpointable[0].id, 2);
3430        assert_eq!(checkpointable[0].end(), None);
3431    }
3432
3433    #[test]
3434    fn checkpoint_collection_uses_btree_marker_for_later_delete_of_replacement() {
3435        let checkpoint = checkpoint_for_collect_tests();
3436        let table_id = MVTableId::from(-2);
3437        let btree_tombstone = table_row_version(table_id, 1, 1, None, Some(5), true);
3438        let deleted_replacement = table_row_version(table_id, 1, 2, Some(5), Some(6), false);
3439
3440        let checkpointable = checkpoint
3441            .maybe_get_checkpointable_versions(&[btree_tombstone, deleted_replacement], table_id);
3442
3443        assert_eq!(checkpointable.len(), 1);
3444        assert_eq!(checkpointable[0].id, 2);
3445        assert_eq!(checkpointable[0].end(), Some(TxTimestampOrID::Timestamp(6)));
3446    }
3447
3448    #[test]
3449    fn checkpoint_collection_skips_delete_of_never_checkpointed_replacement_without_btree_marker() {
3450        let checkpoint = checkpoint_for_collect_tests();
3451        let table_id = MVTableId::from(-2);
3452        let deleted_replacement = table_row_version(table_id, 1, 2, Some(5), Some(6), false);
3453
3454        let checkpointable =
3455            checkpoint.maybe_get_checkpointable_versions(&[deleted_replacement], table_id);
3456
3457        assert!(checkpointable.is_empty());
3458    }
3459
3460    #[test]
3461    fn collect_table_rows_preempts_on_large_scan() {
3462        let db = MvccTestDbNoConn::new();
3463        let conn = db.connect();
3464        let mvstore = db.get_mvcc_store();
3465        let pager = conn.pager.load().clone();
3466        let mut checkpoint = CheckpointStateMachine::new(
3467            pager,
3468            mvstore.clone(),
3469            conn.clone(),
3470            true,
3471            conn.get_sync_mode(),
3472            crate::MAIN_DB_ID,
3473            CheckpointMode::Truncate {
3474                upper_bound_inclusive: None,
3475            },
3476        );
3477
3478        // More than one chunk worth of committed rows so collection must preempt.
3479        let table_id = MVTableId::from(-2);
3480        let row_count = COLLECT_PREEMPTION_THRESHOLD + 10;
3481        for i in 0..row_count as i64 {
3482            let version = committed_table_row_version(table_id, i);
3483            let mut versions =
3484                <crate::mvcc::database::RowVersionChain<crate::alloc::DynAllocator> as crate::alloc::TursoVecInExt<
3485                    RowVersion,
3486                    crate::alloc::DynAllocator,
3487                >>::new_in(crate::alloc::DynAllocator::default());
3488            versions.push(version);
3489            mvstore.rows.insert(
3490                RowID::new(table_id, RowKey::Int(i)),
3491                Arc::new(RwLock::new(versions)),
3492            );
3493        }
3494
3495        // The first chunk fills up before the scan finishes, so it must yield.
3496        let first = checkpoint.collect_table_rows().unwrap();
3497        assert!(
3498            first.is_some_and(|io| io.is_explicit_yield()),
3499            "scanning more than COLLECT_PREEMPTION_THRESHOLD rows must preempt with an explicit yield"
3500        );
3501
3502        // Resume from the cursor until the scan finishes; every row must still
3503        // be collected exactly once across the chunks.
3504        while checkpoint.collect_table_rows().unwrap().is_some() {}
3505        assert_eq!(checkpoint.write_set.len(), row_count);
3506    }
3507
3508    #[test]
3509    fn collect_index_rows_preempts_on_large_scan() {
3510        let db = MvccTestDbNoConn::new();
3511        let conn = db.connect();
3512        let mvstore = db.get_mvcc_store();
3513        let pager = conn.pager.load().clone();
3514        let mut checkpoint = CheckpointStateMachine::new(
3515            pager,
3516            mvstore.clone(),
3517            conn.clone(),
3518            true,
3519            conn.get_sync_mode(),
3520            crate::MAIN_DB_ID,
3521            CheckpointMode::Truncate {
3522                upper_bound_inclusive: None,
3523            },
3524        );
3525
3526        let index_id = MVTableId::from(-7);
3527        let row_count = COLLECT_PREEMPTION_THRESHOLD + 10;
3528        for i in 0..row_count as i64 {
3529            let (key, version) = index_row_version(index_id, "k", i, 1, Some(5), None, false);
3530            mvstore
3531                .insert_index_version(index_id, key, version)
3532                .unwrap();
3533        }
3534
3535        let first = checkpoint.collect_index_rows().unwrap();
3536        assert!(
3537            first.is_some_and(|io| io.is_explicit_yield()),
3538            "scanning more than COLLECT_PREEMPTION_THRESHOLD index rows must preempt with an explicit yield"
3539        );
3540
3541        while checkpoint.collect_index_rows().unwrap().is_some() {}
3542        assert_eq!(checkpoint.index_write_set.len(), row_count);
3543    }
3544
3545    #[test]
3546    fn gc_checkpointed_table_versions_preempts_on_large_scan() {
3547        let db = MvccTestDbNoConn::new();
3548        let conn = db.connect();
3549        let mvstore = db.get_mvcc_store();
3550        let pager = conn.pager.load().clone();
3551        let mut checkpoint = CheckpointStateMachine::new(
3552            pager,
3553            mvstore.clone(),
3554            conn.clone(),
3555            true,
3556            conn.get_sync_mode(),
3557            crate::MAIN_DB_ID,
3558            CheckpointMode::Truncate {
3559                upper_bound_inclusive: None,
3560            },
3561        );
3562        checkpoint.lock_states.blocking_checkpoint_lock_held = true;
3563        checkpoint.durable_txid_max_new = 5;
3564
3565        let table_id = MVTableId::from(-2);
3566        let row_count = COLLECT_PREEMPTION_THRESHOLD + 10;
3567        for i in 0..row_count as i64 {
3568            let version = committed_table_row_version(table_id, i);
3569            let row_id = RowID::new(table_id, RowKey::Int(i));
3570            let mut versions =
3571                <crate::mvcc::database::RowVersionChain<crate::alloc::DynAllocator> as crate::alloc::TursoVecInExt<
3572                    RowVersion,
3573                    crate::alloc::DynAllocator,
3574                >>::new_in(crate::alloc::DynAllocator::default());
3575            versions.push(version.clone());
3576            mvstore.rows.insert(row_id, Arc::new(RwLock::new(versions)));
3577            checkpoint.write_set.push((version, None));
3578        }
3579        checkpoint.state = CheckpointState::GcTableRows {
3580            next_index: 0,
3581            lwm: u64::MAX,
3582        };
3583
3584        let first = checkpoint.gc_checkpointed_table_versions();
3585        assert!(
3586            first.is_some_and(|io| io.is_explicit_yield()),
3587            "GCing more than COLLECT_PREEMPTION_THRESHOLD rows must preempt with an explicit yield"
3588        );
3589
3590        while checkpoint.gc_checkpointed_table_versions().is_some() {}
3591        let remaining = mvstore
3592            .rows
3593            .iter()
3594            .filter(|entry| entry.key().table_id == table_id)
3595            .count();
3596        assert_eq!(remaining, 0);
3597    }
3598
3599    #[test]
3600    fn gc_checkpointed_index_versions_preempts_on_large_scan() {
3601        let db = MvccTestDbNoConn::new();
3602        let conn = db.connect();
3603        let mvstore = db.get_mvcc_store();
3604        let pager = conn.pager.load().clone();
3605        let mut checkpoint = CheckpointStateMachine::new(
3606            pager,
3607            mvstore.clone(),
3608            conn.clone(),
3609            true,
3610            conn.get_sync_mode(),
3611            crate::MAIN_DB_ID,
3612            CheckpointMode::Truncate {
3613                upper_bound_inclusive: None,
3614            },
3615        );
3616        checkpoint.lock_states.blocking_checkpoint_lock_held = true;
3617        checkpoint.durable_txid_max_new = 5;
3618
3619        let index_id = MVTableId::from(-7);
3620        let row_count = COLLECT_PREEMPTION_THRESHOLD + 10;
3621        for i in 0..row_count as i64 {
3622            let (key, version) = index_row_version(index_id, "k", i, 1, Some(5), None, false);
3623            mvstore
3624                .insert_index_version(index_id, key, version.clone())
3625                .unwrap();
3626            checkpoint.index_write_set.push((index_id, version, false));
3627        }
3628        checkpoint.state = CheckpointState::GcIndexRows {
3629            next_index: 0,
3630            lwm: u64::MAX,
3631        };
3632
3633        let first = checkpoint.gc_checkpointed_index_versions();
3634        assert!(
3635            first.is_some_and(|io| io.is_explicit_yield()),
3636            "GCing more than COLLECT_PREEMPTION_THRESHOLD index rows must preempt with an explicit yield"
3637        );
3638
3639        while checkpoint.gc_checkpointed_index_versions().is_some() {}
3640        let remaining = mvstore
3641            .index_rows
3642            .get(&index_id)
3643            .map_or(0, |entry| entry.value().len());
3644        assert_eq!(remaining, 0);
3645    }
3646}