Skip to main content

clt_database/mvcc/database/
mod.rs

1use crate::alloc::{
2    ConcurrentAllocator, TryReserveError, TursoAllocator, TursoTryWithCapacityExt, TursoVecInExt,
3    ALLOC_ERR_MSG,
4};
5use crate::mvcc::clock::LogicalClock;
6use crate::mvcc::cursor::{static_iterator_hack, MvccIterator};
7#[cfg(any(clt_turso_tests, injected_yields))]
8use crate::mvcc::yield_hooks::{ProvidesYieldContext, YieldContext, YieldPointMarker};
9use crate::mvcc::yield_points::{inject_transition_failure, inject_transition_yield};
10use crate::schema::{Schema, Sequence, Table};
11use crate::skiplist::comparator::BasicComparator;
12use crate::skiplist::map::Entry;
13use crate::skiplist::SkipMap;
14use crate::state_machine::StateMachine;
15use crate::state_machine::StateTransition;
16use crate::state_machine::TransitionResult;
17use crate::storage::btree::BTreeCursor;
18use crate::storage::btree::BTreeKey;
19use crate::storage::btree::CursorTrait;
20use crate::storage::btree::CursorValidState;
21use crate::storage::pager::SavepointResult;
22use crate::storage::sqlite3_ondisk::DatabaseHeader;
23use crate::storage::wal::{CheckpointMode, CheckpointResult, TursoRwLock};
24use crate::sync::atomic::{AtomicBool, AtomicI64};
25use crate::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
26use crate::sync::Arc;
27use crate::sync::{Mutex, RwLock};
28use crate::translate::plan::IterationDirection;
29use crate::types::compare_immutable;
30use crate::types::IOCompletions;
31use crate::types::IOResult;
32use crate::types::ImmutableRecord;
33use crate::types::ImmutableRecordRef;
34use crate::types::IndexInfo;
35use crate::types::SeekResult;
36use crate::Completion;
37use crate::File;
38use crate::IOExt;
39use crate::LimboError;
40use crate::PageSize;
41use crate::Result;
42#[cfg(clt_turso_feature = "conn_raw_api")]
43use crate::Value;
44use crate::ValueRef;
45use crate::{io::FileSyncType, io_yield_one, return_if_io};
46use crate::{
47    turso_assert, turso_assert_eq, turso_assert_less_than, turso_assert_reachable, Numeric,
48};
49use crate::{Connection, Pager, SyncMode};
50use rustc_hash::FxHashMap as HashMap;
51use rustc_hash::FxHashSet as HashSet;
52use std::collections::{BTreeSet, HashMap as StdHashMap};
53use std::fmt::Debug;
54use std::marker::PhantomData;
55use std::ops::Bound;
56#[cfg(any(clt_turso_tests, injected_yields))]
57use strum::EnumCount;
58use tracing::instrument;
59use tracing::Level;
60
61pub mod checkpoint_state_machine;
62pub use checkpoint_state_machine::{
63    sqlite_schema_btree_identity, CheckpointState, CheckpointStateMachine,
64};
65
66#[cfg(clt_turso_feature = "conn_raw_api")]
67use super::persistent_storage::logical_log::{
68    encode_delete_portable_extension, parse_ops_from_plaintext, LOG_RECORD_PREFIX_SIZE,
69};
70use super::persistent_storage::logical_log::{
71    HeaderReadResult, IndexOpKind, ParsedOp, StreamingLogicalLogReader, StreamingResult,
72    LOG_HDR_SIZE,
73};
74#[cfg(clt_turso_feature = "conn_raw_api")]
75use super::portable_logical::{
76    is_portable_logical_name, is_portable_schema_row, is_portable_table_schema_row,
77    portable_schema_row_from_record, PortableLogicalBuilder, PortableObjectMapEntry,
78};
79
80#[cfg(clt_turso_tests)]
81pub mod hermitage_tests;
82#[cfg(clt_turso_tests)]
83pub mod tests;
84
85/// Sentinel value for `MvStore::exclusive_tx` indicating no exclusive transaction is active.
86const NO_EXCLUSIVE_TX: u64 = 0;
87
88/// Convert a sequence backing-table row into the exclusive upper bound used by
89/// sync scans. This is intentionally tailored to ascending non-CYCLE sequences,
90/// which is the shape used by AUTOINCREMENT CDC ids.
91pub(crate) fn first_unsafe_sequence_watermark(seq: &Sequence, value: i64, is_called: bool) -> i64 {
92    if !is_called {
93        return value;
94    }
95    if seq.increment_by > 0 && !seq.cycle {
96        value.checked_add(seq.increment_by).unwrap_or(value)
97    } else {
98        value
99    }
100}
101
102#[cfg(not(any(clt_turso_tests, injected_yields)))]
103struct YieldContext;
104
105/// A table ID for MVCC.
106/// MVCC table IDs are always negative. Their corresponding rootpage entry in sqlite_schema
107/// is the same negative value if the table has not been checkpointed yet. Otherwise, the root page
108/// will be positive and corresponds to the actual physical page.
109#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
110#[repr(transparent)]
111pub struct MVTableId(i64);
112
113/// The versions of a single row.
114///
115/// This associated type keeps `RowVersionChain<A>` allocator-shaped in MVCC
116/// code without scattering `cfg(nightly)` branches. Stable Rust cannot define
117/// a `Vec<T, A>` type alias that ignores `A`, so the stable implementation
118/// maps every allocator to the allocator-free `Vec<RowVersion>`.
119pub trait RowVersionAllocator: ConcurrentAllocator {
120    type RowVersionChain: std::fmt::Debug;
121}
122
123#[cfg(not(nightly))]
124impl<A: ConcurrentAllocator> RowVersionAllocator for A {
125    type RowVersionChain = crate::alloc::Vec<RowVersion>;
126}
127
128#[cfg(nightly)]
129impl<A: ConcurrentAllocator> RowVersionAllocator for A {
130    type RowVersionChain = crate::alloc::Vec<RowVersion, A>;
131}
132
133pub type RowVersionChain<A = TursoAllocator> = <A as RowVersionAllocator>::RowVersionChain;
134pub type RowVersions<A = TursoAllocator> = Arc<RwLock<RowVersionChain<A>>>;
135type TableRowEntry<'a, A = TursoAllocator> = Entry<'a, RowID, RowVersions<A>, BasicComparator, A>;
136type IndexRowEntry<'a, A = TursoAllocator> =
137    Entry<'a, Arc<SortableIndexKey>, RowVersions<A>, BasicComparator, A>;
138type IndexRowsEntry<'a, A = TursoAllocator> =
139    Entry<'a, MVTableId, IndexRowsMap<A>, BasicComparator, A>;
140type TableRowIterator<'a, A = TursoAllocator> =
141    Box<dyn Iterator<Item = TableRowEntry<'a, A>> + Send + Sync + 'a>;
142type IndexRowIterator<'a, A = TursoAllocator> =
143    Box<dyn Iterator<Item = IndexRowEntry<'a, A>> + Send + Sync + 'a>;
144
145/// Per-index map of sortable keys to their version chains, stored as the
146/// values of [`MvStore::index_rows`].
147pub type IndexRowsMap<A = TursoAllocator> =
148    SkipMap<Arc<SortableIndexKey>, RowVersions<A>, BasicComparator, A>;
149
150impl MVTableId {
151    pub fn new(value: i64) -> Self {
152        turso_assert_less_than!(value, 0, "MVCC table IDs are always negative");
153        Self(value)
154    }
155}
156
157impl From<i64> for MVTableId {
158    fn from(value: i64) -> Self {
159        turso_assert_less_than!(value, 0, "MVCC table IDs are always negative");
160        Self(value)
161    }
162}
163
164impl From<MVTableId> for i64 {
165    fn from(value: MVTableId) -> Self {
166        value.0
167    }
168}
169
170impl std::fmt::Display for MVTableId {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        write!(f, "MVTableId({})", self.0)
173    }
174}
175
176/// Wrapper for index keys that implements collation-aware, ASC/DESC-aware ordering.
177#[derive(Debug, Clone)]
178pub struct SortableIndexKey {
179    /// The key as bytes.
180    pub key: ImmutableRecord,
181    /// Index metadata containing sort orders and collations
182    pub metadata: Arc<IndexInfo>,
183}
184
185impl SortableIndexKey {
186    pub fn new_from_bytes(key_bytes: Vec<u8>, metadata: Arc<IndexInfo>) -> Self {
187        Self {
188            key: ImmutableRecord::from_bin_record(key_bytes),
189            metadata,
190        }
191    }
192
193    pub fn new_from_record(key: ImmutableRecord, metadata: Arc<IndexInfo>) -> Self {
194        Self { key, metadata }
195    }
196
197    pub fn new_from_values(values: Vec<ValueRef>, metadata: Arc<IndexInfo>) -> Result<Self> {
198        let len = values.len();
199        Ok(Self {
200            key: ImmutableRecord::from_values(values, len)?,
201            metadata,
202        })
203    }
204
205    fn compare(&self, other: &Self) -> Result<std::cmp::Ordering> {
206        // We sometimes need to compare a shorter key to a longer one,
207        // for example when seeking with an index key that is a prefix of the full key.
208        let num_cols = self.metadata.num_cols.min(other.metadata.num_cols);
209
210        let mut lhs = self.key.iter()?;
211        let mut rhs = other.key.iter()?;
212
213        for i in 0..num_cols {
214            let lhs_value = lhs.next().expect("we already checked length")?;
215            let rhs_value = rhs.next().expect("we already checked length")?;
216
217            let cmp = compare_immutable(
218                std::iter::once(&lhs_value),
219                std::iter::once(&rhs_value),
220                &self.metadata.key_info[i..i + 1],
221            );
222
223            if cmp != std::cmp::Ordering::Equal {
224                return Ok(cmp);
225            }
226        }
227
228        Ok(std::cmp::Ordering::Equal)
229    }
230
231    /// Check if the index key contains any NULL values (excluding the rowid column).
232    /// In SQLite, NULLs don't violate UNIQUE constraints, so we skip conflict checks for NULL keys.
233    pub fn contains_null(&self, num_indexed_cols: usize) -> Result<bool> {
234        let mut iter = self.key.iter()?;
235        // Only check the indexed columns, not the rowid at the end
236        for _ in 0..num_indexed_cols {
237            if let Some(value) = iter.next() {
238                if matches!(value?, crate::types::ValueRef::Null) {
239                    return Ok(true);
240                }
241            }
242        }
243        Ok(false)
244    }
245
246    /// Check if the first `num_cols` columns of this key match another key.
247    /// Used for UNIQUE index conflict detection where we need to compare only
248    /// the indexed columns, not the rowid suffix.
249    pub fn matches_prefix(&self, other: &Self, num_cols: usize) -> Result<bool> {
250        let mut lhs = self.key.iter()?;
251        let mut rhs = other.key.iter()?;
252
253        for i in 0..num_cols {
254            let lhs_value = match lhs.next() {
255                Some(v) => v?,
256                None => return Ok(false),
257            };
258            let rhs_value = match rhs.next() {
259                Some(v) => v?,
260                None => return Ok(false),
261            };
262
263            let cmp = compare_immutable(
264                std::iter::once(&lhs_value),
265                std::iter::once(&rhs_value),
266                &self.metadata.key_info[i..i + 1],
267            );
268
269            if cmp != std::cmp::Ordering::Equal {
270                return Ok(false);
271            }
272        }
273
274        Ok(true)
275    }
276}
277
278impl PartialEq for SortableIndexKey {
279    fn eq(&self, other: &Self) -> bool {
280        if self.key == other.key {
281            return true;
282        }
283
284        self.compare(other)
285            .map(|ord| ord == std::cmp::Ordering::Equal)
286            .unwrap_or(false)
287    }
288}
289
290impl Eq for SortableIndexKey {}
291
292impl PartialOrd for SortableIndexKey {
293    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
294        Some(self.cmp(other))
295    }
296}
297
298impl Ord for SortableIndexKey {
299    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
300        self.compare(other).expect("Failed to compare IndexKeys")
301    }
302}
303
304#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
305pub enum RowKey {
306    Int(i64),
307    Record(Arc<SortableIndexKey>),
308}
309
310impl RowKey {
311    pub fn to_int_or_panic(&self) -> i64 {
312        match self {
313            RowKey::Int(row_id) => *row_id,
314            _ => panic!("RowKey is not an integer"),
315        }
316    }
317
318    pub fn is_int_key(&self) -> bool {
319        matches!(self, RowKey::Int(_))
320    }
321}
322
323impl std::fmt::Display for RowKey {
324    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325        match self {
326            RowKey::Int(row_id) => write!(f, "{row_id}"),
327            RowKey::Record(record) => write!(f, "{record:?}"),
328        }
329    }
330}
331
332#[derive(Clone, Debug, PartialEq, Eq)]
333pub struct RowID {
334    /// The table ID. Analogous to table's root page number.
335    pub table_id: MVTableId,
336    pub row_id: RowKey,
337}
338
339impl RowID {
340    pub fn new(table_id: MVTableId, row_id: RowKey) -> Self {
341        Self { table_id, row_id }
342    }
343}
344
345#[derive(Clone, Debug, PartialEq, PartialOrd)]
346
347pub struct Row {
348    pub id: RowID,
349    /// Data is None for index rows because the key holds all the data.
350    pub data: Option<crate::alloc::ArcSlice<u8>>,
351    pub column_count: usize,
352}
353
354impl Row {
355    pub fn new_table_row(
356        id: RowID,
357        data: &[u8],
358        column_count: usize,
359    ) -> Result<Self, TryReserveError> {
360        Self::new_table_row_in(id, data, column_count, TursoAllocator)
361    }
362
363    pub fn new_table_row_in<A: ConcurrentAllocator>(
364        id: RowID,
365        data: &[u8],
366        column_count: usize,
367        alloc: A,
368    ) -> Result<Self, TryReserveError> {
369        Ok(Self {
370            id,
371            data: Some(crate::alloc::try_arc_slice_from_slice_in(data, alloc)?),
372            column_count,
373        })
374    }
375
376    pub fn new_index_row(id: RowID, column_count: usize) -> Self {
377        Self {
378            id,
379            data: None,
380            column_count,
381        }
382    }
383
384    pub fn is_index_row(&self) -> bool {
385        self.data.is_none()
386    }
387
388    pub fn payload(&self) -> &[u8] {
389        match self.id.row_id {
390            RowKey::Int(_) => self.data.as_deref().expect("table rows should have data"),
391            RowKey::Record(ref sortable_key) => sortable_key.key.as_blob(),
392        }
393    }
394}
395
396/// Packed representation of `Option<TxTimestampOrID>` in a single `u64`,
397/// halving the size of the `begin`/`end` fields (16 bytes each → 8).
398///
399/// Layout (top two bits are the tag):
400/// * `0`                  → `None`
401/// * `(1 << 62) | value`  → `Some(Timestamp(value))`
402/// * `(1 << 63) | value`  → `Some(TxID(value))`
403///
404/// `value` occupies the low 62 bits. Timestamps and transaction IDs are
405/// monotonic counters that start near zero, so 62 bits (~4.6e18) is never
406/// exhausted; `pack` asserts this invariant. The two distinct tag bits (rather
407/// than a zero sentinel) are required because `Timestamp(0)` is a real value —
408/// the logical clock hands out timestamp 0 to the first transaction.
409#[derive(Clone, Copy, PartialEq, Eq)]
410pub(crate) struct PackedTs(u64);
411
412impl PackedTs {
413    const TIMESTAMP_TAG: u64 = 1 << 62;
414    const TXID_TAG: u64 = 1 << 63;
415    const VALUE_MASK: u64 = (1 << 62) - 1;
416    const NONE: PackedTs = PackedTs(0);
417
418    #[inline]
419    pub(crate) fn pack(value: Option<TxTimestampOrID>) -> Self {
420        match value {
421            None => Self::NONE,
422            Some(TxTimestampOrID::Timestamp(ts)) => {
423                turso_assert!(ts <= Self::VALUE_MASK, "timestamp exceeds 62-bit range");
424                PackedTs(Self::TIMESTAMP_TAG | ts)
425            }
426            Some(TxTimestampOrID::TxID(id)) => {
427                turso_assert!(id <= Self::VALUE_MASK, "tx id exceeds 62-bit range");
428                PackedTs(Self::TXID_TAG | id)
429            }
430        }
431    }
432
433    #[inline]
434    fn unpack(self) -> Option<TxTimestampOrID> {
435        if self.0 == 0 {
436            None
437        } else if self.0 & Self::TXID_TAG != 0 {
438            Some(TxTimestampOrID::TxID(self.0 & Self::VALUE_MASK))
439        } else {
440            Some(TxTimestampOrID::Timestamp(self.0 & Self::VALUE_MASK))
441        }
442    }
443}
444
445impl std::fmt::Debug for PackedTs {
446    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
447        std::fmt::Debug::fmt(&self.unpack(), f)
448    }
449}
450
451/// A row version.
452#[derive(Clone, Debug, PartialEq)]
453pub struct RowVersion {
454    /// Unique identifier for this version within the MvStore.
455    /// Used for savepoint tracking to identify specific versions to rollback.
456    pub id: u64,
457    /// `begin`/`end` timestamps are bit-packed. Read them through the
458    /// [`RowVersion::begin`]/[`RowVersion::end`] accessors and write them with
459    /// [`RowVersion::set_begin`]/[`RowVersion::set_end`]; the raw `PackedTs`
460    /// fields are `pub(crate)` only so they can be set in struct literals (via
461    /// `PackedTs::pack`).
462    pub(crate) begin: PackedTs,
463    pub(crate) end: PackedTs,
464    pub row: Row,
465    /// Indicates this version was created for a row that existed in B-tree before
466    /// MVCC was enabled (e.g., after switching from WAL to MVCC journal mode).
467    /// This flag helps the checkpoint logic determine if a delete should be
468    /// checkpointed to the B-tree file.
469    pub btree_resident: bool,
470    /// The WAL position at which this version's *current* (begin, end) state was last
471    /// materialized to the B-tree by a checkpoint. [`WalPos::ORIGIN`] means "not yet in the
472    /// B-tree" — either never checkpointed, or its state changed (e.g. a delete set `end`) and
473    /// the new state is not materialized yet. The version-store GC (`gc_version_chain` Rules 2/3)
474    /// may only reclaim a version once its state is materialized (`!= ORIGIN`) AND every reader's
475    /// read mark has reached that position — otherwise a reader pinned below it reads the stale
476    /// B-tree and the version it needed is gone. Set by the checkpoint at materialization
477    /// ([`MvStore::stamp_materialized`]); reset to ORIGIN when a delete supersedes the row.
478    pub(crate) materialized_at: WalPos,
479}
480
481#[derive(Debug)]
482pub enum RowVersionState {
483    LiveVersion,
484    NotFound,
485    Deleted,
486}
487pub type TxID = u64;
488
489/// A log record contains all durable effects of a committed transaction,
490/// pre-serialized into a frame buffer that the logical-log flush path
491/// finalizes (backfills the TX header, appends the CRC trailer, optionally
492/// chunk-encrypts the payload) and writes to disk.
493#[derive(Clone, Debug)]
494pub struct LogRecord {
495    pub(crate) tx_timestamp: TxID,
496    /// Frame buffer that grows in place into the on-disk representation.
497    /// The first `LOG_HDR_SIZE + TX_HEADER_SIZE` bytes are pre-reserved
498    /// (zeros) so that op-entry appends land at the correct on-disk
499    /// offset; the flush path backfills the framing prefix and appends
500    /// the trailer.
501    pub buf: Vec<u8>,
502    /// Number of op entries appended to `buf`. Includes any header op.
503    pub op_count: u32,
504    /// True once a `DatabaseHeader` op has been appended. At most one
505    /// header op is allowed per transaction.
506    pub has_header: bool,
507    /// Portable logical-change metadata stored alongside the MVCC recovery log.
508    ///
509    /// Recovery ignores this field. Raw-log consumers use it to resolve the
510    /// recovery ops' MVCC table ids and read transaction-level metadata.
511    #[cfg(clt_turso_feature = "conn_raw_api")]
512    pub portable_changes: Vec<u8>,
513    /// True when the committing connection requested portable logical-change
514    /// frames, even if this transaction has no client-visible metadata.
515    #[cfg(clt_turso_feature = "conn_raw_api")]
516    pub portable_changes_enabled: bool,
517    /// True when a frame must carry a portable transaction wrapper even if
518    /// the wrapper metadata itself is empty.
519    #[cfg(clt_turso_feature = "conn_raw_api")]
520    pub portable_changes_required: bool,
521}
522
523impl LogRecord {
524    pub(crate) fn new(tx_timestamp: TxID) -> Self {
525        Self {
526            tx_timestamp,
527            // Pre-reserve the framing prefix at the front of buf:
528            //   [LOG_HDR slot (56B) | TX_HEADER slot (24B) | <ops here>]
529            // The log-header slot is only filled on the very first write to
530            // a log file; otherwise it stays zero and the flush path wraps
531            // the buf with `Buffer::new_with_start(..., LOG_HDR_SIZE)` so
532            // those 56 bytes never reach disk.
533            buf: vec![0u8; crate::mvcc::persistent_storage::logical_log::LOG_RECORD_PREFIX_SIZE],
534            op_count: 0,
535            has_header: false,
536            #[cfg(clt_turso_feature = "conn_raw_api")]
537            portable_changes: Vec::new(),
538            #[cfg(clt_turso_feature = "conn_raw_api")]
539            portable_changes_enabled: false,
540            #[cfg(clt_turso_feature = "conn_raw_api")]
541            portable_changes_required: false,
542        }
543    }
544
545    /// True iff no ops (row versions or header) have been appended.
546    pub fn is_empty(&self) -> bool {
547        let empty = self.op_count == 0;
548        turso_assert!(
549            !empty || !self.has_header,
550            "header shouldn't have been written"
551        );
552        empty
553    }
554
555    /// Test-only constructor that eagerly serializes a list of row versions
556    /// and an optional `DatabaseHeader` into the payload buffer using the
557    /// production wire format. Production code uses
558    /// [`DurableStorage::serialize_row_version`] and
559    /// [`DurableStorage::serialize_database_header`] instead so the bytes are
560    /// appended one op at a time.
561    #[cfg(clt_turso_tests)]
562    pub(crate) fn for_test(
563        tx_timestamp: TxID,
564        row_versions: &[RowVersion],
565        header: Option<DatabaseHeader>,
566    ) -> Self {
567        let mut record = Self::new(tx_timestamp);
568        for rv in row_versions {
569            record.push_row_version_for_test(rv);
570        }
571        if let Some(hdr) = header {
572            record.set_header_for_test(&hdr);
573        }
574        record
575    }
576
577    /// Test-only: append one row-version op to the payload buffer.
578    #[cfg(clt_turso_tests)]
579    pub(crate) fn push_row_version_for_test(&mut self, row_version: &RowVersion) {
580        crate::mvcc::persistent_storage::logical_log::serialize_op_entry(
581            &mut self.buf,
582            row_version,
583            None,
584        )
585        .expect("failed to serialize row version in test");
586        self.op_count += 1;
587    }
588
589    /// Test-only: append a `DatabaseHeader` op to the payload buffer.
590    #[cfg(clt_turso_tests)]
591    pub(crate) fn set_header_for_test(&mut self, header: &DatabaseHeader) {
592        assert!(!self.has_header, "header op appended twice in test");
593        crate::mvcc::persistent_storage::logical_log::serialize_header_entry(&mut self.buf, header);
594        self.has_header = true;
595        self.op_count += 1;
596    }
597}
598
599#[cfg(clt_turso_feature = "conn_raw_api")]
600fn portable_table_id_from_rootpage(rootpage: i64) -> MVTableId {
601    if rootpage > 0 {
602        MVTableId::from(-rootpage)
603    } else {
604        MVTableId::from(rootpage)
605    }
606}
607
608#[derive(Clone, Debug)]
609#[cfg(clt_turso_feature = "conn_raw_api")]
610struct PortableTableRef {
611    name: String,
612}
613
614#[cfg(clt_turso_feature = "conn_raw_api")]
615fn rootpage_for_mv_table_id<Clock: LogicalClock, A: ConcurrentAllocator>(
616    mvcc_store: &MvStore<Clock, A>,
617    table_id: MVTableId,
618) -> i64 {
619    mvcc_store
620        .table_id_to_rootpage
621        .get(&table_id)
622        .and_then(|entry| entry.value().root_page)
623        .map(|rootpage| rootpage as i64)
624        .unwrap_or_else(|| i64::from(table_id))
625}
626
627#[cfg(clt_turso_feature = "conn_raw_api")]
628fn table_name_for_rootpage_in_schema(schema: &Schema, rootpage: i64) -> Option<String> {
629    if rootpage == 0 {
630        return None;
631    }
632    if let Some(name) = schema.table_name_for_root_page(rootpage) {
633        return Some(name.to_string());
634    }
635    let alternate_rootpage = -rootpage;
636    if alternate_rootpage == 0 {
637        return None;
638    }
639    schema
640        .table_name_for_root_page(alternate_rootpage)
641        .map(ToString::to_string)
642}
643
644#[cfg(clt_turso_feature = "conn_raw_api")]
645fn table_name_for_rootpage(connection: &Connection, rootpage: i64) -> Option<String> {
646    {
647        let schema = connection.schema.read();
648        if let Some(name) = table_name_for_rootpage_in_schema(&schema, rootpage) {
649            return Some(name);
650        }
651    }
652
653    let schema = connection.db.schema.lock();
654    table_name_for_rootpage_in_schema(&schema, rootpage)
655}
656
657#[cfg(clt_turso_feature = "conn_raw_api")]
658fn table_name_for_rootpage_in_mvcc_schema<Clock: LogicalClock, A: ConcurrentAllocator>(
659    mvcc_store: &MvStore<Clock, A>,
660    rootpage: i64,
661) -> Option<String> {
662    if rootpage == 0 {
663        return None;
664    }
665    let alternate_rootpage = -rootpage;
666    for entry in mvcc_store.rows.iter() {
667        if entry.key().table_id != SQLITE_SCHEMA_MVCC_TABLE_ID {
668            continue;
669        }
670        let row_versions = entry.value().read();
671        for row_version in row_versions.iter().rev() {
672            if row_version.end().is_some() {
673                continue;
674            }
675            let Ok(row) = portable_schema_row_from_record(row_version.row.payload()) else {
676                continue;
677            };
678            if row.rootpage == rootpage || row.rootpage == alternate_rootpage {
679                return Some(row.name);
680            }
681        }
682    }
683    None
684}
685
686#[cfg(clt_turso_feature = "conn_raw_api")]
687fn portable_table_name_for_mv_table_id<Clock: LogicalClock, A: ConcurrentAllocator>(
688    connection: &Connection,
689    mvcc_store: &MvStore<Clock, A>,
690    table_id: MVTableId,
691) -> Option<String> {
692    let rootpage = rootpage_for_mv_table_id(mvcc_store, table_id);
693    table_name_for_rootpage(connection, rootpage)
694        .or_else(|| table_name_for_rootpage_in_mvcc_schema(mvcc_store, rootpage))
695}
696
697#[cfg(clt_turso_feature = "conn_raw_api")]
698fn portable_delete_op_extension_for_row_version<Clock: LogicalClock, A: ConcurrentAllocator>(
699    connection: &Connection,
700    mvcc_store: &MvStore<Clock, A>,
701    row_version: &RowVersion,
702) -> Result<Option<Vec<u8>>> {
703    if !connection.portable_logical_changes_enabled() {
704        return Ok(None);
705    }
706    if !matches!(row_version.end(), Some(TxTimestampOrID::Timestamp(_))) {
707        return Ok(None);
708    }
709    let RowKey::Int(rowid) = row_version.row.id.row_id else {
710        return Ok(None);
711    };
712
713    if row_version.row.id.table_id == SQLITE_SCHEMA_MVCC_TABLE_ID {
714        let extension =
715            encode_delete_portable_extension(Some(row_version.row.payload()), None, Some(rowid));
716        return Ok((!extension.is_empty()).then_some(extension));
717    }
718
719    let Some(table_name) =
720        portable_table_name_for_mv_table_id(connection, mvcc_store, row_version.row.id.table_id)
721    else {
722        return Ok(None);
723    };
724    if !is_portable_logical_name(&table_name) {
725        return Ok(None);
726    }
727
728    let schema = connection.schema.read();
729    let Some(table) = schema.get_btree_table(&table_name) else {
730        return Ok(None);
731    };
732
733    let mut record_values = None;
734    let mut pk_values = Vec::with_capacity(table.primary_key_columns.len());
735    for (pk_name, _) in &table.primary_key_columns {
736        let Some((logical_column, column)) = table.get_column(pk_name) else {
737            return Err(LimboError::InternalError(format!(
738                "primary key column {pk_name} not found for table {table_name}"
739            )));
740        };
741        if column.is_rowid_alias() {
742            pk_values.push(Value::from_i64(rowid));
743            continue;
744        }
745        let values = match &record_values {
746            Some(values) => values,
747            None => record_values.insert(
748                ImmutableRecordRef::from_bin_record(row_version.row.payload())
749                    .get_values_owned()?,
750            ),
751        };
752        let physical_column = table.logical_to_physical_column(logical_column);
753        let Some(value) = values.get(physical_column).cloned() else {
754            return Err(LimboError::Corrupt(format!(
755                "DELETE_TABLE record for {table_name} missing primary key column {pk_name}"
756            )));
757        };
758        pk_values.push(value);
759    }
760
761    let pk_record = if pk_values.is_empty() {
762        Vec::new()
763    } else {
764        ImmutableRecord::from_values(&pk_values, pk_values.len())?.into_payload()
765    };
766    let extension = encode_delete_portable_extension(None, Some(&pk_record), Some(rowid));
767    Ok((!extension.is_empty()).then_some(extension))
768}
769
770/// A transaction timestamp or ID.
771///
772/// Versions either track a timestamp or a transaction ID, depending on the
773/// phase of the transaction. During the active phase, new versions track the
774/// transaction ID in the `begin` and `end` fields. After a transaction commits,
775/// versions switch to tracking timestamps.
776#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
777pub enum TxTimestampOrID {
778    /// A committed transaction's timestamp.
779    Timestamp(u64),
780    /// The ID of a non-committed transaction.
781    TxID(TxID),
782}
783
784/// Tracks versions created/modified during a savepoint for rollback.
785/// Used for statement-level savepoints in interactive transactions.
786#[derive(Debug, Default)]
787enum SavepointKind {
788    /// Internal savepoint used for statement-level rollback.
789    #[default]
790    Statement,
791    /// User-visible named savepoint.
792    Named {
793        name: String,
794        starts_transaction: bool,
795    },
796}
797
798/// Tracks row/index version deltas created inside a single savepoint scope.
799#[derive(Debug)]
800pub struct Savepoint<A: RowVersionAllocator = TursoAllocator> {
801    kind: SavepointKind,
802    deferred_fk_violations: isize,
803    header: DatabaseHeader,
804    header_dirty: bool,
805    /// Versions CREATED during this savepoint (insert operations).
806    /// On rollback: these versions are removed from their chains.
807    created_table_versions: Vec<(RowID, u64)>,
808    created_index_versions: Vec<((MVTableId, Arc<SortableIndexKey>), u64)>,
809    /// Versions DELETED during this savepoint (end timestamp set).
810    /// On rollback: clear end timestamp to restore visibility.
811    deleted_table_versions: Vec<(RowID, u64)>,
812    deleted_index_versions: Vec<((MVTableId, Arc<SortableIndexKey>), u64)>,
813    /// RowIDs that were NEWLY added to write_set by this savepoint.
814    /// On rollback: only these should be removed from write_set.
815    newly_added_to_write_set: Vec<(RowID, RowVersions<A>)>,
816}
817
818impl<A: RowVersionAllocator> Default for Savepoint<A> {
819    fn default() -> Self {
820        Self {
821            kind: SavepointKind::default(),
822            deferred_fk_violations: 0,
823            header: DatabaseHeader::default(),
824            header_dirty: false,
825            created_table_versions: Vec::new(),
826            created_index_versions: Vec::new(),
827            deleted_table_versions: Vec::new(),
828            deleted_index_versions: Vec::new(),
829            newly_added_to_write_set: Vec::new(),
830        }
831    }
832}
833
834impl<A: RowVersionAllocator> Savepoint<A> {
835    /// Creates an internal statement savepoint used for per-statement rollback.
836    fn statement(header: DatabaseHeader, header_dirty: bool) -> Self {
837        Self {
838            header,
839            header_dirty,
840            ..Default::default()
841        }
842    }
843
844    /// Creates a user-visible named savepoint snapshot.
845    fn named(
846        name: String,
847        starts_transaction: bool,
848        deferred_fk_violations: isize,
849        header: DatabaseHeader,
850        header_dirty: bool,
851    ) -> Self {
852        Self {
853            kind: SavepointKind::Named {
854                name,
855                starts_transaction,
856            },
857            deferred_fk_violations,
858            header,
859            header_dirty,
860            ..Default::default()
861        }
862    }
863
864    /// Merges child savepoint deltas into this savepoint.
865    ///
866    /// Called when releasing nested savepoints so outer rollback still has a full undo set.
867    fn merge_from(&mut self, mut other: Savepoint<A>) {
868        self.created_table_versions
869            .append(&mut other.created_table_versions);
870        self.created_index_versions
871            .append(&mut other.created_index_versions);
872        self.deleted_table_versions
873            .append(&mut other.deleted_table_versions);
874        self.deleted_index_versions
875            .append(&mut other.deleted_index_versions);
876        self.newly_added_to_write_set
877            .append(&mut other.newly_added_to_write_set);
878    }
879}
880
881struct SavepointRollbackResult<A: RowVersionAllocator = TursoAllocator> {
882    /// Savepoints that were rolled back, in the order they were created (oldest to newest).
883    rolledback_savepoints: Vec<Savepoint<A>>,
884    /// Deferred FK counter snapshot captured at the target named savepoint.
885    deferred_fk_violations: isize,
886}
887
888#[derive(Debug)]
889struct WriteSet<A: RowVersionAllocator = TursoAllocator> {
890    entries: Vec<(RowID, RowVersions<A>)>,
891    /// A set of the pointer addresses of the `RowVersions`. Used to deduplicate entries.
892    ///
893    /// This is correct because instances of [RowVersions] are created once per [RowID] and then
894    /// reused by cloning the [Arc]. It would be nice to encode this in the type system, but I'm
895    /// not sure how.
896    seen: HashSet<usize>,
897}
898
899impl<A: RowVersionAllocator> Default for WriteSet<A> {
900    fn default() -> Self {
901        Self {
902            entries: Vec::new(),
903            seen: HashSet::default(),
904        }
905    }
906}
907
908impl<A: RowVersionAllocator> WriteSet<A> {
909    fn new() -> Self {
910        Self::default()
911    }
912
913    /// Returns `true` if this `RowVersions` was not already contained in the write set.
914    fn insert(&mut self, id: RowID, row_versions: RowVersions<A>) -> bool {
915        let ptr = Arc::as_ptr(&row_versions) as usize;
916        if self.seen.insert(ptr) {
917            self.entries.push((id, row_versions));
918            true
919        } else {
920            false
921        }
922    }
923
924    fn is_empty(&self) -> bool {
925        self.entries.is_empty()
926    }
927
928    fn iter(&self) -> std::slice::Iter<'_, (RowID, RowVersions<A>)> {
929        self.entries.iter()
930    }
931
932    /// Retain entries where `keep(rowid, row_versions)` returns true.
933    fn retain<F: FnMut(&RowID, &RowVersions<A>) -> bool>(&mut self, mut keep: F) {
934        let seen = &mut self.seen;
935        self.entries.retain(|(rowid, rv)| {
936            if keep(rowid, rv) {
937                true
938            } else {
939                seen.remove(&(Arc::as_ptr(rv) as usize));
940                false
941            }
942        });
943    }
944
945    /// Clones the write set into a [Vec].
946    fn to_vec(&self) -> Vec<(RowID, RowVersions<A>)> {
947        self.entries.clone()
948    }
949}
950
951/// Transaction
952#[derive(Debug)]
953pub struct Transaction<A: RowVersionAllocator = TursoAllocator> {
954    /// The state of the transaction.
955    state: AtomicTransactionState,
956    /// The transaction ID.
957    tx_id: u64,
958    /// The transaction begin timestamp.
959    begin_ts: u64,
960    /// The transaction write set. Only writer is the [Transaction]'s own connection.
961    write_set: Mutex<WriteSet<A>>,
962    /// The transaction header.
963    header: RwLock<DatabaseHeader>,
964    /// True when the transaction mutated its local database header snapshot.
965    header_dirty: AtomicBool,
966    /// Stack of savepoints for statement-level rollback.
967    /// Each savepoint tracks versions created/deleted during that statement.
968    savepoint_stack: RwLock<Vec<Savepoint<A>>>,
969    /// True when this transaction currently holds the serialized logical-log commit lock.
970    pager_commit_lock_held: AtomicBool,
971    /// Number of unresolved commit dependencies (must reach 0 before commit).
972    /// i.e the number of transactions this transaction is dependent on and waiting for
973    /// commit or abort.
974    /// Hekaton Section 2.7: "A transaction cannot commit until this counter is zero."
975    commit_dep_counter: AtomicU64,
976    /// Flag: a depended-on transaction aborted; this transaction must abort too.
977    /// Hekaton Section 2.7: "AbortNow that other transactions can set to tell T to abort."
978    abort_now: AtomicBool,
979    /// Transaction IDs that depend on this transaction (notified on commit/abort).
980    /// Hekaton Section 2.7: "CommitDepSet, that stores transaction IDs of the
981    /// transactions that depend on T."
982    commit_dep_set: Mutex<HashSet<TxID>>,
983    /// True when this transaction holds `blocking_checkpoint_lock` in read mode
984    /// (truncate / flag-off path only). Passive `begin_tx` does not pin the lock.
985    holds_blocking_checkpoint_read: AtomicBool,
986    /// `MvStore::schema_generation` captured at `begin_tx` (passive root publication gate).
987    schema_generation_at_begin: u64,
988    /// This transaction's frozen WAL read mark `(checkpoint_seq, max_frame)`, captured when it
989    /// pinned its read transaction at begin. A checkpoint-materialized B-tree is physically
990    /// reachable by this transaction only if `materialized_at <= read_mark` — i.e. the
991    /// materialization's frames are at-or-below this read mark (or in an earlier, backfilled WAL
992    /// epoch). See [`MvStore::is_btree_readable_at`] / [`MvStore::compute_min_reader_mark`].
993    read_mark: WalPos,
994}
995
996impl<A: RowVersionAllocator> Transaction<A> {
997    fn new(
998        tx_id: u64,
999        begin_ts: u64,
1000        header: DatabaseHeader,
1001        read_mark: WalPos,
1002        schema_generation_at_begin: u64,
1003    ) -> Transaction<A> {
1004        Transaction {
1005            state: TransactionState::Active.into(),
1006            tx_id,
1007            begin_ts,
1008            read_mark,
1009            schema_generation_at_begin,
1010            write_set: Mutex::new(WriteSet::new()),
1011            header: RwLock::new(header),
1012            header_dirty: AtomicBool::new(false),
1013            savepoint_stack: RwLock::new(Vec::new()),
1014            pager_commit_lock_held: AtomicBool::new(false),
1015            commit_dep_counter: AtomicU64::new(0),
1016            abort_now: AtomicBool::new(false),
1017            commit_dep_set: Mutex::new(HashSet::default()),
1018            holds_blocking_checkpoint_read: AtomicBool::new(false),
1019        }
1020    }
1021
1022    fn insert_to_write_set(&self, id: RowID, row_versions: RowVersions<A>) {
1023        // Always record in the current savepoint's `newly_added_to_write_set`.
1024        // Duplicates here are harmless: `rollback_savepoint_changes` collects
1025        // touched rowids into a BTreeSet (dedup), and the actual write_set
1026        // removal is gated by `row_has_uncommitted_version_for_tx`, so a row
1027        // already pinned by a parent savepoint won't be evicted on inner
1028        // rollback.
1029        if let Some(savepoint) = self.savepoint_stack.write().last_mut() {
1030            savepoint
1031                .newly_added_to_write_set
1032                .push((id.clone(), row_versions.clone()));
1033        }
1034        self.write_set.lock().insert(id, row_versions);
1035    }
1036
1037    /// Begin a new savepoint for statement-level tracking.
1038    fn begin_savepoint(&self) {
1039        let depth = self.savepoint_stack.read().len();
1040        tracing::debug!("begin_savepoint(tx_id={}, depth={})", self.tx_id, depth);
1041        let header = *self.header.read();
1042        let header_dirty = self.header_dirty.load(Ordering::Acquire);
1043        self.savepoint_stack
1044            .write()
1045            .push(Savepoint::statement(header, header_dirty));
1046    }
1047
1048    /// Begin a new named savepoint. If `starts_transaction` is true, this savepoint represents the
1049    /// beginning of an interactive transaction and will be used to track deferred FK violations
1050    /// for that transaction.
1051    fn begin_named_savepoint(
1052        &self,
1053        name: String,
1054        starts_transaction: bool,
1055        deferred_fk_violations: isize,
1056    ) {
1057        let depth = self.savepoint_stack.read().len();
1058        tracing::debug!(
1059            "begin_named_savepoint(tx_id={}, depth={}, name={})",
1060            self.tx_id,
1061            depth,
1062            name
1063        );
1064        let header = *self.header.read();
1065        let header_dirty = self.header_dirty.load(Ordering::Acquire);
1066        self.savepoint_stack.write().push(Savepoint::named(
1067            name,
1068            starts_transaction,
1069            deferred_fk_violations,
1070            header,
1071            header_dirty,
1072        ));
1073    }
1074
1075    /// Release the newest savepoint (statement completed successfully).
1076    fn release_savepoint(&self) {
1077        let depth = self.savepoint_stack.read().len();
1078        tracing::debug!("release_savepoint(tx_id={}, depth={})", self.tx_id, depth);
1079        let mut savepoints = self.savepoint_stack.write();
1080        if !matches!(
1081            savepoints.last().map(|savepoint| &savepoint.kind),
1082            Some(SavepointKind::Statement)
1083        ) {
1084            return;
1085        }
1086        let savepoint = savepoints.pop().expect("savepoint must exist");
1087        if let Some(parent) = savepoints.last_mut() {
1088            parent.merge_from(savepoint);
1089        }
1090    }
1091
1092    fn pop_statement_savepoint(&self) -> Option<Savepoint<A>> {
1093        let mut savepoints = self.savepoint_stack.write();
1094        if !matches!(
1095            savepoints.last().map(|savepoint| &savepoint.kind),
1096            Some(SavepointKind::Statement)
1097        ) {
1098            return None;
1099        }
1100        savepoints.pop()
1101    }
1102
1103    /// Release a named savepoint. If this savepoint starts a transaction, returns
1104    /// [SavepointResult::Commit] to indicate the transaction should be committed.
1105    fn release_named_savepoint(&self, name: &str) -> SavepointResult {
1106        let mut savepoints = self.savepoint_stack.write();
1107        let Some(target_idx) = savepoints.iter().rposition(|savepoint| {
1108            matches!(
1109                savepoint.kind,
1110                SavepointKind::Named {
1111                    name: ref savepoint_name,
1112                    ..
1113                } if savepoint_name == name
1114            )
1115        }) else {
1116            return SavepointResult::NotFound;
1117        };
1118
1119        let commits_transaction = if matches!(
1120            savepoints[target_idx].kind,
1121            SavepointKind::Named {
1122                starts_transaction: true,
1123                ..
1124            }
1125        ) && target_idx == 0
1126        {
1127            SavepointResult::Commit
1128        } else {
1129            SavepointResult::Release
1130        };
1131        if matches!(commits_transaction, SavepointResult::Commit) {
1132            // Defer mutation until transaction commit succeeds. If commit fails
1133            // (e.g. deferred FK violation), savepoints must remain intact.
1134            return commits_transaction;
1135        }
1136
1137        let drained: Vec<Savepoint<A>> = savepoints.drain(target_idx..).collect();
1138        if let Some(parent) = savepoints.last_mut() {
1139            for savepoint in drained {
1140                parent.merge_from(savepoint);
1141            }
1142        }
1143        commits_transaction
1144    }
1145
1146    /// Find the named savepoint to rollback to and pop all savepoints above it. Returns the rolled
1147    /// back savepoints and net change in deferred FK violations for undoing changes to transaction
1148    /// state.
1149    fn rollback_to_named_savepoint(&self, name: &str) -> Option<SavepointRollbackResult<A>> {
1150        let mut savepoints = self.savepoint_stack.write();
1151        let target_idx = savepoints.iter().rposition(|savepoint| {
1152            matches!(
1153                savepoint.kind,
1154                SavepointKind::Named {
1155                    name: ref savepoint_name,
1156                    ..
1157                } if savepoint_name == name
1158            )
1159        })?;
1160
1161        let target_name = match &savepoints[target_idx].kind {
1162            SavepointKind::Named { name, .. } => name.clone(),
1163            SavepointKind::Statement => unreachable!("target idx points to named savepoint"),
1164        };
1165        let starts_transaction = matches!(
1166            savepoints[target_idx].kind,
1167            SavepointKind::Named {
1168                starts_transaction: true,
1169                ..
1170            }
1171        );
1172        let deferred_fk_violations = savepoints[target_idx].deferred_fk_violations;
1173        let header = savepoints[target_idx].header;
1174        let header_dirty = savepoints[target_idx].header_dirty;
1175
1176        let drained: Vec<Savepoint<A>> = savepoints.drain(target_idx..).collect();
1177        savepoints.push(Savepoint::named(
1178            target_name,
1179            starts_transaction,
1180            deferred_fk_violations,
1181            header,
1182            header_dirty,
1183        ));
1184        Some(SavepointRollbackResult {
1185            rolledback_savepoints: drained,
1186            deferred_fk_violations,
1187        })
1188    }
1189
1190    /// Record a version that was created during the current savepoint.
1191    fn record_created_table_version(&self, rowid: RowID, version_id: u64) {
1192        if let Some(savepoint) = self.savepoint_stack.write().last_mut() {
1193            tracing::debug!(
1194                "record_created_table_version(tx_id={}, table_id={}, row_id={}, version_id={})",
1195                self.tx_id,
1196                rowid.table_id,
1197                rowid.row_id,
1198                version_id
1199            );
1200            savepoint.created_table_versions.push((rowid, version_id));
1201        }
1202    }
1203
1204    /// Record an index version that was created during the current savepoint.
1205    fn record_created_index_version(
1206        &self,
1207        key: (MVTableId, Arc<SortableIndexKey>),
1208        version_id: u64,
1209    ) {
1210        if let Some(savepoint) = self.savepoint_stack.write().last_mut() {
1211            tracing::debug!(
1212                "record_created_index_version(tx_id={}, table_id={}, version_id={})",
1213                self.tx_id,
1214                key.0,
1215                version_id
1216            );
1217            savepoint.created_index_versions.push((key, version_id));
1218        }
1219    }
1220
1221    /// Record a version that was deleted during the current savepoint.
1222    fn record_deleted_table_version(&self, rowid: RowID, version_id: u64) {
1223        if let Some(savepoint) = self.savepoint_stack.write().last_mut() {
1224            tracing::debug!(
1225                "record_deleted_table_version(tx_id={}, table_id={}, row_id={}, version_id={})",
1226                self.tx_id,
1227                rowid.table_id,
1228                rowid.row_id,
1229                version_id
1230            );
1231            savepoint.deleted_table_versions.push((rowid, version_id));
1232        }
1233    }
1234
1235    /// Record an index version that was deleted during the current savepoint.
1236    fn record_deleted_index_version(
1237        &self,
1238        key: (MVTableId, Arc<SortableIndexKey>),
1239        version_id: u64,
1240    ) {
1241        if let Some(savepoint) = self.savepoint_stack.write().last_mut() {
1242            tracing::debug!(
1243                "record_deleted_index_version(tx_id={}, table_id={}, version_id={})",
1244                self.tx_id,
1245                key.0,
1246                version_id
1247            );
1248            savepoint.deleted_index_versions.push((key, version_id));
1249        }
1250    }
1251}
1252
1253impl<A: RowVersionAllocator> std::fmt::Display for Transaction<A> {
1254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
1255        write!(
1256            f,
1257            "{{ state: {}, id: {}, begin_ts: {}, write_set: ",
1258            self.state.load(),
1259            self.tx_id,
1260            self.begin_ts,
1261        )?;
1262
1263        match self.write_set.try_lock() {
1264            Some(write_set) => {
1265                write!(f, "[")?;
1266                for (i, (id, _chain)) in write_set.iter().enumerate() {
1267                    if i > 0 {
1268                        write!(f, ", ")?
1269                    }
1270                    write!(f, "{id:?}")?;
1271                }
1272                write!(f, "]")?;
1273            }
1274            None => write!(f, "<locked>")?,
1275        }
1276
1277        write!(f, " }}")
1278    }
1279}
1280
1281/// Transaction state.
1282#[derive(Debug, Clone, PartialEq, Copy)]
1283enum TransactionState {
1284    Active,
1285    /// Preparing state includes the end_ts so other transactions can compare
1286    /// timestamps during validation to resolve races (first-committer-wins).
1287    Preparing(u64),
1288    Aborted,
1289    Terminated,
1290    Committed(u64),
1291}
1292
1293impl TransactionState {
1294    // Bit patterns for encoding states with timestamps
1295    const PREPARING_BIT: u64 = 0x4000_0000_0000_0000;
1296    const COMMITTED_BIT: u64 = 0x8000_0000_0000_0000;
1297    const TIMESTAMP_MASK: u64 = 0x3fff_ffff_ffff_ffff;
1298
1299    pub fn encode(&self) -> u64 {
1300        match self {
1301            TransactionState::Active => 0,
1302            TransactionState::Preparing(ts) => {
1303                // We only support 2^62 - 1 timestamps
1304                assert!(ts & !Self::TIMESTAMP_MASK == 0);
1305                Self::PREPARING_BIT | ts
1306            }
1307            TransactionState::Aborted => 1,
1308            TransactionState::Terminated => 2,
1309            TransactionState::Committed(ts) => {
1310                // We only support 2^62 - 1 timestamps
1311                turso_assert_eq!(ts & !Self::TIMESTAMP_MASK, 0);
1312                Self::COMMITTED_BIT | ts
1313            }
1314        }
1315    }
1316
1317    pub fn decode(v: u64) -> Self {
1318        match v {
1319            0 => TransactionState::Active,
1320            1 => TransactionState::Aborted,
1321            2 => TransactionState::Terminated,
1322            v if v & Self::COMMITTED_BIT != 0 => {
1323                TransactionState::Committed(v & Self::TIMESTAMP_MASK)
1324            }
1325            v if v & Self::PREPARING_BIT != 0 => {
1326                TransactionState::Preparing(v & Self::TIMESTAMP_MASK)
1327            }
1328            _ => panic!("Invalid transaction state"),
1329        }
1330    }
1331}
1332
1333// Transaction state encoded into a single 64-bit atomic.
1334#[derive(Debug)]
1335pub(crate) struct AtomicTransactionState {
1336    pub(crate) state: AtomicU64,
1337}
1338
1339impl From<TransactionState> for AtomicTransactionState {
1340    fn from(state: TransactionState) -> Self {
1341        Self {
1342            state: AtomicU64::new(state.encode()),
1343        }
1344    }
1345}
1346
1347impl From<AtomicTransactionState> for TransactionState {
1348    fn from(state: AtomicTransactionState) -> Self {
1349        let encoded = state.state.load(Ordering::Acquire);
1350        TransactionState::decode(encoded)
1351    }
1352}
1353
1354impl std::cmp::PartialEq<TransactionState> for AtomicTransactionState {
1355    fn eq(&self, other: &TransactionState) -> bool {
1356        &self.load() == other
1357    }
1358}
1359
1360impl std::fmt::Display for TransactionState {
1361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
1362        match self {
1363            TransactionState::Active => write!(f, "Active"),
1364            TransactionState::Preparing(ts) => write!(f, "Preparing({ts})"),
1365            TransactionState::Committed(ts) => write!(f, "Committed({ts})"),
1366            TransactionState::Aborted => write!(f, "Aborted"),
1367            TransactionState::Terminated => write!(f, "Terminated"),
1368        }
1369    }
1370}
1371
1372impl AtomicTransactionState {
1373    fn store(&self, state: TransactionState) {
1374        self.state.store(state.encode(), Ordering::Release);
1375    }
1376
1377    fn load(&self) -> TransactionState {
1378        TransactionState::decode(self.state.load(Ordering::Acquire))
1379    }
1380}
1381
1382#[allow(clippy::large_enum_variant)]
1383pub enum CommitState<Clock: LogicalClock, A: ConcurrentAllocator = TursoAllocator> {
1384    Initial,
1385    Commit {
1386        end_ts: u64,
1387    },
1388    /// Wait for unresolved commit dependencies before building the durable
1389    /// committed view for the logical log.
1390    /// Hekaton Section 3.2: "If T passes validation, it must wait for outstanding
1391    /// commit dependencies to be resolved."
1392    WaitForDependencies {
1393        end_ts: u64,
1394    },
1395    /// Build the committed log record incrementally, yielding every
1396    /// `MVCC_COMMIT_BATCH_SIZE` rowids so that very large write sets
1397    /// (e.g. CREATE INDEX on a multi-million row table) don't monopolize
1398    /// the executor.
1399    BuildLogRecord(BuildLogRecordCtx),
1400    BeginCommitLogicalLog {
1401        end_ts: u64,
1402        log_record: LogRecord,
1403    },
1404    UpgradeLogicalLogHeader {
1405        end_ts: u64,
1406        log_record: LogRecord,
1407    },
1408    WriteLogicalLog {
1409        end_ts: u64,
1410        log_record: LogRecord,
1411    },
1412    FinishLogicalLogWrite {
1413        end_ts: u64,
1414    },
1415    SyncLogicalLog {
1416        end_ts: u64,
1417    },
1418    EndCommitLogicalLog {
1419        end_ts: u64,
1420    },
1421    Checkpoint {
1422        // TODO: if and when we transform this code to async we won't be needing this explicit state machine nor
1423        // the mutex
1424        state_machine: Mutex<StateMachine<CheckpointStateMachine<Clock, A>>>,
1425    },
1426    CommitEnd {
1427        end_ts: u64,
1428    },
1429    /// Publish committed timestamps into the live MVCC chains in chunks
1430    /// of `MVCC_COMMIT_BATCH_SIZE` rowids, yielding between chunks. The
1431    /// transaction is already in the Committed state at this point, so
1432    /// readers consult `txs[tx_id]` to resolve any TxID references that
1433    /// haven't been rewritten yet.
1434    RewriteLiveVersions(RewriteLiveVersionsCtx),
1435    /// Final post-rewrite cleanup: drain commit dependents, release the
1436    /// commit lock, update the global header, finish the tx, and start
1437    /// auto-checkpoint if needed.
1438    FinalizeCommit {
1439        end_ts: u64,
1440    },
1441}
1442
1443/// Iteration state for the chunked `BuildLogRecord` step.
1444#[derive(Debug)]
1445pub struct BuildLogRecordCtx {
1446    pub end_ts: u64,
1447    pub log_record: LogRecord,
1448    /// Index into `CommitStateMachine::write_set` for the current pass.
1449    pub cursor: usize,
1450    /// True while emitting schema rows (sqlite_schema), false during the
1451    /// data-row pass. Schema rows are emitted first so log replay sees
1452    /// CREATE TABLE before related INSERTs.
1453    pub schema_process: bool,
1454    /// Snapshot of `tx.header` taken when `header_dirty` was observed
1455    /// at the start of BuildLogRecord. Appended as an `OP_UPDATE_HEADER`
1456    /// op after both row-version passes complete (preserves on-disk
1457    /// order: ops first, header last).
1458    pub pending_header: Option<DatabaseHeader>,
1459}
1460
1461/// Iteration state for the chunked `RewriteLiveVersions` step.
1462#[derive(Debug)]
1463pub struct RewriteLiveVersionsCtx {
1464    pub end_ts: u64,
1465    /// Index into `CommitStateMachine::write_set`.
1466    pub cursor: usize,
1467}
1468
1469/// How many rowids `BuildLogRecord` / `RewriteLiveVersions` process before
1470/// yielding to the executor. Picked to amortize state-machine overhead
1471/// while keeping a CREATE INDEX on a 2M-row table responsive.
1472const MVCC_COMMIT_BATCH_SIZE: usize = 1024;
1473
1474#[derive(Debug)]
1475pub enum WriteRowState {
1476    Initial,
1477    Seek,
1478    /// After seek returns TryAdvance for an index key stored in an interior node,
1479    /// advance the cursor to that interior cell so insert overwrites it.
1480    Advance,
1481    Insert,
1482    /// Move to the next record in order to leave the cursor in the next position, this is used for inserting multiple rows for optimizations.
1483    Next,
1484}
1485
1486#[derive(Debug)]
1487struct CommitCoordinator {
1488    pager_commit_lock: Arc<TursoRwLock>,
1489}
1490
1491impl CommitCoordinator {
1492    fn new() -> Self {
1493        Self {
1494            pager_commit_lock: Arc::new(TursoRwLock::new()),
1495        }
1496    }
1497}
1498
1499#[cfg(any(clt_turso_tests, injected_yields))]
1500#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumCount)]
1501#[repr(u8)]
1502pub(crate) enum CommitYieldPoint {
1503    CommitValidation,
1504    WaitForDependencies,
1505    /// Fires once on the first entry into `step_build_log_record` (cursor=0,
1506    /// schema_process=true), before any chunk processing. Pairs with
1507    /// `LogRecordPrepared` to bracket the BuildLogRecord chunked yields.
1508    BuildLogRecordStart,
1509    LogRecordPrepared,
1510    /// Fires after commit dependencies are released and the commit lock is
1511    /// dropped, but before publishing the cached global header / committed
1512    /// timestamp watermark.
1513    BeforeGlobalHeaderUpdate,
1514    BeforeFinishCommittedTx,
1515    /// Boundary right after `remove_tx` runs but before the connection cache
1516    /// is cleared by the caller at vdbe/mod.rs. Used for failure injection
1517    /// to reproduce divergence between `mv_store.txs` and `connection.mv_tx_id`.
1518    AfterRemoveTx,
1519}
1520
1521#[cfg(any(clt_turso_tests, injected_yields))]
1522#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumCount)]
1523#[repr(u8)]
1524pub(crate) enum ExclusiveTxYieldPoint {
1525    AfterTimestampCheckBeforeCas,
1526}
1527
1528#[cfg(any(clt_turso_tests, injected_yields))]
1529impl YieldPointMarker for ExclusiveTxYieldPoint {
1530    const POINT_COUNT: u8 = Self::COUNT as u8;
1531
1532    fn ordinal(self) -> u8 {
1533        self as u8
1534    }
1535}
1536
1537#[cfg(any(clt_turso_tests, injected_yields))]
1538impl YieldPointMarker for CommitYieldPoint {
1539    const POINT_COUNT: u8 = Self::COUNT as u8;
1540
1541    fn ordinal(self) -> u8 {
1542        self as u8
1543    }
1544}
1545
1546#[cfg(any(clt_turso_tests, injected_yields))]
1547fn commit_yield_key(tx_id: u64) -> u64 {
1548    // any large number will do
1549    const COMMIT_SELECTION_TAG: u64 = 0xC011_C011_C011_C011;
1550    tx_id ^ COMMIT_SELECTION_TAG
1551}
1552
1553#[cfg(any(clt_turso_tests, injected_yields))]
1554impl<Clock: LogicalClock, A: ConcurrentAllocator> ProvidesYieldContext
1555    for CommitStateMachine<Clock, A>
1556{
1557    fn yield_context(&self) -> YieldContext {
1558        YieldContext::new(
1559            self.connection.yield_injector(),
1560            self.connection.failure_injector(),
1561            self.yield_instance_id,
1562            commit_yield_key(self.tx_id),
1563        )
1564    }
1565}
1566
1567pub struct CommitStateMachine<Clock: LogicalClock, A: ConcurrentAllocator = TursoAllocator> {
1568    state: CommitState<Clock, A>,
1569    is_finalized: bool,
1570    #[cfg(any(clt_turso_tests, injected_yields))]
1571    yield_instance_id: u64,
1572    did_commit_schema_change: bool,
1573    tx_id: TxID,
1574    mvcc_store: Arc<MvStore<Clock, A>>,
1575    connection: Arc<Connection>,
1576    /// Database index this commit is for (`MAIN_DB_ID` or an attached-db id).
1577    /// Threaded through so that `finish_committed_tx` can clear the matching
1578    /// connection-level mv_tx slot atomically with `remove_tx`.
1579    db_id: usize,
1580    commit_coordinator: Arc<CommitCoordinator>,
1581    header: Arc<RwLock<Option<DatabaseHeader>>>,
1582    pager: Arc<Pager>,
1583    /// Bytes appended to the logical log for this commit; applied to writer offset only after durability and before lock release.
1584    pending_log_append_bytes: Option<u64>,
1585    /// The synchronous mode for fsync operations. When set to Off, fsync is skipped.
1586    sync_mode: SyncMode,
1587    _phantom: PhantomData<Clock>,
1588}
1589
1590impl<Clock: LogicalClock, A: ConcurrentAllocator> Debug for CommitStateMachine<Clock, A> {
1591    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1592        f.debug_struct("CommitStateMachine")
1593            .field("state", &self.state)
1594            .field("is_finalized", &self.is_finalized)
1595            .finish()
1596    }
1597}
1598
1599impl<Clock: LogicalClock, A: ConcurrentAllocator> Drop for CommitStateMachine<Clock, A> {
1600    fn drop(&mut self) {
1601        self.cleanup_unfinished_commit();
1602    }
1603}
1604
1605pub struct WriteRowStateMachine {
1606    state: WriteRowState,
1607    is_finalized: bool,
1608    row: Row,
1609    record: Option<ImmutableRecord>,
1610    cursor: Arc<RwLock<BTreeCursor>>,
1611    requires_seek: bool,
1612}
1613
1614#[derive(Debug)]
1615pub enum DeleteRowState {
1616    Initial,
1617    Seek,
1618    /// After seek returns TryAdvance (key found in interior node, not leaf),
1619    /// advance the cursor to position it on the interior cell.
1620    Advance,
1621    Delete,
1622}
1623
1624pub struct DeleteRowStateMachine {
1625    state: DeleteRowState,
1626    is_finalized: bool,
1627    rowid: RowID,
1628    cursor: Arc<RwLock<BTreeCursor>>,
1629}
1630
1631impl<Clock: LogicalClock, A: ConcurrentAllocator> CommitStateMachine<Clock, A> {
1632    pub(crate) fn cleanup_mvcc_checkpoint_state(&mut self) {
1633        if let CommitState::Checkpoint { state_machine } = &mut self.state {
1634            let _ = state_machine
1635                .lock()
1636                .inner_mut()
1637                .cleanup_after_external_io_error(LimboError::InternalError(
1638                    "mvcc: cleanup_unfinished_commit".to_string(),
1639                ))
1640                .inspect_err(|e| tracing::error!("cleanup_after_external_io_error failed: {e}"));
1641        }
1642    }
1643
1644    #[allow(clippy::too_many_arguments)]
1645    fn new(
1646        state: CommitState<Clock, A>,
1647        tx_id: TxID,
1648        mvcc_store: Arc<MvStore<Clock, A>>,
1649        connection: Arc<Connection>,
1650        db_id: usize,
1651        commit_coordinator: Arc<CommitCoordinator>,
1652        header: Arc<RwLock<Option<DatabaseHeader>>>,
1653        sync_mode: SyncMode,
1654    ) -> Self {
1655        let pager = connection.pager.load().clone();
1656        // Use the connection's tx-level schema_did_change flag as the
1657        // single source of truth.  This flag is set by SetCookie(SchemaVersion)
1658        // which every DDL emits, so it covers all schema-changing operations
1659        // including ones that don't write to sqlite_schema (e.g. AddType
1660        // writing to __turso_internal_types for custom types).
1661        let schema_did_change_from_tx = matches!(
1662            connection.get_tx_state(),
1663            crate::connection::TransactionState::Write {
1664                schema_did_change: true
1665            }
1666        );
1667        Self {
1668            state,
1669            is_finalized: false,
1670            #[cfg(any(clt_turso_tests, injected_yields))]
1671            yield_instance_id: connection.next_yield_instance_id(),
1672            did_commit_schema_change: schema_did_change_from_tx,
1673            tx_id,
1674            mvcc_store,
1675            connection,
1676            db_id,
1677            commit_coordinator,
1678            pager,
1679            header,
1680            pending_log_append_bytes: None,
1681            sync_mode,
1682            _phantom: PhantomData,
1683        }
1684    }
1685
1686    fn cleanup_unfinished_commit(&mut self) {
1687        if !self.is_finalized {
1688            self.cleanup_mvcc_checkpoint_state();
1689            if self.pending_log_append_bytes.take().is_some() {
1690                if let Err(err) = self.mvcc_store.storage.discard_pending_log_write() {
1691                    tracing::error!("failed to discard pending MVCC logical-log write: {err}");
1692                }
1693            }
1694            if !matches!(self.state, CommitState::Checkpoint { .. }) {
1695                self.mvcc_store.cleanup_dropped_commit(
1696                    self.tx_id,
1697                    self.connection.as_ref(),
1698                    self.db_id,
1699                );
1700            }
1701            self.end_read_tx_for_db();
1702            if self.db_id == crate::MAIN_DB_ID {
1703                self.connection
1704                    .set_tx_state(crate::connection::TransactionState::None);
1705            }
1706        }
1707
1708        let tx_id = self.tx_id;
1709        let db_id = self.db_id;
1710        turso_assert!(
1711            self.mvcc_store.txs.get(&tx_id).is_none(),
1712            "MVCC tx should be removed from txs after a successful commit",
1713            { "tx_id": tx_id }
1714        );
1715        turso_assert!(
1716            !self.mvcc_store.is_exclusive_tx(&tx_id),
1717            "MVCC tx should not still hold the exclusive slot after a successful commit",
1718            { "tx_id": tx_id }
1719        );
1720        turso_assert!(
1721            self.connection.get_mv_tx_id_for_db(db_id) != Some(tx_id),
1722            "Connection should not still reference an MVCC tx after a successful commit",
1723            { "tx_id": tx_id, "db_id": db_id }
1724        );
1725    }
1726
1727    fn end_read_tx_for_db(&self) {
1728        if let Ok(pager) = self.connection.get_pager_from_database_index(&self.db_id) {
1729            pager.end_read_tx();
1730            return;
1731        }
1732        self.pager.end_read_tx();
1733    }
1734
1735    /// Validates commit-time write-write conflicts for one table row key.
1736    ///
1737    /// Returns [LimboError::WriteWriteConflict] when another transaction committed or is
1738    /// preparing a conflicting version according to first-committer-wins.
1739    fn check_rowid_for_conflicts(
1740        &self,
1741        rowid: &RowID,
1742        end_ts: u64,
1743        tx: &Transaction<A>,
1744        mvcc_store: &Arc<MvStore<Clock, A>>,
1745    ) -> Result<()> {
1746        let row_versions = mvcc_store.rows.get(rowid);
1747        if row_versions.is_none() {
1748            return Ok(());
1749        }
1750
1751        let row_versions = row_versions.unwrap();
1752        let row_versions = row_versions.value();
1753        let row_versions = row_versions.read();
1754
1755        self.check_version_conflicts(end_ts, tx, mvcc_store, &row_versions)?;
1756        Ok(())
1757    }
1758
1759    /// Validates commit-time write-write conflicts for one index key.
1760    ///
1761    /// Returns [LimboError::WriteWriteConflict] when another transaction committed or is
1762    /// preparing a conflicting index version according to first-committer-wins.
1763    fn check_index_for_conflicts(
1764        &self,
1765        rowid: &RowID,
1766        end_ts: u64,
1767        tx: &Transaction<A>,
1768        mvcc_store: &Arc<MvStore<Clock, A>>,
1769    ) -> Result<()> {
1770        let RowKey::Record(record) = &rowid.row_id else {
1771            panic!("invalid index row_id type, should be Record")
1772        };
1773        if !record.metadata.is_unique {
1774            // Skip indexes which are not unique or not primary key
1775            return Ok(());
1776        }
1777        // In SQLite, NULLs don't violate UNIQUE constraints - skip conflict check for keys containing NULL
1778        let num_indexed_cols = record.metadata.num_cols.saturating_sub(1); // exclude rowid column
1779        if record.contains_null(num_indexed_cols)? {
1780            return Ok(());
1781        }
1782
1783        // Create a prefix key with num_cols - 1 for range lookup.
1784        // Due to SortableIndexKey's Ord using min(num_cols), this key compares Equal
1785        // to all entries with the same indexed columns (regardless of rowid).
1786        let prefix_key = {
1787            let mut index_info = record.metadata.as_ref().clone();
1788            turso_assert!(index_info.has_rowid, "not supported yet without rowid");
1789            index_info.num_cols -= 1;
1790            SortableIndexKey {
1791                key: record.key.clone(),
1792                metadata: Arc::new(index_info),
1793            }
1794        };
1795
1796        let table_id = rowid.table_id;
1797        let index_rows = mvcc_store
1798            .index_rows
1799            .get(&table_id)
1800            .unwrap_or_else(|| panic!("expected index {table_id:?}"));
1801        let index_rows = index_rows.value();
1802
1803        // Use range to efficiently find all entries that match the prefix.
1804        // Since entries are ordered by Ord, all entries with the same indexed columns
1805        // are contiguous. We start from the prefix_key and stop when prefix no longer matches.
1806        for entry in index_rows.range::<SortableIndexKey, _>(&prefix_key..) {
1807            let other_key = entry.key();
1808            // Check if prefix still matches - if not, we've passed all matching entries
1809            if !record.matches_prefix(other_key, num_indexed_cols)? {
1810                break;
1811            }
1812            let row_versions = entry.value();
1813            let row_versions = row_versions.read();
1814            self.check_version_conflicts(end_ts, tx, mvcc_store, &row_versions)?;
1815        }
1816
1817        Ok(())
1818    }
1819
1820    /// Validates a single version chain against the current transaction's commit timestamp.
1821    ///
1822    /// This enforces snapshot-isolation conflict checks for both:
1823    /// 1. versions ended by concurrent commits (`end > tx.begin_ts`), and
1824    /// 2. live versions owned by concurrent transactions (state/ts tie-breaking).
1825    fn check_version_conflicts(
1826        &self,
1827        end_ts: u64,
1828        tx: &Transaction<A>,
1829        mvcc_store: &Arc<MvStore<Clock, A>>,
1830        row_versions: &[RowVersion],
1831    ) -> Result<()> {
1832        // Check for conflicts - iterate in reverse for faster early termination
1833        for version in row_versions.iter().rev() {
1834            // A row that we are trying to commit was deleted/updated by another
1835            // committed transaction after our begin timestamp. Even if that
1836            // version is now "ended", this is still a write-write conflict.
1837            if let Some(TxTimestampOrID::Timestamp(end_ts)) = version.end() {
1838                turso_assert!(
1839                    end_ts != tx.begin_ts,
1840                    "committed end_ts and begin_ts cannot be equal: txn timestamps are strictly monotonic"
1841                );
1842                if end_ts > tx.begin_ts {
1843                    return Err(LimboError::WriteWriteConflict);
1844                }
1845            }
1846
1847            // B-tree tombstones (begin: None, end: TxID) act as write locks.
1848            // When another transaction has created a tombstone to delete a
1849            // B-tree-resident row, that tombstone is effectively a write lock
1850            // on the row — same as Hekaton's End field. We must detect this
1851            // as a write-write conflict using the same state-based logic used
1852            // for begin: TxID checks below.
1853            if version.begin().is_none() {
1854                // Committed tombstones (end: Timestamp) are already handled by
1855                // the check above at lines 1070-1074. Here we only need to check
1856                // in-flight tombstones (end: TxID) from other transactions.
1857                if let Some(TxTimestampOrID::TxID(other_tx_id)) = version.end() {
1858                    if other_tx_id != self.tx_id {
1859                        let other_tx = mvcc_store.txs.get(&other_tx_id).expect(
1860                            "check_version_conflicts: tombstone end TxID not found in txn map",
1861                        );
1862                        let other_tx = other_tx.value();
1863                        match other_tx.state.load() {
1864                            TransactionState::Committed(_) => {
1865                                return Err(LimboError::WriteWriteConflict);
1866                            }
1867                            TransactionState::Preparing(other_end_ts) => {
1868                                if other_end_ts < end_ts {
1869                                    return Err(LimboError::WriteWriteConflict);
1870                                }
1871                            }
1872                            TransactionState::Active => {}
1873                            TransactionState::Aborted | TransactionState::Terminated => {}
1874                        }
1875                    }
1876                }
1877                // Tombstones have no meaningful begin field — skip begin checks
1878                continue;
1879            }
1880
1881            match version.end() {
1882                Some(TxTimestampOrID::Timestamp(end_ts)) => {
1883                    // Committed deletion. If end_ts > our begin_ts, the conflict
1884                    // would have been already caught earlier when we iterate through
1885                    // the row versions in reverse. If end_ts < our
1886                    // begin_ts, the deletion predates our snapshot — no conflict.
1887                    turso_assert!(
1888                        end_ts < tx.begin_ts,
1889                        "row version's end_ts cannot be greater than txns begin_ts"
1890                    );
1891                    continue;
1892                }
1893                Some(TxTimestampOrID::TxID(end_tx_id)) => {
1894                    // Deletion not yet finalized; the deleting transaction may still be in Preparing.
1895                    if end_tx_id == self.tx_id {
1896                        // We deleted this version ourselves, so it cannot conflict with our commit.
1897                        continue;
1898                    }
1899
1900                    match lookup_tx_state(
1901                        &mvcc_store.txs,
1902                        &mvcc_store.finalized_tx_states,
1903                        end_tx_id,
1904                    ) {
1905                        Some(TransactionState::Committed(committed_end_ts)) => {
1906                            turso_assert!(
1907                                committed_end_ts != tx.begin_ts,
1908                                "committed end_ts and begin_ts cannot be equal: txn timestamps are strictly monotonic"
1909                            );
1910                            if committed_end_ts > tx.begin_ts {
1911                                return Err(LimboError::WriteWriteConflict);
1912                            }
1913                            continue;
1914                        }
1915                        _ => {
1916                            // Deleting tx is Active, Preparing, Aborted, or gone.
1917                            // The deletion may not stick, so this version may still be live.
1918                            // Fall through to check begin for conflicts.
1919                        }
1920                    }
1921                }
1922                None => {
1923                    // No end — version is live. Fall through to check begin.
1924                }
1925            }
1926
1927            match version.begin() {
1928                Some(TxTimestampOrID::TxID(other_tx_id)) => {
1929                    // Skip our own version
1930                    if other_tx_id == self.tx_id {
1931                        continue;
1932                    }
1933                    // Another transaction's uncommitted version - check their state
1934                    match lookup_tx_state(
1935                        &mvcc_store.txs,
1936                        &mvcc_store.finalized_tx_states,
1937                        other_tx_id,
1938                    ) {
1939                        // Other tx already committed = conflict
1940                        Some(TransactionState::Committed(_)) => {
1941                            return Err(LimboError::WriteWriteConflict);
1942                        }
1943                        // Both preparing - compare end_ts (lower wins)
1944                        Some(TransactionState::Preparing(other_end_ts)) => {
1945                            if other_end_ts < end_ts {
1946                                // Other tx has lower end_ts, they win
1947                                return Err(LimboError::WriteWriteConflict);
1948                            }
1949                            // We have lower end_ts, we win - they'll abort when they validate
1950                        }
1951                        // Other tx still active - we're already Preparing so we're ahead
1952                        // They'll see us in Preparing/Committed when they try to commit
1953                        Some(TransactionState::Active) => {}
1954                        // Other tx aborted - no conflict
1955                        Some(TransactionState::Aborted) | Some(TransactionState::Terminated) => {}
1956                        None => {
1957                            // TODO: an aborted txn should not affect another one.. properly handle
1958                            // this case, but for now be conservative and treat as conflict to avoid
1959                            // potential correctness issues
1960                            tracing::debug!(
1961                                "check_version_conflicts: missing tx {} for row version {:?}; conservatively treating as conflict",
1962                                other_tx_id,
1963                                version
1964                            );
1965                            return Err(LimboError::WriteWriteConflict);
1966                        }
1967                    }
1968                }
1969                Some(TxTimestampOrID::Timestamp(begin_ts)) => {
1970                    // A live committed version with this rowid exists.
1971                    // begin_ts >= tx.begin_ts: a concurrent transaction committed a row
1972                    //   with this rowid after our snapshot — invisible to NotExists.
1973                    // begin_ts < tx.begin_ts: the row predates our snapshot. NotExists
1974                    //   should have seen it at INSERT time, so this is a defensive guard.
1975                    let _ = begin_ts;
1976                    return Err(LimboError::WriteWriteConflict);
1977                }
1978                None => {
1979                    // Invalid version
1980                }
1981            }
1982        }
1983        Ok(())
1984    }
1985
1986    /// Run one chunked step of `BuildLogRecord`. Processes up to
1987    /// `MVCC_COMMIT_BATCH_SIZE` rowids per call, then yields. Schema rows
1988    /// (table_id == SQLITE_SCHEMA_MVCC_TABLE_ID) are emitted before data rows
1989    /// in two passes so that log replay sees CREATE TABLE before INSERTs.
1990    fn step_build_log_record(
1991        &mut self,
1992        mvcc_store: &Arc<MvStore<Clock, A>>,
1993    ) -> Result<TransitionResult<()>> {
1994        // First entry into BuildLogRecord (no chunk processed yet): a yield-
1995        // point to bracket the chunked yields so tests can count them exactly.
1996        // Must run before the `&mut self.state` re-bind below — the macro
1997        // calls `self.yield_context()` which needs `&self`.
1998        let is_first_entry = matches!(
1999            self.state,
2000            CommitState::BuildLogRecord(BuildLogRecordCtx {
2001                cursor: 0,
2002                schema_process: true,
2003                ..
2004            })
2005        );
2006        if is_first_entry {
2007            inject_transition_yield!(self, CommitYieldPoint::BuildLogRecordStart);
2008        }
2009
2010        let tx_id = self.tx_id;
2011        let tx_entry = mvcc_store.txs.get(&tx_id);
2012        let tx = tx_entry
2013            .as_ref()
2014            .map(|entry| entry.value())
2015            .ok_or_else(|| {
2016                LimboError::NoSuchTransactionID(format!(
2017                    "tx id {tx_id} not found in step_build_logical_record"
2018                ))
2019            })?;
2020        let write_set_len = tx.write_set.lock().entries.len();
2021        #[cfg(clt_turso_feature = "conn_raw_api")]
2022        let connection = Arc::clone(&self.connection);
2023        let CommitState::BuildLogRecord(ctx) = &mut self.state else {
2024            unreachable!("step_build_log_record requires BuildLogRecord state")
2025        };
2026        let end_ts = ctx.end_ts;
2027
2028        // A sqlite_schema row that is inserted and deleted inside one
2029        // transaction does not always mean the underlying table or index was
2030        // inserted and deleted. ALTER TABLE can rewrite sqlite_schema several
2031        // times for an existing root page. We only treat a root page as
2032        // transaction-local when it has no schema row before the transaction and
2033        // no schema row after it.
2034
2035        let is_our_begin = |row_version: &RowVersion| {
2036            matches!(
2037                row_version.begin(),
2038                Some(TxTimestampOrID::TxID(vid)) if vid == tx_id
2039            )
2040        };
2041        let is_our_end = |row_version: &RowVersion| {
2042            matches!(
2043                row_version.end(),
2044                Some(TxTimestampOrID::TxID(vid)) if vid == tx_id
2045            )
2046        };
2047
2048        let mut btree_ids_created_and_dropped_in_tx: HashSet<MVTableId> = HashSet::default();
2049        let mut btree_ids_removed_from_schema_by_tx: HashSet<MVTableId> = HashSet::default();
2050        {
2051            let write_set = tx.write_set.lock();
2052            let frame_writes_schema = write_set
2053                .entries
2054                .iter()
2055                .any(|(id, _)| id.table_id == SQLITE_SCHEMA_MVCC_TABLE_ID);
2056
2057            // Decoding sqlite_schema records is only needed for DDL frames.
2058            // Normal DML frames do not touch sqlite_schema, so avoid parsing
2059            // schema records while committing the common path.
2060            if frame_writes_schema {
2061                let mut schema_roots_created_and_deleted_in_tx: HashSet<i64> = HashSet::default();
2062                let mut schema_roots_deleted_by_tx: HashSet<i64> = HashSet::default();
2063                let mut schema_roots_present_before_tx: HashSet<i64> = HashSet::default();
2064                let mut schema_roots_present_after_tx: HashSet<i64> = HashSet::default();
2065
2066                for (id, row_versions) in &write_set.entries {
2067                    // Only sqlite_schema entries can yield a btree identity. The
2068                    // common path (CREATE INDEX on a populated table, bulk DML)
2069                    // has tens of thousands of write_set entries that are NOT
2070                    // sqlite_schema; locking each and parsing every version was
2071                    // the dominant cost of MVCC commit on this branch.
2072                    if id.table_id != SQLITE_SCHEMA_MVCC_TABLE_ID {
2073                        continue;
2074                    }
2075                    for row_version in row_versions.read().iter() {
2076                        let Some(identity) = sqlite_schema_btree_identity(row_version) else {
2077                            continue;
2078                        };
2079                        let our_begin = is_our_begin(row_version);
2080                        let our_end = is_our_end(row_version);
2081                        if !our_begin {
2082                            schema_roots_present_before_tx.insert(identity.root_page);
2083                        }
2084                        if our_end {
2085                            schema_roots_deleted_by_tx.insert(identity.root_page);
2086                        }
2087                        if !our_end {
2088                            schema_roots_present_after_tx.insert(identity.root_page);
2089                        }
2090                        if our_begin && our_end && !row_version.btree_resident {
2091                            schema_roots_created_and_deleted_in_tx.insert(identity.root_page);
2092                        }
2093                    }
2094                }
2095
2096                for root_page in schema_roots_deleted_by_tx {
2097                    if !schema_roots_present_after_tx.contains(&root_page) {
2098                        btree_ids_removed_from_schema_by_tx
2099                            .insert(mvcc_store.get_table_id_from_root_page(root_page));
2100                    }
2101                }
2102
2103                for root_page in schema_roots_created_and_deleted_in_tx {
2104                    if !schema_roots_present_before_tx.contains(&root_page)
2105                        && !schema_roots_present_after_tx.contains(&root_page)
2106                    {
2107                        btree_ids_created_and_dropped_in_tx
2108                            .insert(mvcc_store.get_table_id_from_root_page(root_page));
2109                    }
2110                }
2111            }
2112        }
2113
2114        // Remap a table_id to its canonical form for the log. After checkpoint,
2115        // a table's in-memory table_id (e.g. -53) may differ from -(root_page)
2116        // (e.g. -58). On recovery, bootstrap reconstructs the map using
2117        // -(root_page), so log records must use that canonical form to be found.
2118        let canonicalize_table_id = |version: &mut RowVersion| {
2119            let table_id = version.row.id.table_id;
2120            if table_id == SQLITE_SCHEMA_MVCC_TABLE_ID {
2121                return;
2122            }
2123            if let Some(entry) = mvcc_store.table_id_to_rootpage.get(&table_id) {
2124                if let Some(root_page) = entry.value().root_page {
2125                    let canonical = MVTableId::from(-(root_page as i64));
2126                    if canonical != table_id {
2127                        version.row.id.table_id = canonical;
2128                    }
2129                }
2130            }
2131        };
2132
2133        let collect_versions = |row_versions: &RowVersions<A>,
2134                                log_record: &mut LogRecord|
2135         -> Result<()> {
2136            // `log_record.row_versions` is the logical transaction log. Recovery
2137            // replays it in this order. `insert_version_raw` is for the versions
2138            // of one MVCC entry: one table row, one sqlite_schema row, or one
2139            // index entry. Its timestamp sort is correct for that one entry, but
2140            // it is not a rule for ordering the whole transaction.
2141            //
2142            // Example: the db file already has table t, index idx, and
2143            // t(rowid=1). One transaction runs `DELETE FROM t`, then
2144            // `ALTER TABLE t ADD COLUMN x`. ALTER TABLE records a DELETE for the
2145            // old sqlite_schema row for t and an UPSERT for the replacement row.
2146            // DELETE FROM t records a DELETE for t(rowid=1) and a DELETE_INDEX
2147            // for the idx entry that pointed at that row. The schema DELETE,
2148            // table-row DELETE, and DELETE_INDEX are all for entries already in
2149            // the db file, so their `begin` is None. Sorting the whole
2150            // transaction with `insert_version_raw` can put those three deletes
2151            // before the schema UPSERT:
2152            // `[DELETE schema(t), DELETE t(rowid=1), DELETE_INDEX idx(rowid=1),
2153            //   UPSERT schema(t)]`.
2154            //
2155            // Index log ops contain serialized index keys, not CREATE INDEX SQL.
2156            // Recovery now decodes every index op using schema snapshots for the
2157            // whole transaction frame, so it does not install a half-updated
2158            // schema while the frame is still being replayed. The writer still
2159            // must not scramble a sqlite_schema DELETE+UPSERT pair with unrelated
2160            // table/index entries; keeping each write-set entry together gives
2161            // recovery a frame whose final schema can be understood.
2162            //
2163            // The filtering below has four separate jobs:
2164            //
2165            // 1. Omit all entries for a table/index root page that had no
2166            //    sqlite_schema row before this transaction and has no
2167            //    sqlite_schema row after it. Example: CREATE INDEX followed by
2168            //    DROP INDEX in one transaction.
2169            // 2. Omit inserts and updates for a table/index root page that is
2170            //    removed from sqlite_schema by this transaction. Example:
2171            //    UPDATE writes a new entry into idx_old, then DROP INDEX
2172            //    idx_old runs before COMMIT. The old index-entry deletes still
2173            //    matter, but new entries for idx_old cannot survive the frame.
2174            // 3. Omit one version that was created and deleted by this
2175            //    transaction before it reached the database file. Such a version
2176            //    does not change durable state, and recovery may not have enough
2177            //    schema information to decode a delete for it.
2178            // 4. If this entry has a delete for the row that already existed in
2179            //    the database file, do not also log a same-transaction
2180            //    create/delete replacement for the same write-set entry. Example:
2181            //    ALTER TABLE rewrites an index sqlite_schema row, then DROP INDEX
2182            //    deletes that replacement in the same transaction. The durable
2183            //    change is one delete of the original sqlite_schema row.
2184            //
2185            // What the code below does after that filtering:
2186            // - look only at this one write-set entry (`row_versions`);
2187            // - copy the versions written or ended by the committing transaction;
2188            // - if this same entry appears twice with the same
2189            //   `begin=Timestamp(...)`, keep the later one, because recovery should
2190            //   not replay an intermediate value for the same table row,
2191            //   sqlite_schema row, or index entry;
2192            // - append those versions to `log_record.row_versions` without sorting
2193            //   them against versions from other write-set entries.
2194
2195            let mut entry_versions: Vec<RowVersion> = Vec::new();
2196            let row_versions = row_versions.read();
2197
2198            // A tombstone over a row that was already in the B-tree before this
2199            // tx (begin=None, end=tx_id, btree_resident=true) is the canonical
2200            // log record for deleting that durable row. If the same entry also
2201            // contains a version this tx both began and ended over a B-tree row
2202            // (e.g. DELETE; INSERT; DELETE on a btree-resident rowid), the
2203            // tombstone already covers the durable delete and the begun+ended
2204            // version must be suppressed to avoid logging the same delete twice.
2205            let has_tombstone_for_btree_row = row_versions.iter().any(|row_version| {
2206                !is_our_begin(row_version) && is_our_end(row_version) && row_version.btree_resident
2207            });
2208
2209            // Helper that returns Some(row_version) if our tx contributed to it and if we must therefore log it.
2210            let our_committed_image = |row_version: &RowVersion| -> Option<RowVersion> {
2211                let our_begin = is_our_begin(row_version);
2212                let our_end = is_our_end(row_version);
2213                if !our_begin && !our_end {
2214                    // row_version belongs to another tx
2215                    return None;
2216                }
2217                if btree_ids_created_and_dropped_in_tx.contains(&row_version.row.id.table_id) {
2218                    // This table or index has no sqlite_schema row before the
2219                    // transaction and no sqlite_schema row after it. It was created
2220                    // and dropped inside this commit, so its table/index entries do
2221                    // not change durable state.
2222                    return None;
2223                }
2224                if btree_ids_removed_from_schema_by_tx.contains(&row_version.row.id.table_id)
2225                    && our_begin
2226                    && !our_end
2227                {
2228                    // This transaction created or rewrote an entry for a table or
2229                    // index that is gone from sqlite_schema by COMMIT. Example:
2230                    // UPDATE writes a new entry into idx_old, then DROP INDEX
2231                    // idx_old runs before COMMIT. Keep deletes for entries that
2232                    // existed before the transaction, but do not log new entries
2233                    // that cannot exist after the transaction.
2234                    return None;
2235                }
2236                if our_begin && our_end {
2237                    // A begun+ended version is purely in-memory unless it
2238                    // shadows a B-tree row (insert_btree_resident_to_table_or_index
2239                    // then delete). For btree_resident=true we still need to log
2240                    // the delete of the durable row, UNLESS a sibling tombstone
2241                    // in this same entry already covers it.
2242                    if !row_version.btree_resident || has_tombstone_for_btree_row {
2243                        return None;
2244                    }
2245                }
2246
2247                let mut committed = row_version.clone();
2248                if our_begin {
2249                    // New version is valid STARTING FROM the committing
2250                    // transaction's end timestamp. See Hekaton page 299.
2251                    committed.set_begin(Some(TxTimestampOrID::Timestamp(end_ts)));
2252
2253                    if !our_end {
2254                        // A version row_version we inserted may have row_version.end() == tx_b.tx_id,
2255                        // where tx_b is a concurrent tx. This is because when a tx transitions to
2256                        // Preparing, its row_version becomes *speculatively updatable*, and a tx tx_b
2257                        // is allowed to change row_version.end() from None to tx_b.tx_id to delete it
2258                        // (see the Hekaton paper, §3.1, heading "check updatability").
2259                        //
2260                        // That deletion is tx_b's contribution, and tx_b will log it on its own commit.
2261                        // Our log record must capture our own contribution, but if the `end` field is
2262                        // set, it will be serialized as a OP_DELETE_* in the logical log, so we unset
2263                        // it so that it will be serialized as an OP_UPSERT_*. tx_b will take care of
2264                        // logging the deletion.
2265                        committed.set_end(None);
2266                    }
2267                }
2268                if our_end {
2269                    // Old version is valid UNTIL the committing
2270                    // transaction's end timestamp. See Hekaton page 299.
2271                    committed.set_end(Some(TxTimestampOrID::Timestamp(end_ts)));
2272                }
2273                Some(committed)
2274            };
2275
2276            for row_version in row_versions.iter() {
2277                let Some(mut committed_version) = our_committed_image(row_version) else {
2278                    continue;
2279                };
2280                canonicalize_table_id(&mut committed_version);
2281                let is_btree_resident_delete_marker =
2282                    |version: &RowVersion| version.btree_resident && version.end().is_some();
2283                let replaces_last = entry_versions.last().is_some_and(|last| {
2284                    last.row.id == committed_version.row.id
2285                        && !is_btree_resident_delete_marker(last)
2286                        && matches!(
2287                            (&last.begin(), &committed_version.begin()),
2288                            (
2289                                Some(TxTimestampOrID::Timestamp(existing)),
2290                                Some(TxTimestampOrID::Timestamp(new))
2291                            ) if existing == new
2292                        )
2293                });
2294                if replaces_last {
2295                    *entry_versions
2296                        .last_mut()
2297                        .expect("last version checked above") = committed_version;
2298                    continue;
2299                }
2300                #[cfg(debug_assertions)]
2301                {
2302                    let same_row_and_begin = |existing: &RowVersion| {
2303                        existing.row.id == committed_version.row.id
2304                            && !is_btree_resident_delete_marker(existing)
2305                            && !is_btree_resident_delete_marker(&committed_version)
2306                            && matches!(
2307                                (&existing.begin(), &committed_version.begin()),
2308                                (
2309                                    Some(TxTimestampOrID::Timestamp(existing)),
2310                                    Some(TxTimestampOrID::Timestamp(new))
2311                                ) if existing == new
2312                            )
2313                    };
2314                    turso_assert!(
2315                        !entry_versions.iter().any(same_row_and_begin),
2316                        "one write-set entry produced non-adjacent log versions with the same row id and commit timestamp"
2317                    );
2318                }
2319                entry_versions.push(committed_version);
2320            }
2321            for committed_version in &entry_versions {
2322                #[cfg(clt_turso_feature = "conn_raw_api")]
2323                let portable_extension = portable_delete_op_extension_for_row_version(
2324                    &connection,
2325                    mvcc_store,
2326                    committed_version,
2327                )?;
2328                #[cfg(not(clt_turso_feature = "conn_raw_api"))]
2329                let portable_extension: Option<Vec<u8>> = None;
2330                mvcc_store.storage.serialize_row_version(
2331                    log_record,
2332                    committed_version,
2333                    portable_extension.as_deref(),
2334                )?;
2335            }
2336            Ok(())
2337        };
2338
2339        // Process schema rows (sqlite_schema) before data/index rows so that
2340        // replay sees table_id_to_rootpage updates before row ops reference
2341        // those ids. `tx.write_set` preserves first-touch order, so mixed DDL
2342        // and DML in one transaction cannot rely on write-set order alone.
2343        let mut iterations = 0;
2344
2345        let write_set = tx.write_set.lock();
2346        while ctx.cursor < write_set_len && iterations < MVCC_COMMIT_BATCH_SIZE {
2347            let (id, row_versions) = &write_set.entries[ctx.cursor];
2348            let is_schema = id.table_id == SQLITE_SCHEMA_MVCC_TABLE_ID;
2349            // schema_process=true: schema rows only, false: data rows only.
2350            let process = if ctx.schema_process {
2351                is_schema
2352            } else {
2353                !is_schema
2354            };
2355            if process {
2356                collect_versions(row_versions, &mut ctx.log_record)?;
2357            }
2358            ctx.cursor += 1;
2359            iterations += 1;
2360        }
2361        if ctx.cursor < write_set_len {
2362            // More work remains in the current pass: yield and resume.
2363            return Ok(TransitionResult::Io(IOCompletions::Single(
2364                Completion::new_yield(),
2365            )));
2366        }
2367
2368        if ctx.schema_process {
2369            // Schema pass done; start the data pass from the top.
2370            ctx.schema_process = false;
2371            ctx.cursor = 0;
2372            return Ok(TransitionResult::Continue);
2373        }
2374
2375        if let Some(header) = ctx.pending_header.take() {
2376            mvcc_store
2377                .storage
2378                .serialize_database_header(&mut ctx.log_record, &header)?;
2379        }
2380
2381        // Move the assembled log record out and transition to
2382        // BeginCommitLogicalLog (or directly to CommitEnd if there is nothing
2383        // to log).
2384        let mut log_record = std::mem::replace(&mut ctx.log_record, LogRecord::new(end_ts));
2385        self.populate_portable_changes(mvcc_store, &mut log_record)?;
2386        tracing::trace!("prepared_log_record(tx_id={})", self.tx_id);
2387
2388        if log_record.is_empty() {
2389            // Nothing to log. We still need to release the commit lock here
2390            // if this is an exclusive tx, mirroring the pre-chunk path
2391            // through WaitForDependencies.
2392            if mvcc_store.is_exclusive_tx(&self.tx_id) {
2393                if let Some(tx_entry) = mvcc_store.txs.get(&self.tx_id) {
2394                    mvcc_store.unlock_commit_lock_if_held(tx_entry.value());
2395                }
2396            }
2397            self.state = CommitState::CommitEnd { end_ts };
2398        } else {
2399            self.state = CommitState::BeginCommitLogicalLog { end_ts, log_record };
2400        }
2401        inject_transition_yield!(self, CommitYieldPoint::LogRecordPrepared);
2402        Ok(TransitionResult::Continue)
2403    }
2404
2405    fn populate_portable_changes(
2406        &self,
2407        mvcc_store: &Arc<MvStore<Clock, A>>,
2408        log_record: &mut LogRecord,
2409    ) -> Result<()> {
2410        #[cfg(not(clt_turso_feature = "conn_raw_api"))]
2411        {
2412            let _ = mvcc_store;
2413            let _ = log_record;
2414            Ok(())
2415        }
2416
2417        #[cfg(clt_turso_feature = "conn_raw_api")]
2418        {
2419            if !self.connection.portable_logical_changes_enabled() {
2420                return Ok(());
2421            }
2422            log_record.portable_changes_enabled = true;
2423
2424            let mut builder = PortableLogicalBuilder::new();
2425            let mut metadata: Vec<_> = self
2426                .connection
2427                .mvcc_log_meta_snapshot()
2428                .into_iter()
2429                .collect();
2430            metadata.sort_by(|a, b| a.0.cmp(&b.0));
2431            for (key, value) in metadata {
2432                builder.add_metadata(&key, &value);
2433            }
2434
2435            // The recovery payload is the single durable operation stream.
2436            // The portable extension only adds the metadata required to interpret
2437            // recovery table ids outside this database instance.
2438            let mut table_refs_by_id = HashMap::default();
2439            let recovery_payload = &log_record.buf[LOG_RECORD_PREFIX_SIZE..];
2440            let parsed_ops = parse_ops_from_plaintext(
2441                recovery_payload,
2442                recovery_payload.len(),
2443                log_record.op_count,
2444                log_record.tx_timestamp,
2445            )?;
2446
2447            let mut schema_upserts = HashMap::default();
2448            let mut schema_deletes = HashMap::default();
2449            let mut schema_rowids = Vec::new();
2450            let mut data_table_ids = HashSet::default();
2451            let mut has_portable_schema_changes = false;
2452            for op in &parsed_ops {
2453                match op {
2454                    ParsedOp::UpsertTable {
2455                        table_id,
2456                        rowid,
2457                        record_bytes,
2458                        ..
2459                    } if *table_id == SQLITE_SCHEMA_MVCC_TABLE_ID => {
2460                        let rowid = rowid.row_id.to_int_or_panic();
2461                        let row = portable_schema_row_from_record(record_bytes)?;
2462                        has_portable_schema_changes |= is_portable_schema_row(&row);
2463                        schema_rowids.push(rowid);
2464                        schema_upserts.insert(rowid, row);
2465                    }
2466                    ParsedOp::DeleteTable {
2467                        rowid,
2468                        record_bytes,
2469                        ..
2470                    } if rowid.table_id == SQLITE_SCHEMA_MVCC_TABLE_ID => {
2471                        if record_bytes.is_empty() {
2472                            return Err(LimboError::Corrupt(
2473                                "sqlite_schema DELETE_TABLE missing old record".to_string(),
2474                            ));
2475                        }
2476                        let schema_rowid = rowid.row_id.to_int_or_panic();
2477                        let row = portable_schema_row_from_record(record_bytes)?;
2478                        has_portable_schema_changes |= is_portable_schema_row(&row);
2479                        schema_rowids.push(schema_rowid);
2480                        schema_deletes.insert(schema_rowid, row);
2481                    }
2482                    ParsedOp::UpsertTable { table_id, .. } => {
2483                        data_table_ids.insert(*table_id);
2484                    }
2485                    ParsedOp::DeleteTable { rowid, .. } => {
2486                        if rowid.table_id != SQLITE_SCHEMA_MVCC_TABLE_ID {
2487                            data_table_ids.insert(rowid.table_id);
2488                        }
2489                    }
2490                    ParsedOp::UpsertIndex { .. }
2491                    | ParsedOp::DeleteIndex { .. }
2492                    | ParsedOp::UpdateHeader { .. } => {}
2493                }
2494            }
2495
2496            schema_rowids.sort_unstable();
2497            schema_rowids.dedup();
2498            for rowid in schema_rowids {
2499                let old_row = schema_deletes.get(&rowid);
2500                let new_row = schema_upserts.get(&rowid);
2501                match (old_row, new_row) {
2502                    (Some(old_row), Some(new_row)) => {
2503                        if is_portable_table_schema_row(old_row) {
2504                            table_refs_by_id.insert(
2505                                portable_table_id_from_rootpage(old_row.rootpage),
2506                                PortableTableRef {
2507                                    name: old_row.name.clone(),
2508                                },
2509                            );
2510                        }
2511                        if is_portable_table_schema_row(new_row) {
2512                            table_refs_by_id.insert(
2513                                portable_table_id_from_rootpage(new_row.rootpage),
2514                                PortableTableRef {
2515                                    name: new_row.name.clone(),
2516                                },
2517                            );
2518                        }
2519                    }
2520                    (None, Some(new_row)) => {
2521                        if is_portable_table_schema_row(new_row) {
2522                            table_refs_by_id.insert(
2523                                portable_table_id_from_rootpage(new_row.rootpage),
2524                                PortableTableRef {
2525                                    name: new_row.name.clone(),
2526                                },
2527                            );
2528                        }
2529                    }
2530                    (Some(old_row), None) => {
2531                        if is_portable_table_schema_row(old_row) {
2532                            table_refs_by_id.insert(
2533                                portable_table_id_from_rootpage(old_row.rootpage),
2534                                PortableTableRef {
2535                                    name: old_row.name.clone(),
2536                                },
2537                            );
2538                        }
2539                    }
2540                    (None, None) => {}
2541                }
2542            }
2543
2544            let rootpage_for_table_id = |table_id: MVTableId| -> i64 {
2545                mvcc_store
2546                    .table_id_to_rootpage
2547                    .get(&table_id)
2548                    .and_then(|entry| entry.value().root_page)
2549                    .map(|rootpage| rootpage as i64)
2550                    .unwrap_or_else(|| i64::from(table_id))
2551            };
2552
2553            let mut unresolved_data_tables = Vec::new();
2554            let mut needed_rootpages = HashSet::default();
2555            for table_id in &data_table_ids {
2556                if table_refs_by_id.contains_key(table_id) {
2557                    continue;
2558                }
2559                let rootpage = rootpage_for_table_id(*table_id);
2560                if rootpage == 0 {
2561                    continue;
2562                }
2563                let root_table_id = portable_table_id_from_rootpage(rootpage);
2564                if let Some(table_ref) = table_refs_by_id.get(&root_table_id).cloned() {
2565                    table_refs_by_id.insert(*table_id, table_ref);
2566                    continue;
2567                }
2568                needed_rootpages.insert(rootpage);
2569                unresolved_data_tables.push((*table_id, root_table_id));
2570            }
2571
2572            if !needed_rootpages.is_empty() {
2573                for rootpage in needed_rootpages {
2574                    let Some(name) = table_name_for_rootpage(&self.connection, rootpage)
2575                        .or_else(|| table_name_for_rootpage_in_mvcc_schema(mvcc_store, rootpage))
2576                    else {
2577                        continue;
2578                    };
2579                    let resolved_rootpage = if rootpage < 0 { -rootpage } else { rootpage };
2580                    let resolved_table_id = portable_table_id_from_rootpage(resolved_rootpage);
2581                    let table_ref = PortableTableRef { name };
2582                    table_refs_by_id.insert(resolved_table_id, table_ref);
2583                }
2584
2585                for (table_id, root_table_id) in unresolved_data_tables {
2586                    if let Some(table_ref) = table_refs_by_id.get(&root_table_id).cloned() {
2587                        table_refs_by_id.insert(table_id, table_ref);
2588                    }
2589                }
2590            }
2591
2592            for (table_id, table_ref) in &table_refs_by_id {
2593                if !is_portable_logical_name(&table_ref.name) {
2594                    continue;
2595                }
2596                let added = builder.add_object_map(PortableObjectMapEntry {
2597                    mv_table_id: i64::from(*table_id),
2598                    name: &table_ref.name,
2599                });
2600                turso_assert!(
2601                    added,
2602                    "portable object map unexpectedly rejected a user object"
2603                );
2604            }
2605
2606            for table_id in data_table_ids {
2607                let Some(table_ref) = table_refs_by_id.get(&table_id) else {
2608                    return Err(LimboError::Corrupt(format!(
2609                        "portable changes cannot resolve user data table id {table_id}"
2610                    )));
2611                };
2612                if !is_portable_logical_name(&table_ref.name) {
2613                    continue;
2614                }
2615            }
2616
2617            log_record.portable_changes = builder.finish();
2618            log_record.portable_changes_required = has_portable_schema_changes;
2619            Ok(())
2620        }
2621    }
2622
2623    /// Run one chunked step of `RewriteLiveVersions`. Processes up to
2624    /// `MVCC_COMMIT_BATCH_SIZE` rowids per call, then yields. The transaction
2625    /// is already in the Committed state at this point; un-rewritten TxID
2626    /// references resolve via `txs[tx_id]` for visibility/conflict checks.
2627    fn step_rewrite_live_versions(
2628        &mut self,
2629        mvcc_store: &Arc<MvStore<Clock, A>>,
2630    ) -> Result<TransitionResult<()>> {
2631        let tx_id = self.tx_id;
2632        let tx_entry = mvcc_store.txs.get(&tx_id);
2633        let tx = tx_entry
2634            .as_ref()
2635            .map(|entry| entry.value())
2636            .ok_or_else(|| {
2637                LimboError::NoSuchTransactionID(format!(
2638                    "tx id {tx_id} not found in step_build_logical_record"
2639                ))
2640            })?;
2641        let write_set = tx.write_set.lock();
2642        let write_set_len = write_set.entries.len();
2643        let CommitState::RewriteLiveVersions(ctx) = &mut self.state else {
2644            unreachable!("step_rewrite_live_versions requires RewriteLiveVersions state")
2645        };
2646        let end_ts = ctx.end_ts;
2647        if ctx.cursor == 0 {
2648            let tx_state = mvcc_store
2649                .txs
2650                .get(&tx_id)
2651                .map(|entry| entry.value().state.load());
2652            turso_assert!(
2653                matches!(tx_state, Some(TransactionState::Committed(ts)) if ts == end_ts),
2654                "RewriteLiveVersions requires a committed transaction state"
2655            );
2656        }
2657        let mut iterations = 0;
2658        while ctx.cursor < write_set_len && iterations < MVCC_COMMIT_BATCH_SIZE {
2659            let (_id, row_versions) = &write_set.entries[ctx.cursor];
2660            let mut row_versions = row_versions.write();
2661            for row_version in row_versions.iter_mut() {
2662                row_version.rewrite_txid_to_timestamp(tx_id, end_ts);
2663            }
2664            ctx.cursor += 1;
2665            iterations += 1;
2666        }
2667        if ctx.cursor < write_set_len {
2668            return Ok(TransitionResult::Io(IOCompletions::Single(
2669                Completion::new_yield(),
2670            )));
2671        }
2672        self.state = CommitState::FinalizeCommit { end_ts };
2673        Ok(TransitionResult::Continue)
2674    }
2675}
2676
2677impl WriteRowStateMachine {
2678    fn new(row: Row, cursor: Arc<RwLock<BTreeCursor>>, requires_seek: bool) -> Self {
2679        Self {
2680            state: WriteRowState::Initial,
2681            is_finalized: false,
2682            row,
2683            record: None,
2684            cursor,
2685            requires_seek,
2686        }
2687    }
2688}
2689
2690impl<Clock: LogicalClock, A: ConcurrentAllocator> StateTransition for CommitStateMachine<Clock, A> {
2691    type Context = Arc<MvStore<Clock, A>>;
2692    type SMResult = ();
2693
2694    #[tracing::instrument(fields(state = ?self.state), skip(self, mvcc_store), level = Level::DEBUG)]
2695    fn step(&mut self, mvcc_store: &Self::Context) -> Result<TransitionResult<Self::SMResult>> {
2696        tracing::trace!("step(state={:?})", self.state);
2697        match &self.state {
2698            CommitState::Initial => {
2699                // NOTICE: the first shadowed tx keeps the entry alive in the map
2700                // for the duration of this whole function, which is important for correctness!
2701                let tx = mvcc_store
2702                    .txs
2703                    .get(&self.tx_id)
2704                    .ok_or(LimboError::TxTerminated)?;
2705                let tx = tx.value();
2706                match tx.state.load() {
2707                    TransactionState::Terminated => {
2708                        return Err(LimboError::TxTerminated);
2709                    }
2710                    _ => {
2711                        turso_assert_eq!(tx.state, TransactionState::Active);
2712                    }
2713                }
2714
2715                // Atomically generate end_ts and publish Preparing(end_ts) while the
2716                // clock lock is held. This closes the TOCTOU window
2717                // Consider the example:
2718                //
2719                // tx1 (Active): get_ts for end - 10
2720                // tx2 (Active): got begin_ts - 11
2721                // tx2 (Active): does queries but does not see changes by tx1
2722                // tx1 (Preparing): now stores `end_ts(10)`
2723                // tx2 (Active): queries again, but now it can see changes by tx1
2724                //
2725                // hence we want to guard the timestamp generation by a mutex, only allow next
2726                // ts to generate when the previous one is used / discarded
2727
2728                let write_set_is_empty = tx.write_set.lock().is_empty();
2729                let header_write = tx.header_dirty.load(Ordering::Acquire);
2730                // Read only is not only exclusive to empty write set, we could be writing the
2731                // database header here.
2732                let read_only = write_set_is_empty && !header_write;
2733
2734                let mut schema_conflict = false;
2735                let mut exclusive_conflict = false;
2736
2737                let end_ts = mvcc_store.get_commit_timestamp(|ts| {
2738                    turso_assert!(
2739                        ts > tx.begin_ts,
2740                        "end_ts must be strictly greater than begin_ts"
2741                    );
2742
2743                    // First we check if there is exclusive conflict, if there is then we won't
2744                    // commit txn.
2745                    if !mvcc_store.is_exclusive_tx(&self.tx_id) && mvcc_store.has_exclusive_tx() {
2746                        // A non-CONCURRENT transaction is holding the exclusive lock, we must abort.
2747                        turso_assert_reachable!("commit aborted due to exclusive tx conflict");
2748                        exclusive_conflict = true;
2749                    }
2750                    // Now check if we saw schema change which would require reprepare. Let's note
2751                    // that we check this right after exlusive tx because we update this before we release
2752                    // exclusive lock.
2753                    let schema_updated = mvcc_store
2754                        .last_committed_schema_change_ts
2755                        .load(Ordering::Acquire)
2756                        > tx.begin_ts;
2757                    // last_committed_schema_ts is not enough, we need to check schema cookie
2758                    // is the same because e.g:
2759                    // T1 CREATE INDEX
2760                    // T1 Yield somewhere in middle of commit
2761                    // T1 end_ts = x
2762                    // T2 BEGIN CONCURRENT; INSERT (same table); COMMIT
2763                    // T2 begin_ts = x+1
2764                    // T2 begin_ts > end_ts
2765                    //
2766                    // Therefore even if exclusive tx (T1) finishes right in time
2767                    // we will see schema was not updated because we see an younger timestamp and
2768                    // ours is older!
2769                    //
2770                    let our_cookie = tx.header.read().schema_cookie.get();
2771                    let global_cookie = {
2772                        let h = mvcc_store.global_header.read();
2773                        let h = h.as_ref();
2774                        turso_assert!(h.is_some(), "global_header should be initialized");
2775                        h.unwrap().schema_cookie.get()
2776                    };
2777
2778                    let header_dirty = tx.header_dirty.load(Ordering::Acquire);
2779                    turso_assert!(!header_dirty || mvcc_store.is_exclusive_tx(&tx.tx_id), "header_dirty=true implies that tx is exclusive");
2780                    if our_cookie != global_cookie && !header_dirty {
2781                        tracing::debug!("cookie mismatch in CommitState::Initial tx({our_cookie}) != global(!{global_cookie})");
2782                        schema_conflict = true;
2783                    }
2784
2785                    if schema_updated {
2786                        tracing::debug!("schema ts is older than our ts");
2787                        // Schema changes made after the transaction began always cause a [SchemaConflict] error and the tx must abort.
2788                        schema_conflict = true;
2789                    }
2790
2791                    let can_commit_tx = !(exclusive_conflict || schema_conflict);
2792                    if can_commit_tx || read_only {
2793                        tx.state.store(TransactionState::Preparing(ts));
2794                    }
2795                });
2796                // We allow reads from happening. Exlusive means there is a single writer.
2797                if exclusive_conflict && !read_only {
2798                    return Err(LimboError::WriteWriteConflict);
2799                }
2800                if schema_conflict && !read_only {
2801                    return Err(LimboError::SchemaConflict);
2802                }
2803                tracing::trace!("prepare_tx(tx_id={}, end_ts={})", self.tx_id, end_ts);
2804                /* In order to implement serializability, we need the following steps:
2805                **
2806                ** 1. Validate if all read versions are still visible by inspecting the read_set
2807                ** 2. Validate if there are no phantoms by walking the scans from scan_set (which we don't even have yet)
2808                **    - a phantom is a version that became visible in the middle of our transaction,
2809                **      but wasn't taken into account during one of the scans from the scan_set
2810                ** 3. Wait for commit dependencies, which we don't even track yet...
2811                **    Excerpt from what's a commit dependency and how it's tracked in the original paper:
2812                **    """
2813                        A transaction T1 has a commit dependency on another transaction
2814                        T2, if T1 is allowed to commit only if T2 commits. If T2 aborts,
2815                        T1 must also abort, so cascading aborts are possible. T1 acquires a
2816                        commit dependency either by speculatively reading or speculatively ignoring a version,
2817                        instead of waiting for T2 to commit.
2818                        We implement commit dependencies by a register-and-report
2819                        approach: T1 registers its dependency with T2 and T2 informs T1
2820                        when it has committed or aborted. Each transaction T contains a
2821                        counter, CommitDepCounter, that counts how many unresolved
2822                        commit dependencies it still has. A transaction cannot commit
2823                        until this counter is zero. In addition, T has a Boolean variable
2824                        AbortNow that other transactions can set to tell T to abort. Each
2825                        transaction T also has a set, CommitDepSet, that stores transaction IDs
2826                        of the transactions that depend on T.
2827                        To take a commit dependency on a transaction T2, T1 increments
2828                        its CommitDepCounter and adds its transaction ID to T2’s CommitDepSet.
2829                        When T2 has committed, it locates each transaction in
2830                        its CommitDepSet and decrements their CommitDepCounter. If
2831                        T2 aborted, it tells the dependent transactions to also abort by
2832                        setting their AbortNow flags. If a dependent transaction is not
2833                        found, this means that it has already aborted.
2834                        Note that a transaction with commit dependencies may not have to
2835                        wait at all - the dependencies may have been resolved before it is
2836                        ready to commit. Commit dependencies consolidate all waits into
2837                        a single wait and postpone the wait to just before commit.
2838                        Some transactions may have to wait before commit.
2839                        Waiting raises a concern of deadlocks.
2840                        However, deadlocks cannot occur because an older transaction never
2841                        waits on a younger transaction. In
2842                        a wait-for graph the direction of edges would always be from a
2843                        younger transaction (higher end timestamp) to an older transaction
2844                        (lower end timestamp) so cycles are impossible.
2845                    """
2846                **  If you're wondering when a speculative read happens, here you go:
2847                **  Case 1: speculative read of TB:
2848                    """If transaction TB is in the Preparing state, it has acquired an end
2849                        timestamp TS which will be V’s begin timestamp if TB commits.
2850                        A safe approach in this situation would be to have transaction T
2851                        wait until transaction TB commits. However, we want to avoid all
2852                        blocking during normal processing so instead we continue with
2853                        the visibility test and, if the test returns true, allow T to
2854                        speculatively read V. Transaction T acquires a commit dependency on
2855                        TB, restricting the serialization order of the two transactions. That
2856                        is, T is allowed to commit only if TB commits.
2857                    """
2858                **  Case 2: speculative ignore of TE:
2859                    """
2860                        If TE’s state is Preparing, it has an end timestamp TS that will become
2861                        the end timestamp of V if TE does commit. If TS is greater than the read
2862                        time RT, it is obvious that V will be visible if TE commits. If TE
2863                        aborts, V will still be visible, because any transaction that updates
2864                        V after TE has aborted will obtain an end timestamp greater than
2865                        TS. If TS is less than RT, we have a more complicated situation:
2866                        if TE commits, V will not be visible to T but if TE aborts, it will
2867                        be visible. We could handle this by forcing T to wait until TE
2868                        commits or aborts but we want to avoid all blocking during normal processing.
2869                        Instead we allow T to speculatively ignore V and
2870                        proceed with its processing. Transaction T acquires a commit
2871                        dependency (see Section 2.7) on TE, that is, T is allowed to commit
2872                        only if TE commits.
2873                    """
2874                */
2875                /* NOTE: Commit dependencies (Hekaton Section 2.7) are implemented via
2876                 ** the register-and-report protocol:
2877                 ** - Speculative reads/ignores call register_commit_dependency, which
2878                 **   increments CommitDepCounter and adds to CommitDepSet.
2879                 ** - WaitForDependencies checks AbortNow and waits for counter == 0.
2880                 ** - CommitEnd / rollback_tx drain CommitDepSet, notifying dependents.
2881                 **
2882                 ** TODO: For full serializability (beyond snapshot isolation), we still need:
2883                 ** 1. Validate if all read versions are still visible by inspecting the read_set
2884                 ** 2. Validate if there are no phantoms by walking the scans from scan_set
2885                 */
2886                tracing::trace!("commit_tx(tx_id={})", self.tx_id);
2887                // Header-only writes must not take this fast path; they need durable log records.
2888                if read_only {
2889                    turso_assert!(
2890                        tx.commit_dep_set.lock().is_empty(),
2891                        "MVCC read only transaction should not have commit dependencies on other txns"
2892                    );
2893                    // Abort eagerly if requested
2894                    if tx.abort_now.load(Ordering::Acquire) {
2895                        return Err(LimboError::CommitDependencyAborted);
2896                    }
2897                    // Even read-only transactions must honour commit dependencies.
2898                    // A SELECT during normal processing may have speculatively read
2899                    // from a Preparing transaction (Hekaton §2.7), incrementing our
2900                    // CommitDepCounter. We must wait for those to resolve.
2901                    if tx.commit_dep_counter.load(Ordering::Acquire) > 0 {
2902                        // Unresolved dependencies — skip validation (no writes)
2903                        // and go straight to WaitForDependencies.
2904                        self.state = CommitState::WaitForDependencies { end_ts };
2905                        return Ok(TransitionResult::Continue);
2906                    }
2907                    // Check abort_now AFTER counter: rollback_tx stores abort_now
2908                    // (Release) before fetch_sub (AcqRel). Once counter == 0, all
2909                    // decrements have completed and the abort_now flag is visible.
2910                    if tx.abort_now.load(Ordering::Acquire) {
2911                        return Err(LimboError::CommitDependencyAborted);
2912                    }
2913                    tx.state.store(TransactionState::Committed(end_ts));
2914                    if mvcc_store.is_exclusive_tx(&self.tx_id) {
2915                        mvcc_store.release_exclusive_tx(&self.tx_id);
2916                    }
2917                    mvcc_store.unlock_commit_lock_if_held(tx);
2918                    mvcc_store.finish_committed_tx(self.tx_id, &self.connection, self.db_id)?;
2919                    inject_transition_failure!(self, CommitYieldPoint::AfterRemoveTx);
2920                    self.finalize(mvcc_store)?;
2921                    return Ok(TransitionResult::Done(()));
2922                }
2923                self.state = CommitState::Commit { end_ts };
2924                inject_transition_yield!(self, CommitYieldPoint::CommitValidation);
2925                Ok(TransitionResult::Continue)
2926            }
2927            CommitState::Commit { end_ts } => {
2928                // Check for rowid conflicts before committing (pure optimistic, first-committer-wins)
2929                // Ref: Hekaton paper Section 3.2 - validation uses end_ts comparison
2930                let tx = mvcc_store
2931                    .txs
2932                    .get(&self.tx_id)
2933                    .ok_or(LimboError::TxTerminated)?;
2934                let tx = tx.value();
2935
2936                for (id, _chain) in tx.write_set.lock().iter() {
2937                    if id.row_id.is_int_key() {
2938                        self.check_rowid_for_conflicts(id, *end_ts, tx, mvcc_store)?;
2939                    } else {
2940                        self.check_index_for_conflicts(id, *end_ts, tx, mvcc_store)?;
2941                    }
2942                }
2943
2944                // Validation passed. Wait for commit dependencies before building
2945                // the durable commit record. The live row versions must stay on
2946                // TxID references until CommitEnd so an abandoned commit can
2947                // still be rolled back by matching on TxID(self.tx_id).
2948                self.state = CommitState::WaitForDependencies { end_ts: *end_ts };
2949                inject_transition_yield!(self, CommitYieldPoint::WaitForDependencies);
2950                return Ok(TransitionResult::Continue);
2951            }
2952            CommitState::WaitForDependencies { end_ts } => {
2953                let end_ts = *end_ts;
2954                let tx = mvcc_store
2955                    .txs
2956                    .get(&self.tx_id)
2957                    .ok_or(LimboError::TxTerminated)?;
2958                let tx = tx.value();
2959
2960                // Eagarly check for abort_now
2961                if tx.abort_now.load(Ordering::Acquire) {
2962                    return Err(LimboError::CommitDependencyAborted);
2963                }
2964                // Hekaton Section 2.7: "A transaction cannot commit until this
2965                // counter is zero." Deadlock impossible: edges always go from higher
2966                // end_ts to lower end_ts, so the wait graph is acyclic.
2967                if tx.commit_dep_counter.load(Ordering::Acquire) > 0 {
2968                    return Ok(TransitionResult::Io(IOCompletions::Single(
2969                        Completion::new_yield(),
2970                    )));
2971                }
2972
2973                // Check abort_now AFTER counter reaches 0. Memory ordering:
2974                // rollback_tx does abort_now.store(true, Release) BEFORE
2975                // counter.fetch_sub(1, AcqRel). Our Acquire load of counter==0
2976                // synchronizes-with that fetch_sub, making the abort_now store
2977                // visible. Checking in the opposite order (abort_now first) has a
2978                // TOCTOU race: an aborting dep can set abort_now and decrement
2979                // between our two reads, letting us see (false, 0) and commit.
2980                if tx.abort_now.load(Ordering::Acquire) {
2981                    return Err(LimboError::CommitDependencyAborted);
2982                }
2983
2984                // Read-only fast path: if write_set is empty and header was not mutated, commit without
2985                // going through CommitEnd. CommitEnd updates last_committed_tx_ts
2986                // which would make a read-only transaction look like a write,
2987                // causing spurious Busy errors from acquire_exclusive_tx.
2988                if tx.write_set.lock().is_empty() && !tx.header_dirty.load(Ordering::Acquire) {
2989                    turso_assert!(
2990                        tx.commit_dep_set.lock().is_empty(),
2991                        "MVCC read-only transaction should not have other transactions depending on it"
2992                    );
2993                    tx.state.store(TransactionState::Committed(end_ts));
2994                    if mvcc_store.is_exclusive_tx(&self.tx_id) {
2995                        mvcc_store.release_exclusive_tx(&self.tx_id);
2996                        self.commit_coordinator.pager_commit_lock.unlock();
2997                    }
2998                    mvcc_store.finish_committed_tx(self.tx_id, &self.connection, self.db_id)?;
2999                    inject_transition_failure!(self, CommitYieldPoint::AfterRemoveTx);
3000                    self.finalize(mvcc_store)?;
3001                    return Ok(TransitionResult::Done(()));
3002                }
3003
3004                // All dependencies resolved. Initialize an empty log record
3005                // (`buf` is grown incrementally during BuildLogRecord) and
3006                // snapshot the dirty header for the tail of the payload.
3007                // Live row versions stay on TxID references until CommitEnd
3008                // so rollback of an abandoned commit can still match them.
3009                let pending_header = if tx.header_dirty.load(Ordering::Acquire) {
3010                    Some(*tx.header.read())
3011                } else {
3012                    None
3013                };
3014                self.state = CommitState::BuildLogRecord(BuildLogRecordCtx {
3015                    end_ts,
3016                    log_record: LogRecord::new(end_ts),
3017                    cursor: 0,
3018                    schema_process: true,
3019                    pending_header,
3020                });
3021                return Ok(TransitionResult::Continue);
3022            }
3023            // Chunked: yields every MVCC_COMMIT_BATCH_SIZE rowids. Pulled out
3024            // to a helper because the arm body needs `&mut self` to mutate
3025            // the cursor / pass / log_record inside the variant, which
3026            // conflicts with the outer `&self.state` match borrow.
3027            CommitState::BuildLogRecord(_) => self.step_build_log_record(mvcc_store),
3028            CommitState::BeginCommitLogicalLog { end_ts, .. } => {
3029                if !mvcc_store.is_exclusive_tx(&self.tx_id) {
3030                    // logical log needs to be serialized.
3031                    let tx = mvcc_store
3032                        .txs
3033                        .get(&self.tx_id)
3034                        .ok_or_else(|| LimboError::NoSuchTransactionID(self.tx_id.to_string()))?;
3035                    let locked = self.commit_coordinator.pager_commit_lock.write();
3036                    if !locked {
3037                        return Ok(TransitionResult::Io(IOCompletions::Single(
3038                            Completion::new_yield(),
3039                        )));
3040                    }
3041                    tx.value()
3042                        .pager_commit_lock_held
3043                        .store(true, Ordering::Release);
3044                }
3045                let end_ts = *end_ts;
3046                let log_record = match std::mem::replace(
3047                    &mut self.state,
3048                    CommitState::UpgradeLogicalLogHeader {
3049                        end_ts,
3050                        log_record: LogRecord::new(end_ts),
3051                    },
3052                ) {
3053                    CommitState::BeginCommitLogicalLog { log_record, .. } => log_record,
3054                    _ => unreachable!(),
3055                };
3056                self.state = CommitState::UpgradeLogicalLogHeader { end_ts, log_record };
3057                Ok(TransitionResult::Continue)
3058            }
3059            CommitState::UpgradeLogicalLogHeader { end_ts, log_record } => {
3060                if let Some(c) = mvcc_store.storage.upgrade_header_for_log_tx(log_record)? {
3061                    if !c.succeeded() {
3062                        return Ok(TransitionResult::Io(IOCompletions::Single(c)));
3063                    }
3064                }
3065                let end_ts = *end_ts;
3066                let log_record = match std::mem::replace(
3067                    &mut self.state,
3068                    CommitState::WriteLogicalLog {
3069                        end_ts,
3070                        log_record: LogRecord::new(end_ts),
3071                    },
3072                ) {
3073                    CommitState::UpgradeLogicalLogHeader { log_record, .. } => log_record,
3074                    _ => unreachable!(),
3075                };
3076                self.state = CommitState::WriteLogicalLog { end_ts, log_record };
3077                Ok(TransitionResult::Continue)
3078            }
3079            CommitState::WriteLogicalLog { end_ts, .. } => {
3080                let end_ts = *end_ts;
3081                let log_record = match std::mem::replace(
3082                    &mut self.state,
3083                    CommitState::FinishLogicalLogWrite { end_ts },
3084                ) {
3085                    CommitState::WriteLogicalLog { log_record, .. } => log_record,
3086                    _ => unreachable!(),
3087                };
3088                let (c, append_bytes) = mvcc_store.storage.log_tx(log_record, None)?;
3089                self.pending_log_append_bytes = Some(append_bytes);
3090                // if Completion Completed without errors we can continue
3091                if c.succeeded() {
3092                    Ok(TransitionResult::Continue)
3093                } else {
3094                    Ok(TransitionResult::Io(IOCompletions::Single(c)))
3095                }
3096            }
3097
3098            CommitState::FinishLogicalLogWrite { end_ts } => {
3099                let c = mvcc_store.storage.on_log_write_complete()?;
3100                self.state = CommitState::SyncLogicalLog { end_ts: *end_ts };
3101                if c.succeeded() {
3102                    Ok(TransitionResult::Continue)
3103                } else {
3104                    Ok(TransitionResult::Io(IOCompletions::Single(c)))
3105                }
3106            }
3107
3108            CommitState::SyncLogicalLog { end_ts } => {
3109                // Skip fsync when synchronous mode is not FULL.
3110                // NORMAL mode skips fsync on commit (but still fsyncs on checkpoint).
3111                if self.sync_mode != SyncMode::Full {
3112                    tracing::debug!("Skipping fsync of logical log (synchronous!=full)");
3113                    self.state = CommitState::EndCommitLogicalLog { end_ts: *end_ts };
3114                    return Ok(TransitionResult::Continue);
3115                }
3116                let c = mvcc_store.storage.sync(self.pager.get_sync_type())?;
3117                self.state = CommitState::EndCommitLogicalLog { end_ts: *end_ts };
3118                // if Completion Completed without errors we can continue
3119                if c.succeeded() {
3120                    Ok(TransitionResult::Continue)
3121                } else {
3122                    Ok(TransitionResult::Io(IOCompletions::Single(c)))
3123                }
3124            }
3125            CommitState::EndCommitLogicalLog { end_ts } => {
3126                let tx = mvcc_store
3127                    .txs
3128                    .get(&self.tx_id)
3129                    .ok_or_else(|| LimboError::NoSuchTransactionID(self.tx_id.to_string()))?;
3130                let tx_unlocked = tx.value();
3131                let tx_header = *tx_unlocked.header.read();
3132                let schema_did_change = self.did_commit_schema_change
3133                    || self
3134                        .header
3135                        .read()
3136                        .as_ref()
3137                        .map(|header| header.schema_cookie.get())
3138                        != Some(tx_header.schema_cookie.get());
3139                self.did_commit_schema_change = schema_did_change;
3140                if schema_did_change {
3141                    let schema = self.connection.schema.read().clone();
3142                    self.connection.db.update_schema_if_newer(schema);
3143                }
3144                // Guard the global_header write against out-of-order
3145                // completion. An exclusive tx can bypass `pager_commit_lock`
3146                // (the conditional at the top of BeginCommitLogicalLog) and
3147                // race past us into EndCommitLogicalLog; if its end_ts is
3148                // higher, its header has already been published, and our
3149                // older write would regress `global_header.schema_cookie`
3150                // below the latest committed value. The same monotonicity
3151                // applies in FinalizeCommit below.
3152                let prev_hdr_ts = mvcc_store
3153                    .last_global_header_ts
3154                    .fetch_max(*end_ts, Ordering::AcqRel);
3155                if prev_hdr_ts <= *end_ts {
3156                    self.header.write().replace(tx_header);
3157                }
3158                tracing::trace!("end_commit_logical_log(tx_id={})", self.tx_id);
3159                self.state = CommitState::CommitEnd { end_ts: *end_ts };
3160                return Ok(TransitionResult::Continue);
3161            }
3162            CommitState::CommitEnd { end_ts } => {
3163                // Order of operations matters here:
3164                // 1. Advance logical log writer offset (makes the written bytes "owned")
3165                // 2. Mark transaction Committed
3166                // 3. Rewrite live row versions from TxID to Timestamp (chunked
3167                //    in `RewriteLiveVersions` so 2M-row write sets don't stall)
3168                // 4. Notify dependents
3169                // 5. Release commit lock (allows next committer)
3170                // 6. Update cached global header
3171                //
3172                // (1) must precede (5): the commit lock serializes log writes, and
3173                // log_tx() writes at the current offset. If we released the lock before
3174                // advancing, the next committer would overwrite our bytes.
3175                //
3176                // (2) must precede (3): rewriting before marking Committed would
3177                // publish the transaction's effects to readers before its fate is
3178                // decided, which breaks rollback of abandoned commits.
3179                //
3180                // (2) must also precede (5): the next committer's validation (CommitState::Commit)
3181                // checks our transaction state. If it still sees Preparing instead of
3182                // Committed, the tie-breaking logic (lower end_ts wins) applies instead
3183                // of the definitive "already committed = conflict" path.
3184                //
3185                // pending_log_append_bytes is set in BeginCommitLogicalLog after log_tx
3186                // writes to disk. If the commit fails before reaching here (e.g. during
3187                // sync), the bytes are never consumed and the in-memory writer offset
3188                // stays behind — the next write overwrites the uncommitted bytes.
3189                let tx = mvcc_store
3190                    .txs
3191                    .get(&self.tx_id)
3192                    .ok_or_else(|| LimboError::NoSuchTransactionID(self.tx_id.to_string()))?;
3193                let tx_unlocked = tx.value();
3194                if let Some(append_bytes) = self.pending_log_append_bytes.take() {
3195                    mvcc_store
3196                        .storage
3197                        .advance_logical_log_offset_after_success(append_bytes)?;
3198                }
3199                tx_unlocked
3200                    .state
3201                    .store(TransactionState::Committed(*end_ts));
3202
3203                // Hand off to the chunked rewriter. Between chunks readers
3204                // resolve any unwritten TxID refs via `txs[tx_id]` which now
3205                // reports Committed(end_ts).
3206                self.state = CommitState::RewriteLiveVersions(RewriteLiveVersionsCtx {
3207                    end_ts: *end_ts,
3208                    cursor: 0,
3209                });
3210                Ok(TransitionResult::Continue)
3211            }
3212            // Chunked: yields every MVCC_COMMIT_BATCH_SIZE rowids. Same
3213            // helper-dispatch reason as BuildLogRecord above.
3214            CommitState::RewriteLiveVersions(_) => self.step_rewrite_live_versions(mvcc_store),
3215            CommitState::FinalizeCommit { end_ts } => {
3216                let tx = mvcc_store
3217                    .txs
3218                    .get(&self.tx_id)
3219                    .ok_or_else(|| LimboError::NoSuchTransactionID(self.tx_id.to_string()))?;
3220                let tx_unlocked = tx.value();
3221
3222                // Hekaton Section 3.3: "The transaction then processes all outgoing
3223                // commit dependencies listed in its CommitDepSet. If it committed, it
3224                // decrements the target transaction's CommitDepCounter."
3225                // IOW since this txn committed, let's signal waiting transactions.
3226                let dependents = std::mem::take(&mut *tx_unlocked.commit_dep_set.lock());
3227                for dep_tx_id in dependents {
3228                    if let Some(dep_tx_entry) = mvcc_store.txs.get(&dep_tx_id) {
3229                        dep_tx_entry
3230                            .value()
3231                            .commit_dep_counter
3232                            .fetch_sub(1, Ordering::AcqRel);
3233                    }
3234                }
3235
3236                mvcc_store.unlock_commit_lock_if_held(tx_unlocked);
3237
3238                inject_transition_yield!(self, CommitYieldPoint::BeforeGlobalHeaderUpdate);
3239
3240                let tx_header = *tx_unlocked.header.read();
3241                {
3242                    // Hold the header lock across the watermark update and header
3243                    // publish so the guard decision and replacement are serialized.
3244                    let mut global_header = mvcc_store.global_header.write();
3245                    // Since we assign a commit timestamp and then we drive the commit to completion,
3246                    // it is totally possible for so an older transaction can finish after a newer one.
3247                    // In such case, we should not let older commit to set lower value than previous.
3248                    // This value is used in checkpointing as a watermark boundary, and an incorrect
3249                    // lower value can cause data loss / corruption.
3250                    let last_committed_ts = mvcc_store
3251                        .last_committed_tx_ts
3252                        .fetch_max(*end_ts, Ordering::AcqRel);
3253                    if last_committed_ts <= *end_ts {
3254                        global_header.replace(tx_header);
3255                    }
3256                }
3257                if self.did_commit_schema_change {
3258                    mvcc_store
3259                        .last_committed_schema_change_ts
3260                        .fetch_max(*end_ts, Ordering::AcqRel);
3261                }
3262
3263                // We have now updated all the versions with a reference to the
3264                // transaction ID to a timestamp and can, therefore, remove the
3265                // transaction. Pair removal with the connection cache clear so
3266                // an IO yield + abandon during the upcoming checkpoint cannot
3267                // strand `conn.mv_tx_id` referencing a tx that's gone from `txs`.
3268                //
3269                // Release `exclusive_tx` BEFORE `finish_committed_tx` /
3270                // `inject_transition_failure!` so an Err at `AfterRemoveTx`
3271                // cannot strand the atomic — matches the ordering at the
3272                // two fast-path CommitEnd sites.
3273                if mvcc_store.is_exclusive_tx(&self.tx_id) {
3274                    mvcc_store.release_exclusive_tx(&self.tx_id);
3275                }
3276                inject_transition_yield!(self, CommitYieldPoint::BeforeFinishCommittedTx);
3277                mvcc_store.finish_committed_tx(self.tx_id, &self.connection, self.db_id)?;
3278                inject_transition_failure!(self, CommitYieldPoint::AfterRemoveTx);
3279                if mvcc_store.storage.should_checkpoint() {
3280                    let auto_checkpoint_mode = if self
3281                        .connection
3282                        .experimental_mvcc_passive_checkpoint_enabled()
3283                    {
3284                        crate::storage::wal::CheckpointMode::Passive {
3285                            upper_bound_inclusive: None,
3286                        }
3287                    } else {
3288                        crate::storage::wal::CheckpointMode::Truncate {
3289                            upper_bound_inclusive: None,
3290                        }
3291                    };
3292                    let state_machine = StateMachine::new(CheckpointStateMachine::new(
3293                        self.pager.clone(),
3294                        mvcc_store.clone(),
3295                        self.connection.clone(),
3296                        false,
3297                        self.connection.get_sync_mode(),
3298                        self.db_id,
3299                        auto_checkpoint_mode,
3300                    ));
3301                    let state_machine = Mutex::new(state_machine);
3302                    self.state = CommitState::Checkpoint { state_machine };
3303                    return Ok(TransitionResult::Continue);
3304                }
3305                // Not checkpointing this commit. Reclaim invisible versions
3306                // inline with a bounded, non-blocking GC pass so steady-state
3307                // memory stays flat between checkpoints (which only fire on
3308                // logical-log byte growth, not version accumulation). The pass
3309                // does no I/O and is capped at MAX_CHAINS_PER_GC chains, so it
3310                // doesn't meaningfully slow this committing connection. See
3311                // `gc_incremental`.
3312                if mvcc_store.should_gc() {
3313                    mvcc_store.gc_incremental(MvStore::<Clock>::MAX_CHAINS_PER_GC);
3314                }
3315                tracing::trace!("logged(tx_id={}, end_ts={})", self.tx_id, *end_ts);
3316                self.finalize(mvcc_store)?;
3317                Ok(TransitionResult::Done(()))
3318            }
3319            CommitState::Checkpoint { state_machine } => {
3320                let step_result = {
3321                    let mut sm = state_machine.lock();
3322                    // Step the checkpoint SM directly so a passive publish retry can return
3323                    // `Continue` and yield the commit executor (the `StateMachine` wrapper would
3324                    // spin on `Continue` internally and starve pinned readers on the same thread).
3325                    sm.inner_mut().step(&())
3326                };
3327                match step_result {
3328                    Ok(TransitionResult::Continue) => {
3329                        return Ok(TransitionResult::Continue);
3330                    }
3331                    Ok(TransitionResult::Io(iocompletions)) => {
3332                        return Ok(TransitionResult::Io(iocompletions));
3333                    }
3334                    Ok(TransitionResult::Done(_)) => {
3335                        state_machine.lock().finalize(&())?;
3336                    }
3337                    Err(err) => {
3338                        // Auto-checkpoint errors should not surface to the committed statement.
3339                        tracing::info!("MVCC auto-checkpoint failed: {err}");
3340                        self.finalize(mvcc_store)?;
3341                        return Ok(TransitionResult::Done(()));
3342                    }
3343                }
3344                self.finalize(mvcc_store)?;
3345                return Ok(TransitionResult::Done(()));
3346            }
3347        }
3348    }
3349
3350    fn finalize(&mut self, _context: &Self::Context) -> Result<()> {
3351        self.is_finalized = true;
3352        Ok(())
3353    }
3354
3355    fn is_finalized(&self) -> bool {
3356        self.is_finalized
3357    }
3358}
3359
3360impl StateTransition for WriteRowStateMachine {
3361    type Context = ();
3362    type SMResult = ();
3363
3364    #[tracing::instrument(fields(state = ?self.state), skip(self, _context), level = Level::DEBUG)]
3365    fn step(&mut self, _context: &Self::Context) -> Result<TransitionResult<Self::SMResult>> {
3366        use crate::types::{IOResult, SeekKey, SeekOp};
3367
3368        match self.state {
3369            WriteRowState::Initial => {
3370                // Create the record and key
3371                self.record = if self.row.is_index_row() {
3372                    None
3373                } else {
3374                    let row_data = self.row.data.as_ref().expect("table rows should have data");
3375                    let mut record = ImmutableRecord::new(row_data.len())?;
3376                    record.start_serialization(row_data)?;
3377                    Some(record)
3378                };
3379                // `requires_seek == false` is a write_set-level *candidate* for the
3380                // sequential-write optimization (this row's key is exactly previous
3381                // row's key + 1, so the cursor — left on the previous row and advanced
3382                // by WriteRowState::Next — is usually already at the insert position).
3383                // It is only sound if the cursor is still PAST the start of its leaf:
3384                // if the previous row was the last cell of its leaf, next() crossed
3385                // into the following leaf (cell 0), and this row may belong on the
3386                // other side of the parent divider. Dividers keep their key when the
3387                // checkpoint deletes their row, so the divider can
3388                // be >= this row's key, meaning the row MUST go into the left leaf
3389                // even though the cursor is in the right one. Writing it at the
3390                // cursor would keep the leaf locally sorted but break the interior
3391                // ordering invariant, making the row invisible to point lookups
3392                // ("Rowid N out of order" under sqlite3 integrity_check). Fall back
3393                // to a real seek, which resolves the divider comparison correctly.
3394                if self.requires_seek || !self.cursor.read().is_positioned_past_page_start() {
3395                    self.state = WriteRowState::Seek;
3396                } else {
3397                    self.state = WriteRowState::Insert;
3398                }
3399                Ok(TransitionResult::Continue)
3400            }
3401            WriteRowState::Seek => {
3402                // Position the cursor by seeking to the row position
3403                let seek_key = match &self.row.id.row_id {
3404                    RowKey::Int(row_id) => SeekKey::TableRowId(*row_id),
3405                    RowKey::Record(record) => SeekKey::IndexKey(&record.key),
3406                };
3407
3408                match self
3409                    .cursor
3410                    .write()
3411                    .seek(seek_key, SeekOp::GE { eq_only: true })?
3412                {
3413                    IOResult::Done(seek_result) => {
3414                        if self.row.is_index_row() && matches!(seek_result, SeekResult::TryAdvance)
3415                        {
3416                            self.state = WriteRowState::Advance;
3417                            return Ok(TransitionResult::Continue);
3418                        }
3419                    }
3420                    IOResult::IO(io) => {
3421                        return Ok(TransitionResult::Io(io));
3422                    }
3423                }
3424                turso_assert_eq!(self.cursor.write().valid_state, CursorValidState::Valid);
3425                self.state = WriteRowState::Insert;
3426                Ok(TransitionResult::Continue)
3427            }
3428            WriteRowState::Advance => {
3429                match self
3430                    .cursor
3431                    .write()
3432                    .next()
3433                    .map_err(|e: LimboError| LimboError::InternalError(e.to_string()))?
3434                {
3435                    IOResult::Done(_) => {}
3436                    IOResult::IO(io) => {
3437                        return Ok(TransitionResult::Io(io));
3438                    }
3439                }
3440                turso_assert!(
3441                    self.cursor.read().has_record(),
3442                    "MVCC checkpoint index insert did not land on the matched interior record"
3443                );
3444                self.state = WriteRowState::Insert;
3445                Ok(TransitionResult::Continue)
3446            }
3447            WriteRowState::Insert => {
3448                // Insert the record into the B-tree
3449                let key = match &self.row.id.row_id {
3450                    RowKey::Int(row_id) => BTreeKey::new_table_rowid(*row_id, self.record.as_ref()),
3451                    RowKey::Record(record) => BTreeKey::new_index_key(&record.key),
3452                };
3453
3454                match self
3455                    .cursor
3456                    .write()
3457                    .insert(&key)
3458                    .map_err(|e: LimboError| LimboError::InternalError(e.to_string()))?
3459                {
3460                    IOResult::Done(()) => {}
3461                    IOResult::IO(io) => {
3462                        return Ok(TransitionResult::Io(io));
3463                    }
3464                }
3465                self.state = WriteRowState::Next;
3466                Ok(TransitionResult::Continue)
3467            }
3468            WriteRowState::Next => {
3469                match self
3470                    .cursor
3471                    .write()
3472                    .next()
3473                    .map_err(|e: LimboError| LimboError::InternalError(e.to_string()))?
3474                {
3475                    IOResult::Done(_) => {}
3476                    IOResult::IO(io) => {
3477                        return Ok(TransitionResult::Io(io));
3478                    }
3479                }
3480                self.finalize(&())?;
3481                Ok(TransitionResult::Done(()))
3482            }
3483        }
3484    }
3485
3486    fn finalize(&mut self, _context: &Self::Context) -> Result<()> {
3487        self.is_finalized = true;
3488        Ok(())
3489    }
3490
3491    fn is_finalized(&self) -> bool {
3492        self.is_finalized
3493    }
3494}
3495
3496impl StateTransition for DeleteRowStateMachine {
3497    type Context = ();
3498    type SMResult = ();
3499
3500    #[tracing::instrument(fields(state = ?self.state), skip(self, _context), level = Level::TRACE)]
3501    fn step(&mut self, _context: &Self::Context) -> Result<TransitionResult<Self::SMResult>> {
3502        use crate::types::{IOResult, SeekKey, SeekOp};
3503
3504        match self.state {
3505            DeleteRowState::Initial => {
3506                self.state = DeleteRowState::Seek;
3507                Ok(TransitionResult::Continue)
3508            }
3509            DeleteRowState::Seek => {
3510                let seek_key = match &self.rowid.row_id {
3511                    RowKey::Int(row_id) => SeekKey::TableRowId(*row_id),
3512                    RowKey::Record(record) => SeekKey::IndexKey(&record.key),
3513                };
3514
3515                match self
3516                    .cursor
3517                    .write()
3518                    .seek(seek_key, SeekOp::GE { eq_only: true })?
3519                {
3520                    IOResult::Done(seek_res) => {
3521                        match seek_res {
3522                            SeekResult::Found => {
3523                                self.state = DeleteRowState::Delete;
3524                            }
3525                            SeekResult::TryAdvance => {
3526                                // In index B-trees, the key can reside in an interior node
3527                                // rather than a leaf. The seek descends to the leaf but
3528                                // doesn't find it there, returning TryAdvance. Advancing
3529                                // the cursor will move up to the interior cell.
3530                                self.state = DeleteRowState::Advance;
3531                            }
3532                            SeekResult::NotFound => {
3533                                crate::bail_corrupt_error!(
3534                                    "MVCC delete: rowid {} not found",
3535                                    self.rowid.row_id
3536                                );
3537                            }
3538                        }
3539                        Ok(TransitionResult::Continue)
3540                    }
3541                    IOResult::IO(io) => {
3542                        return Ok(TransitionResult::Io(io));
3543                    }
3544                }
3545            }
3546            DeleteRowState::Advance => {
3547                let next_result = self.cursor.write().next()?;
3548                match next_result {
3549                    IOResult::Done(()) => {
3550                        if !self.cursor.read().has_record() {
3551                            crate::bail_corrupt_error!(
3552                                "MVCC delete: rowid {} not found after advance",
3553                                self.rowid.row_id
3554                            );
3555                        }
3556                        self.state = DeleteRowState::Delete;
3557                        Ok(TransitionResult::Continue)
3558                    }
3559                    IOResult::IO(io) => {
3560                        return Ok(TransitionResult::Io(io));
3561                    }
3562                }
3563            }
3564            DeleteRowState::Delete => {
3565                // Insert the record into the B-tree
3566
3567                match self
3568                    .cursor
3569                    .write()
3570                    .delete()
3571                    .map_err(|e| LimboError::InternalError(e.to_string()))?
3572                {
3573                    IOResult::Done(()) => {}
3574                    IOResult::IO(io) => {
3575                        return Ok(TransitionResult::Io(io));
3576                    }
3577                }
3578                tracing::trace!(
3579                    "delete_row_from_pager(table_id={}, row_id={})",
3580                    self.rowid.table_id,
3581                    self.rowid.row_id
3582                );
3583                self.finalize(&())?;
3584                Ok(TransitionResult::Done(()))
3585            }
3586        }
3587    }
3588
3589    fn finalize(&mut self, _context: &Self::Context) -> Result<()> {
3590        self.is_finalized = true;
3591        Ok(())
3592    }
3593
3594    fn is_finalized(&self) -> bool {
3595        self.is_finalized
3596    }
3597}
3598
3599impl DeleteRowStateMachine {
3600    fn new(rowid: RowID, cursor: Arc<RwLock<BTreeCursor>>) -> Self {
3601        Self {
3602            state: DeleteRowState::Initial,
3603            is_finalized: false,
3604            rowid,
3605            cursor,
3606        }
3607    }
3608}
3609
3610pub const SQLITE_SCHEMA_MVCC_TABLE_ID: MVTableId = MVTableId(-1);
3611pub(crate) const MVCC_META_TABLE_NAME: &str = "__turso_internal_mvcc_meta";
3612/// Indicates the maximum transaction timestamp that has been made durable in the WAL.
3613/// Used to determine the replay boundary for recovery; only records with a higher timestamp
3614/// are replayed.
3615pub(crate) const MVCC_META_KEY_PERSISTENT_TX_TS_MAX: &str = "persistent_tx_ts_max";
3616
3617#[derive(Debug)]
3618pub struct RowidAllocator {
3619    /// Exclusive lock serializing initialization (btree max read → store).
3620    /// Only held during the first NewRowid for a table; after that, the
3621    /// fast path is lock-free (atomic CAS on max_rowid).
3622    lock: TursoRwLock,
3623    /// Monotonically increasing counter. 0 = empty table (rowids start at 1).
3624    /// Updated via atomic CAS — no RwLock needed on the fast path.
3625    max_rowid: AtomicI64,
3626    /// True after the first btree-max scan. Never reset to false.
3627    initialized: AtomicBool,
3628}
3629
3630/// Sub state machine for [`MvStore::bootstrap_nonblock`]. Carried by the
3631/// open state machine (`OpenDbAsyncPhase::BootstrapMvStore`) across the
3632/// metadata-bootstrap IO chain so opening an MVCC database does not block on
3633/// the log-header truncate / write / fsync sequence, the interrupted-checkpoint
3634/// reconciliation, the schema reparse, or the metadata-table reads/writes.
3635#[derive(Default)]
3636pub enum BootstrapState {
3637    #[default]
3638    Start,
3639    /// Pre-metadata: reconcile an interrupted checkpoint (non-blocking).
3640    PreCheckpoint {
3641        tvfs: Vec<Arc<crate::vtab::VirtualTable>>,
3642        checkpoint_st: CompleteCheckpointState,
3643    },
3644    /// Pre-metadata: reparse the schema (non-blocking).
3645    PreReparse {
3646        tvfs: Vec<Arc<crate::vtab::VirtualTable>>,
3647        reparse_st: crate::connection::ReparseSchemaState,
3648    },
3649    /// Reading the persistent tx-ts-max from the MVCC metadata table to decide
3650    /// whether the metadata-bootstrap IO chain is needed.
3651    BeginReadTxTs {
3652        read_st: ReadPersistentTxTsMaxState,
3653    },
3654    MetadataIo(MetadataIoInFlight),
3655    /// Finish: create/seed the MVCC metadata table (non-blocking).
3656    FinishInit {
3657        init_st: InitMetadataTableState,
3658    },
3659    /// Finish: reconcile the interrupted checkpoint again after metadata writes.
3660    FinishCheckpoint {
3661        checkpoint_st: CompleteCheckpointState,
3662    },
3663    /// Replay the logical log into the MVCC store (non-blocking), then promote
3664    /// the bootstrap connection to a regular MVCC connection.
3665    Recover {
3666        recover_st: RecoverLogicalLogState,
3667    },
3668    /// Recover sequence descriptors from each backing table (non-blocking).
3669    /// The pre-replay reparse missed backing tables created by committed-but-
3670    /// not-checkpointed CREATE SEQUENCE statements; this pass registers pure
3671    /// descriptors via the MVCC-aware SQL path. The runtime watermark is never
3672    /// read — every nextval queries disk on demand.
3673    LoadSequences {
3674        load_st: crate::connection::LoadSequenceDescriptorsState,
3675    },
3676    /// Compatibility sync for AUTOINCREMENT tables created in WAL mode (where
3677    /// the watermark lives in sqlite_sequence) and then reopened in MVCC mode
3678    /// (where the disk-only allocation path reads backing tables). Non-blocking.
3679    SyncAutoincrement {
3680        sync_st: crate::connection::SyncAutoincrementState,
3681    },
3682    AwaitingGlobalHeader,
3683}
3684
3685#[doc(hidden)]
3686pub struct MetadataIoInFlight {
3687    pub completion: Completion,
3688    pub sync_type: FileSyncType,
3689    pub next: MetadataIoStep,
3690}
3691
3692#[doc(hidden)]
3693#[derive(Clone, Copy)]
3694pub enum MetadataIoStep {
3695    /// `log_file.truncate(0)` just finished. If the log was <= header size we
3696    /// still need to write a fresh header (and maybe fsync). `log_size` is the
3697    /// original on-disk size; `sync_mode_off` records whether sync is disabled.
3698    AfterTruncate { log_size: u64, sync_mode_off: bool },
3699    /// `storage.update_header` just finished. Maybe issue `storage.sync`.
3700    AfterUpdateHeader { sync_mode_off: bool },
3701    /// `storage.sync` just finished — metadata IO chain done.
3702    AfterSync,
3703}
3704
3705/// Sub state machine for
3706/// [`MvStore::maybe_complete_interrupted_checkpoint_nonblock`]. Tracks the
3707/// sequence of IO yields needed to reconcile an interrupted MVCC checkpoint
3708/// without blocking: read log header → optional early WAL truncate, or
3709/// WAL→DB backfill + db_file.sync + log-header rewrite (with a single-shot
3710/// CRC retry) + final WAL truncate.
3711#[derive(Default)]
3712pub enum CompleteCheckpointState {
3713    #[default]
3714    Start,
3715    /// Reading the log header via the streaming reader.
3716    ReadingHeader {
3717        reader: Box<StreamingLogicalLogReader>,
3718    },
3719    /// `wal_max_frame == 0` branch: driving the early `wal.truncate_wal`.
3720    /// `checkpoint_result` must persist across IO yields because
3721    /// `truncate_wal`/`truncate_log` tracks its truncate/sync progress through
3722    /// its `wal_truncate_sent` / `wal_sync_sent` flags; recreating it each
3723    /// re-entry would re-issue the truncate forever.
3724    DriveEarlyTruncate {
3725        header_result: HeaderReadResult,
3726        checkpoint_result: CheckpointResult,
3727    },
3728    /// Main path: driving `wal.checkpoint(Truncate)`. Reached from a Valid header (reused
3729    /// via `set_header`) or a `NoLog` log (the Passive steady state); the fresh header is
3730    /// (re)written later in `RetryHeader`.
3731    DriveCheckpoint,
3732    /// Awaiting the `db_file.sync` completion after a successful backfill.
3733    AwaitDbFileSync {
3734        completion: Completion,
3735        checkpoint_result: CheckpointResult,
3736    },
3737    /// Retry loop: rewriting the log header + verifying CRC. `retried_crc`
3738    /// allows a single retry on torn-tail mismatch before failing closed.
3739    RetryHeader {
3740        checkpoint_result: CheckpointResult,
3741        retried_crc: bool,
3742        phase: RetryHeaderPhase,
3743    },
3744    /// Driving the final `wal.truncate_wal`.
3745    DriveFinalTruncate { checkpoint_result: CheckpointResult },
3746}
3747
3748#[doc(hidden)]
3749pub enum RetryHeaderPhase {
3750    /// Need to issue `storage.update_header`.
3751    NeedUpdateHeader,
3752    /// Awaiting the `storage.update_header` write.
3753    AwaitUpdateHeader(Completion),
3754    /// Awaiting `storage.sync` (only when `sync_mode != Off`).
3755    AwaitLogSync(Completion),
3756    /// Reading the log header to verify the CRC.
3757    AwaitCrcCheck {
3758        reader: Box<StreamingLogicalLogReader>,
3759    },
3760}
3761
3762/// Sub state machine for [`MvStore::try_read_persistent_tx_ts_max_nonblock`].
3763/// Holds the prepared metadata-read statement + accumulated value across IO
3764/// yields while the SELECT runs cooperatively.
3765#[derive(Default)]
3766pub enum ReadPersistentTxTsMaxState {
3767    #[default]
3768    Start,
3769    Running {
3770        stmt: Box<crate::Statement>,
3771        value: Option<i64>,
3772    },
3773}
3774
3775/// Sub state machine for [`MvStore::initialize_mvcc_metadata_table_nonblock`].
3776/// Sequences the CREATE TABLE then INSERT statements, holding each prepared
3777/// statement across IO yields.
3778#[derive(Default)]
3779pub enum InitMetadataTableState {
3780    #[default]
3781    Start,
3782    CreateTable {
3783        stmt: Box<crate::Statement>,
3784    },
3785    Insert {
3786        stmt: Box<crate::Statement>,
3787    },
3788}
3789
3790/// Sub state machine for [`MvStore::maybe_recover_logical_log`]. The
3791/// setup phases (header read, persistent-tx-ts read, schema-cookie read,
3792/// sqlite_schema scan) each yield IO; the `Replay` phase carries the loop
3793/// accumulators in [`RecoverCtx`] across per-frame `next_frame` yields.
3794#[derive(Default)]
3795pub enum RecoverLogicalLogState {
3796    #[default]
3797    Start,
3798    ReadHeader {
3799        reader: Box<StreamingLogicalLogReader>,
3800        preserved_tvfs: Vec<Arc<crate::vtab::VirtualTable>>,
3801    },
3802    ReadTxTs {
3803        reader: Box<StreamingLogicalLogReader>,
3804        preserved_tvfs: Vec<Arc<crate::vtab::VirtualTable>>,
3805        header_present: bool,
3806        txts_st: ReadPersistentTxTsMaxState,
3807    },
3808    ReadCookie {
3809        reader: Box<StreamingLogicalLogReader>,
3810        preserved_tvfs: Vec<Arc<crate::vtab::VirtualTable>>,
3811        persistent_tx_ts_max: u64,
3812    },
3813    QuerySchema {
3814        reader: Box<StreamingLogicalLogReader>,
3815        preserved_tvfs: Vec<Arc<crate::vtab::VirtualTable>>,
3816        persistent_tx_ts_max: u64,
3817        cookie: u32,
3818        stmt: Option<Box<crate::Statement>>,
3819        schema_rows: HashMap<i64, ImmutableRecord>,
3820    },
3821    Replay {
3822        ctx: Box<RecoverCtx>,
3823    },
3824    Done,
3825}
3826
3827/// Accumulated state of the logical-log replay loop, persisted across
3828/// `next_frame` IO yields. See [`MvStore::maybe_recover_logical_log`].
3829pub struct RecoverCtx {
3830    reader: Box<StreamingLogicalLogReader>,
3831    preserved_table_valued_functions: Vec<Arc<crate::vtab::VirtualTable>>,
3832    /// Fallback schema cookie (pre-read) used by `recover_build_schema` only
3833    /// when `global_header` is unset.
3834    cookie: u32,
3835    persistent_tx_ts_max: u64,
3836    replay_cutoff_ts: u64,
3837    max_commit_ts_seen: u64,
3838    schema_rows: HashMap<i64, ImmutableRecord>,
3839    dropped_root_pages: HashSet<i64>,
3840    current_schema: Arc<Schema>,
3841    index_infos: HashMap<(MVTableId, IndexOpKind), Arc<IndexInfo>>,
3842}
3843
3844/// WAL position `(checkpoint_seq, frame)`. Ordered lexicographically for physical reachability.
3845#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
3846pub struct WalPos {
3847    pub checkpoint_seq: u32,
3848    pub frame: u64,
3849}
3850
3851impl WalPos {
3852    /// In the durable base from the very beginning — reachable by every reader.
3853    pub const ORIGIN: WalPos = WalPos {
3854        checkpoint_seq: 0,
3855        frame: 0,
3856    };
3857    /// Staged / not-yet-committed sentinel: greater than any real reader's mark, so unreachable.
3858    pub const STAGED: WalPos = WalPos {
3859        checkpoint_seq: u32::MAX,
3860        frame: u64::MAX,
3861    };
3862
3863    pub fn from_pair((checkpoint_seq, frame): (u32, u64)) -> Self {
3864        Self {
3865            checkpoint_seq,
3866            frame,
3867        }
3868    }
3869}
3870
3871/// Versioned `table_id -> root_page` binding (`begin`/`end` = snapshot lifetime).
3872#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3873pub struct RootEntry {
3874    pub root_page: Option<u64>,
3875    pub begin: u64,
3876    pub end: u64,
3877    /// When this binding's B-tree pages became durable in the WAL (`ORIGIN` = always in base).
3878    pub materialized_at: WalPos,
3879}
3880
3881impl RootEntry {
3882    /// A live binding visible to every snapshot (bootstrap/recovery/uncheckpointed-create).
3883    pub fn live(root_page: Option<u64>) -> Self {
3884        Self {
3885            root_page,
3886            begin: 0,
3887            end: u64::MAX,
3888            materialized_at: WalPos::ORIGIN,
3889        }
3890    }
3891
3892    /// Still live (not yet dropped).
3893    pub fn is_live(&self) -> bool {
3894        self.end == u64::MAX
3895    }
3896
3897    /// Whether this binding is logically visible to a transaction at snapshot `ts`
3898    /// (`begin <= ts` and, unless live, `ts < end`). This is base-validity only; for a btree
3899    /// *read* also require physical reachability via [`Self::materialized_at`].
3900    pub fn covers(&self, ts: u64) -> bool {
3901        self.begin <= ts && (self.end == u64::MAX || ts < self.end)
3902    }
3903}
3904
3905/// A multi-version concurrency control database.
3906#[derive(Debug)]
3907pub struct MvStore<Clock: LogicalClock, A: ConcurrentAllocator = TursoAllocator> {
3908    pub rows: SkipMap<RowID, RowVersions<A>, BasicComparator, A>,
3909    /// Table ID is an opaque identifier that is only meaningful to the MV store.
3910    /// Each checkpointed MVCC table corresponds to a single B-tree on the pager,
3911    /// which naturally has a root page.
3912    /// We cannot use root page as the MVCC table ID directly because:
3913    /// - We assign table IDs during MVCC commit, but
3914    /// - we commit pages to the pager only during checkpoint
3915    ///
3916    /// which means the root page is not easily knowable ahead of time.
3917    /// Hence, we store the mapping here.
3918    /// The value is Option because tables created in an MVCC commit that have not
3919    /// been checkpointed yet have no real root page assigned yet.
3920    ///
3921    /// Versioned root bindings; passive checkpoints update these at publish, not during collection.
3922    pub table_id_to_rootpage: SkipMap<MVTableId, RootEntry, BasicComparator, A>,
3923    /// Unlike table rows which are stored in a single map, we have a separate map for every index
3924    /// because operations like last() on an index are much easier when we don't have to take the
3925    /// table identifier into account.
3926    pub index_rows: SkipMap<MVTableId, IndexRowsMap<A>, BasicComparator, A>,
3927    /// Bumped whenever the key set of `index_rows` may change (every
3928    /// [`Self::insert_index_version`], which is the single funnel through which
3929    /// new index keys are created). Forward-scan cursors snapshot this next to
3930    /// their [`crate::mvcc::cursor::IndexShadowFinger`] and reset the finger on
3931    /// a mismatch, since a key inserted at or behind an already-positioned
3932    /// finger would otherwise be skipped (#7578).
3933    index_rows_epoch: AtomicU64,
3934    txs: SkipMap<TxID, Transaction<A>, BasicComparator, A>,
3935    /// Final state for removed transactions. Readers may still race with stale TxID
3936    /// references in row versions after a transaction is removed from `txs`.
3937    finalized_tx_states: SkipMap<TxID, TransactionState, BasicComparator, A>,
3938    /// Allocator backing every skiplist in this store, including lazily
3939    /// created per-index maps in `index_rows`.
3940    alloc: A,
3941    tx_ids: AtomicU64,
3942    version_id_counter: AtomicU64,
3943    next_rowid: AtomicU64,
3944    next_table_id: AtomicI64,
3945    clock: Clock,
3946
3947    /// MVCC durable storage (logical log writes, checkpoint thresholding, recovery state).
3948    ///
3949    /// Stored behind a trait object so callers can inject their own implementation
3950    /// per database (via `Database::durable_storage`) for testing or custom durability.
3951    storage: Arc<dyn crate::mvcc::persistent_storage::DurableStorage>,
3952
3953    /// The transaction ID of a transaction that has acquired an exclusive write lock, if any.
3954    ///
3955    /// An exclusive MVCC transaction is one that has a write lock on the pager, which means
3956    /// every other MVCC transaction must wait for it to commit before they can commit. We have
3957    /// exclusive transactions to support single-writer semantics for compatibility with SQLite.
3958    ///
3959    /// If there is no exclusive transaction, the field is set to `NO_EXCLUSIVE_TX`.
3960    exclusive_tx: AtomicU64,
3961    commit_coordinator: Arc<CommitCoordinator>,
3962    global_header: Arc<RwLock<Option<DatabaseHeader>>>,
3963    /// Held by checkpoints only during the brief in-memory publish phase; the I/O-heavy
3964    /// MvStore → WAL write-out runs unlocked, so concurrent `BEGIN CONCURRENT`s aren't
3965    /// blocked. Phases: (unlocked) snapshot + collect + write + commit pager txn;
3966    /// (locked) publish durable_txid_max / global_header / schema roots; (unlocked) GC,
3967    /// CheckpointWal, truncate logical log, TruncateWal.
3968    blocking_checkpoint_lock: Arc<TursoRwLock>,
3969    /// Passive publish drain: set for the brief in-memory publish window so new `begin_tx`
3970    /// calls contend out instead of pinning a lifetime checkpoint read guard.
3971    checkpoint_publish_in_progress: AtomicBool,
3972    /// Bumped when a passive checkpoint publishes physical btree roots into the shared schema.
3973    /// Open transactions compare their captured value and get [`LimboError::SchemaUpdated`].
3974    schema_generation: AtomicU64,
3975    /// Single-orchestrator gate: set while a CheckpointStateMachine runs its unlocked
3976    /// write-out phase, cleared on completion/error. Commits racing `should_checkpoint()`
3977    /// contend on it; only one wins. Needed because the lock no longer guards the start
3978    /// of the checkpoint (it's acquired after the pager-write phase, not before).
3979    checkpoint_in_progress: AtomicBool,
3980    /// The highest transaction ID that has been made durable in the WAL.
3981    /// Used to skip checkpointing transactions from mv store to WAL that have already been processed.
3982    durable_txid_max: AtomicU64,
3983    /// The WAL backfill boundary published by the most recent checkpoint: a version materialized at
3984    /// or below this `WalPos` is durably in the DB file, so reachable by EVERY snapshot (including
3985    /// a db-file reader pinned at the boundary). The passive checkpoint GC reclaims a materialized version
3986    /// only once its `materialized_at <= backfill_floor` — otherwise a low-frame reader that
3987    /// cannot reach the un-backfilled WAL frame still needs the version-store copy. See
3988    /// `gc_version_chain` / `gc_floor_reader_mark`. `RwLock<WalPos>` mirrors `global_header`.
3989    backfill_floor: Arc<RwLock<WalPos>>,
3990    /// The timestamp of the last committed schema change.
3991    /// Schema changes always cause a [SchemaUpdated] error.
3992    last_committed_schema_change_ts: AtomicU64,
3993    /// The timestamp of the last committed transaction.
3994    /// If there are two concurrent BEGIN (non-CONCURRENT) transactions, and one tries to promote
3995    /// to exclusive, it will abort if another transaction committed after its begin timestamp.
3996    last_committed_tx_ts: AtomicU64,
3997    /// `end_ts` of the most recent tx whose header was written into
3998    /// `global_header`. Used to gate header writes at both
3999    /// `EndCommitLogicalLog` and `FinalizeCommit` so an older commit
4000    /// finishing after a newer one cannot regress
4001    /// `global_header.schema_cookie` below the latest committed value
4002    /// — which would break `maybe_reparse_schema`'s cookie-mismatch
4003    /// early-exit and strand readers in a reparse-WaitForDependencies
4004    /// deadlock.
4005    last_global_header_ts: AtomicU64,
4006    table_id_to_last_rowid: RwLock<HashMap<MVTableId, Arc<RowidAllocator>>>,
4007    /// Per-sequence first value not guaranteed safe to read past based only on
4008    /// durable/current sequence state. Active allocations can lower this.
4009    sequence_watermarks: Mutex<HashMap<String, i64>>,
4010    /// Per-sequence minimum allocated value for each active transaction.
4011    ///
4012    /// This is in-memory and therefore only correct while all MVCC writers for a
4013    /// database live in one process. Multi-process MVCC will need a shared
4014    /// coordination mechanism before sync can rely on this watermark.
4015    sequence_allocations: Mutex<HashMap<String, StdHashMap<TxID, i64>>>,
4016
4017    /// Approximate count of live row versions across `rows` + `index_rows`.
4018    /// Incremented on every inserted version, decremented when versions are
4019    /// reclaimed (GC) or physically removed (savepoint rollback, checkpoint
4020    /// purge). This is a *heuristic* used only to decide when to run an
4021    /// incremental GC pass (`should_gc`) — never a correctness input. Aborted
4022    /// versions are left counted until GC reclaims them (they still occupy
4023    /// memory). See [`Self::live_version_count_approx`].
4024    live_version_count_approx: AtomicUsize,
4025    /// Snapshot of `live_version_count_approx` taken at the end of the last
4026    /// `gc_incremental` pass. `should_gc` fires once growth since this snapshot
4027    /// crosses `gc_version_threshold`, giving a roughly fixed GC cadence per N
4028    /// new versions regardless of how many a single pass reclaims.
4029    live_versions_at_last_gc: AtomicUsize,
4030    /// Growth in `live_version_count_approx` (number of newly inserted versions since
4031    /// the last GC pass) that triggers an incremental GC pass on the commit
4032    /// path. Negative disables inline GC entirely. Configurable via the
4033    /// `mvcc_gc_threshold` PRAGMA; mirrors `checkpoint_threshold`.
4034    gc_version_threshold: AtomicI64,
4035    /// Resume cursor for the incremental table-row GC sweep: the last `RowID`
4036    /// processed by the previous `gc_incremental` pass. `None` restarts the
4037    /// sweep from the beginning of `rows` (and marks the point where index
4038    /// chains are swept — see `gc_incremental`).
4039    gc_table_cursor: Mutex<Option<RowID>>,
4040    /// Resume cursor for the incremental index-row GC sweep: the last
4041    /// `(index id, key)` processed by the previous `gc_incremental` pass.
4042    /// `None` restarts from the beginning of `index_rows`. Mirrors
4043    /// `gc_table_cursor` but over the nested `index_rows` maps, so a single
4044    /// huge index can't force an unbounded pass.
4045    gc_index_cursor: Mutex<Option<(MVTableId, Arc<SortableIndexKey>)>>,
4046    /// Single-flight gate for inline GC. Many connections commit concurrently
4047    /// (each shares this `MvStore`), and the commit path calls
4048    /// `gc_incremental` without holding any global lock — so without this gate
4049    /// several threads would run overlapping GC passes at once. That is *safe*
4050    /// (per-chain write locks + idempotent reclamation) but wasteful: redundant
4051    /// scans plus thrashing of `gc_table_cursor`. This flag ensures at most one
4052    /// inline pass runs at a time; a committer that loses the race simply skips
4053    /// GC for that commit.
4054    gc_in_progress: AtomicBool,
4055    /// LWM observed by the last inline GC pass that actually ran. When a
4056    /// long-running transaction pins the LWM, re-scanning at the same LWM
4057    /// reclaims nothing new — any version superseded since then has
4058    /// `end_ts > LWM` (the pinning txn is older) and stays visible. So an inline
4059    /// pass short-circuits when the LWM hasn't advanced, avoiding wasted scans
4060    /// while a long txn is open. `u64::MAX` means "no pass has run yet" and also
4061    /// the no-active-txn state, which never short-circuits (there is always
4062    /// potential garbage to reclaim then). Aborted garbage and post-checkpoint
4063    /// sole-survivors that a skipped pass leaves behind are still collected by
4064    /// the checkpoint's full sweep.
4065    gc_last_lwm: AtomicU64,
4066    experimental_mvcc_passive_checkpoint: bool,
4067}
4068
4069impl<Clock: LogicalClock> MvStore<Clock> {
4070    /// Creates a new database backed by the default [`TursoAllocator`].
4071    pub fn new(
4072        clock: Clock,
4073        storage: Arc<dyn crate::mvcc::persistent_storage::DurableStorage>,
4074        experimental_mvcc_passive_checkpoint: bool,
4075    ) -> Result<Self> {
4076        Self::new_in(
4077            clock,
4078            storage,
4079            TursoAllocator,
4080            experimental_mvcc_passive_checkpoint,
4081        )
4082    }
4083}
4084
4085impl<Clock: LogicalClock, A: ConcurrentAllocator> MvStore<Clock, A> {
4086    pub(crate) fn allocator(&self) -> A {
4087        self.alloc.clone()
4088    }
4089
4090    fn uses_durable_mvcc_metadata(&self, connection: &Arc<Connection>) -> bool {
4091        !connection.db.is_in_memory_db()
4092    }
4093
4094    /// Captures table-valued functions (e.g. generate_series) from the schema before
4095    /// reparse_schema() drops them. Built-in TVFs are registered programmatically and
4096    /// don't survive schema re-parsing from sqlite_schema; we save and re-inject them.
4097    fn capture_table_valued_functions(schema: &Schema) -> Vec<Arc<crate::vtab::VirtualTable>> {
4098        schema
4099            .tables
4100            .values()
4101            .filter_map(|table| match table.as_ref() {
4102                Table::Virtual(vtab)
4103                    if matches!(vtab.kind, turso_ext::VTabKind::TableValuedFunction) =>
4104                {
4105                    Some(vtab.clone())
4106                }
4107                _ => None,
4108            })
4109            .collect()
4110    }
4111
4112    fn rehydrate_table_valued_functions(
4113        schema: &mut Schema,
4114        table_valued_functions: &[Arc<crate::vtab::VirtualTable>],
4115    ) {
4116        for vtab in table_valued_functions {
4117            let normalized_name = crate::util::normalize_ident(&vtab.name);
4118            schema
4119                .tables
4120                .entry(normalized_name)
4121                .or_insert_with(|| Arc::new(Table::Virtual(vtab.clone())));
4122        }
4123    }
4124
4125    fn rehydrate_connection_table_valued_functions(
4126        &self,
4127        connection: &Arc<Connection>,
4128        table_valued_functions: &[Arc<crate::vtab::VirtualTable>],
4129    ) -> Result<()> {
4130        connection.with_schema_mut(|schema| {
4131            Self::rehydrate_table_valued_functions(schema, table_valued_functions);
4132        })?;
4133        *connection.db.schema.lock() = connection.schema.read().clone();
4134        Ok(())
4135    }
4136
4137    /// Creates a new database whose skiplists allocate through `alloc`.
4138    pub fn new_in(
4139        clock: Clock,
4140        storage: Arc<dyn crate::mvcc::persistent_storage::DurableStorage>,
4141        alloc: A,
4142        experimental_mvcc_passive_checkpoint: bool,
4143    ) -> Result<Self> {
4144        let table_id_to_rootpage = SkipMap::new_in(alloc.clone());
4145        // table id 1 / root page 1 is always sqlite_schema.
4146        table_id_to_rootpage.try_insert(SQLITE_SCHEMA_MVCC_TABLE_ID, RootEntry::live(Some(1)))?;
4147        Ok(Self {
4148            rows: SkipMap::new_in(alloc.clone()),
4149            table_id_to_rootpage,
4150            index_rows: SkipMap::new_in(alloc.clone()),
4151            index_rows_epoch: AtomicU64::new(0),
4152            txs: SkipMap::new_in(alloc.clone()),
4153            finalized_tx_states: SkipMap::new_in(alloc.clone()),
4154            alloc,
4155            tx_ids: AtomicU64::new(1), // let's reserve transaction 0 for special purposes
4156            version_id_counter: AtomicU64::new(1), // Reserve 0 for special purposes
4157            next_rowid: AtomicU64::new(0), // TODO: determine this from B-Tree
4158            next_table_id: AtomicI64::new(-2), // table id -1 / root page 1 is always sqlite_schema.
4159            clock,
4160            storage,
4161            exclusive_tx: AtomicU64::new(NO_EXCLUSIVE_TX),
4162            commit_coordinator: Arc::new(CommitCoordinator::new()),
4163            global_header: Arc::new(RwLock::new(None)),
4164            backfill_floor: Arc::new(RwLock::new(WalPos::ORIGIN)),
4165            blocking_checkpoint_lock: Arc::new(TursoRwLock::new()),
4166            checkpoint_publish_in_progress: AtomicBool::new(false),
4167            schema_generation: AtomicU64::new(0),
4168            checkpoint_in_progress: AtomicBool::new(false),
4169            durable_txid_max: AtomicU64::new(0),
4170            last_committed_schema_change_ts: AtomicU64::new(0),
4171            last_committed_tx_ts: AtomicU64::new(0),
4172            last_global_header_ts: AtomicU64::new(0),
4173            table_id_to_last_rowid: RwLock::new(HashMap::default()),
4174            sequence_watermarks: Mutex::new(HashMap::default()),
4175            sequence_allocations: Mutex::new(HashMap::default()),
4176            live_version_count_approx: AtomicUsize::new(0),
4177            live_versions_at_last_gc: AtomicUsize::new(0),
4178            gc_version_threshold: AtomicI64::new(Self::DEFAULT_GC_VERSION_THRESHOLD),
4179            gc_table_cursor: Mutex::new(None),
4180            gc_index_cursor: Mutex::new(None),
4181            gc_in_progress: AtomicBool::new(false),
4182            gc_last_lwm: AtomicU64::new(u64::MAX),
4183            experimental_mvcc_passive_checkpoint,
4184        })
4185    }
4186
4187    /// Get the table ID from the root page, resolving against the current (live) mapping.
4188    /// Equivalent to `get_table_id_from_root_page_at(root_page, u64::MAX)`.
4189    pub fn get_table_id_from_root_page(&self, root_page: i64) -> MVTableId {
4190        self.get_table_id_from_root_page_at(root_page, u64::MAX)
4191    }
4192
4193    /// Get the table ID for `root_page` as seen by a transaction at `snapshot_ts`.
4194    ///
4195    /// Negative root pages are non-checkpointed objects whose table ID equals the root page;
4196    /// they are never reused or versioned, so the snapshot is irrelevant.
4197    ///
4198    /// For a positive (checkpointed) root page, a PASSIVE checkpoint may have dropped the
4199    /// object — and possibly reused the page for a new btree — while this transaction still
4200    /// references it at an older snapshot. Successive owners of a page hold disjoint,
4201    /// back-to-back lifetimes; we return the owner whose binding has not yet ended at the
4202    /// snapshot (smallest `end > ts`, live counting as `+inf`). We deliberately do NOT gate on
4203    /// `begin` here: a transaction's *physical* schema (root pages) can run ahead of its *data*
4204    /// snapshot, because a checkpoint allocating a root page is not a logical schema change.
4205    /// Whether the btree should actually be read at the snapshot is decided separately by
4206    /// [`Self::is_btree_allocated_at`] / [`Self::resolve_root_page_at`], which do gate on
4207    /// `begin`. `u64::MAX` resolves the current live owner.
4208    pub fn get_table_id_from_root_page_at(&self, root_page: i64, snapshot_ts: u64) -> MVTableId {
4209        self.try_get_table_id_from_root_page_at(root_page, snapshot_ts)
4210            .unwrap_or_else(|| {
4211                panic!("Positive root page is not mapped to a table id: {root_page}")
4212            })
4213    }
4214
4215    /// Fallible variant of [`Self::get_table_id_from_root_page_at`]: returns `None` when a
4216    /// positive root page has no binding that covers `snapshot_ts`. Under a PASSIVE checkpoint
4217    /// this is not an invariant violation but a stale-schema read: the transaction captured an
4218    /// older `schema_cookie` (the commit that dropped this object published its cookie after the
4219    /// transaction read the header, even though the drop's commit ts precedes the transaction's
4220    /// begin ts) and compiled a cursor against a table its own snapshot already sees dropped.
4221    /// The caller turns this into [`LimboError::SchemaUpdated`] so the statement reprepares
4222    /// against the current schema. See the begin/commit schema-coherence note in the passive
4223    /// checkpoint design.
4224    pub fn try_get_table_id_from_root_page_at(
4225        &self,
4226        root_page: i64,
4227        snapshot_ts: u64,
4228    ) -> Option<MVTableId> {
4229        if root_page < 0 {
4230            // Not checkpointed table - table ID and root_page are both the same negative value
4231            return Some(root_page.into());
4232        }
4233        let root_page = root_page as u64;
4234        self.table_id_to_rootpage
4235            .iter()
4236            .filter(|entry| {
4237                let e = entry.value();
4238                e.root_page == Some(root_page) && (e.is_live() || snapshot_ts < e.end)
4239            })
4240            .min_by_key(|entry| entry.value().end)
4241            .map(|entry| *entry.key())
4242    }
4243
4244    /// Snapshot timestamp (`begin_ts`) of the given transaction, or `u64::MAX` if it is not
4245    /// tracked (resolving the live root-page binding). Used to make a transaction's root-page
4246    /// lookups snapshot-consistent.
4247    pub fn read_snapshot_ts(&self, tx_id: TxID) -> u64 {
4248        self.txs
4249            .get(&tx_id)
4250            .map(|tx| tx.value().begin_ts)
4251            .unwrap_or(u64::MAX)
4252    }
4253
4254    /// This transaction's frozen WAL read mark, or [`WalPos::STAGED`] (sees everything published)
4255    /// if untracked. The physical-reachability coordinate of the btree-read gate. See
4256    /// [`Self::is_btree_readable_at`].
4257    pub fn read_tx_mark(&self, tx_id: TxID) -> WalPos {
4258        self.txs
4259            .get(&tx_id)
4260            .map(|tx| tx.value().read_mark)
4261            .unwrap_or(WalPos::STAGED)
4262    }
4263
4264    /// Bump `next_table_id` below `table_id` (and below `-root_page` for a checkpointed root) so
4265    /// recovery's `table_id = -root_page` assignment can never collide with an existing id.
4266    fn bump_next_table_id_below(&self, table_id: MVTableId, root_page: Option<u64>) {
4267        let minimum: i64 = if let Some(root_page) = root_page {
4268            let root_page_as_table_id = MVTableId::from(-(root_page as i64));
4269            table_id.min(root_page_as_table_id).into()
4270        } else {
4271            table_id.into()
4272        };
4273        if minimum <= self.next_table_id.load(Ordering::SeqCst) {
4274            self.next_table_id.store(minimum - 1, Ordering::SeqCst);
4275        }
4276    }
4277
4278    /// Insert a live `table_id -> root_page` binding (bootstrap/recovery, or an
4279    /// uncheckpointed-create with `None`). Visible to every snapshot. Checkpoint-time
4280    /// allocation of a real root page goes through [`Self::record_rootpage_alloc`] instead.
4281    pub fn insert_table_id_to_rootpage(&self, table_id: MVTableId, root_page: Option<u64>) {
4282        self.table_id_to_rootpage
4283            .insert(table_id, RootEntry::live(root_page));
4284        self.bump_next_table_id_below(table_id, root_page);
4285    }
4286
4287    pub fn remove_table_id_to_rootpage(&self, table_id: &MVTableId) {
4288        self.table_id_to_rootpage.remove(table_id);
4289        self.table_id_to_last_rowid.write().remove(table_id);
4290    }
4291
4292    /// The current physical root page of `table_id`, if it is checkpointed and live.
4293    pub fn current_root_page(&self, table_id: &MVTableId) -> Option<u64> {
4294        self.table_id_to_rootpage
4295            .get(table_id)
4296            .and_then(|entry| entry.value().root_page)
4297    }
4298
4299    /// Record that a PASSIVE checkpoint allocated `root_page` for `table_id`. `begin_ts` is the
4300    /// checkpoint's snapshot ts (base-validity lower bound); `materialized_at` is the WAL position
4301    /// the pages reach durability at — [`WalPos::STAGED`] at `btree_create` (pages not committed
4302    /// yet), lowered to the real position by [`Self::publish_rootpage_visible`] in the
4303    /// post-`CommitPagerTxn` publish window.
4304    pub fn record_rootpage_alloc(
4305        &self,
4306        table_id: MVTableId,
4307        root_page: u64,
4308        begin_ts: u64,
4309        materialized_at: WalPos,
4310    ) {
4311        self.table_id_to_rootpage.insert(
4312            table_id,
4313            RootEntry {
4314                root_page: Some(root_page),
4315                begin: begin_ts,
4316                end: u64::MAX,
4317                materialized_at,
4318            },
4319        );
4320        // A page has one live owner. Claiming this page means it was freed+reused; retire any
4321        // stale prior owner still marked live for it (drop-time retire raced collection),
4322        // else two live bindings resolve to one page (integrity_check: referenced twice).
4323        let stale: Vec<(MVTableId, RootEntry)> = self
4324            .table_id_to_rootpage
4325            .iter()
4326            .filter(|entry| {
4327                let e = entry.value();
4328                e.is_live() && e.root_page == Some(root_page) && *entry.key() != table_id
4329            })
4330            .map(|entry| (*entry.key(), *entry.value()))
4331            .collect();
4332        for (key, mut entry) in stale {
4333            entry.end = begin_ts;
4334            self.table_id_to_rootpage.insert(key, entry);
4335        }
4336        self.bump_next_table_id_below(table_id, Some(root_page));
4337    }
4338
4339    /// Publish a staged root-page binding: set its `materialized_at` from [`WalPos::STAGED`] to the
4340    /// WAL position the pages were committed at, making the btree physically readable by any
4341    /// transaction whose read mark reaches that position. Called in the checkpoint's post-commit
4342    /// publish window. No-op if the entry is gone (e.g. dropped same checkpoint).
4343    pub fn publish_rootpage_visible(&self, table_id: MVTableId, materialized_at: WalPos) {
4344        if let Some(entry) = self.table_id_to_rootpage.get(&table_id) {
4345            let mut e = *entry.value();
4346            e.materialized_at = materialized_at;
4347            self.table_id_to_rootpage.insert(table_id, e);
4348        }
4349    }
4350
4351    /// Close `table_id`'s binding at `end_ts` (the drop tombstone commit ts) but keep it, so a
4352    /// transaction whose snapshot predates the drop can still resolve the (read-mark-protected)
4353    /// root page. Reclaimed by [`Self::gc_rootpage_entries`] once `end_ts <= lwm`.
4354    pub fn retire_rootpage(&self, table_id: MVTableId, end_ts: u64) {
4355        if let Some(entry) = self.table_id_to_rootpage.get(&table_id) {
4356            let mut e = *entry.value();
4357            e.end = end_ts;
4358            self.table_id_to_rootpage.insert(table_id, e);
4359        }
4360    }
4361
4362    /// Drop closed (retired) bindings no transaction can still see (dropped, with
4363    /// `end <= lwm`). Live bindings have `end == u64::MAX` and are never reclaimed — important
4364    /// because `compute_lwm()` is `u64::MAX` when no transactions are active. Returns count.
4365    pub fn gc_rootpage_entries(&self, lwm: u64) -> usize {
4366        let stale: Vec<MVTableId> = self
4367            .table_id_to_rootpage
4368            .iter()
4369            .filter(|entry| {
4370                let e = entry.value();
4371                !e.is_live() && e.end <= lwm
4372            })
4373            .map(|entry| *entry.key())
4374            .collect();
4375        for key in &stale {
4376            self.table_id_to_rootpage.remove(key);
4377            self.table_id_to_last_rowid.write().remove(key);
4378        }
4379        stale.len()
4380    }
4381
4382    /// Acquire MVCC's stop-the-world gate for VACUUM.
4383    ///
4384    /// This is the same lock used by MVCC checkpointing. All MVCC transactions
4385    /// hold it in read mode for their whole lifetime, so acquiring it in write
4386    /// mode proves there are no active MVCC transactions and prevents new ones
4387    /// from starting until VACUUM releases it.
4388    pub(crate) fn try_begin_vacuum_gate(&self) -> Result<()> {
4389        if !self.blocking_checkpoint_lock.write() {
4390            return Err(LimboError::Busy);
4391        }
4392        turso_assert!(
4393            self.txs.is_empty(),
4394            "MVCC vacuum gate acquired while transactions are still active"
4395        );
4396        Ok(())
4397    }
4398
4399    /// Release the MVCC stop-the-world gate acquired by `try_begin_vacuum_gate`.
4400    pub(crate) fn release_vacuum_gate(&self) {
4401        self.blocking_checkpoint_lock.unlock();
4402    }
4403
4404    /// VACUUM copies the physical DB image, so any MVCC logical-log bytes must
4405    /// be checkpointed first.
4406    pub(crate) fn has_uncheckpointed_log(&self) -> Result<bool> {
4407        Ok(self.get_logical_log_file().size()? != 0)
4408    }
4409
4410    /// Rebuild MVCC's physical root-page metadata after in-place VACUUM
4411    /// reparses the rewritten B-tree image and stages the committed page-1
4412    /// header that now owns the physical schema cookie.
4413    ///
4414    /// The caller must hold the MVCC vacuum gate and pass both the committed
4415    /// page-1 header and the schema parsed from the post-VACUUM physical
4416    /// database image.
4417    pub(crate) fn reset_after_vacuum(&self, header: DatabaseHeader, schema: &Schema) {
4418        turso_assert!(
4419            self.txs.is_empty(),
4420            "MVCC VACUUM reset requires no active transactions"
4421        );
4422        // see the test `test_mvcc_plain_vacuum_active_write_tx_returns_busy`
4423        self.drop_unused_row_versions();
4424        let has_table_versions = self
4425            .rows
4426            .iter()
4427            .any(|entry| !entry.value().read().is_empty());
4428        turso_assert!(
4429            !has_table_versions,
4430            "MVCC VACUUM reset requires checkpointed table versions to be cleared"
4431        );
4432        let has_index_versions = self.index_rows.iter().any(|index_entry| {
4433            index_entry
4434                .value()
4435                .iter()
4436                .any(|entry| !entry.value().read().is_empty())
4437        });
4438        turso_assert!(
4439            !has_index_versions,
4440            "MVCC VACUUM reset requires checkpointed index versions to be cleared"
4441        );
4442        turso_assert!(
4443            self.finalized_tx_states.is_empty(),
4444            "MVCC VACUUM reset requires finalized transaction cache to be cleared"
4445        );
4446        // Drop empty buckets left by checkpoint GC: their table_ids reference
4447        // pre-VACUUM root pages and can alias new objects after root-page
4448        // reuse, corrupting `index_rows` lookups and SkipMap ordering.
4449        self.rows.clear();
4450        self.index_rows.clear();
4451        let root_pages = schema
4452            .tables
4453            .values()
4454            .filter_map(|table| match table.as_ref() {
4455                Table::BTree(btree) => Some(btree.root_page),
4456                _ => None,
4457            })
4458            .chain(
4459                schema
4460                    .indexes
4461                    .values()
4462                    .flatten()
4463                    .map(|index| index.root_page),
4464            )
4465            .collect::<Vec<_>>();
4466        for &root_page in &root_pages {
4467            turso_assert!(
4468                root_page > 0,
4469                "post-VACUUM B-tree root page must be positive"
4470            );
4471        }
4472        // Clears live and retired bindings alike: both reference pre-VACUUM root pages that
4473        // would alias new objects after root-page reuse.
4474        self.table_id_to_rootpage.clear();
4475        self.table_id_to_last_rowid.write().clear();
4476        // TODO: vacuum related code, not handling alloc errors for now
4477        self.insert_table_id_to_rootpage(SQLITE_SCHEMA_MVCC_TABLE_ID, Some(1));
4478        for root_page in root_pages {
4479            let table_id = MVTableId::from(-root_page);
4480            self.insert_table_id_to_rootpage(table_id, Some(root_page as u64));
4481        }
4482        self.global_header.write().replace(header);
4483    }
4484
4485    /// Creates the `__turso_internal_mvcc_meta` table and seeds it with
4486    /// `persistent_tx_ts_max` (initialized to 0). This table stores the durable replay
4487    /// boundary: on recovery, only logical-log frames with `commit_ts > persistent_tx_ts_max`
4488    /// are replayed. Called once during first MVCC bootstrap.
4489    /// Creates and seeds the MVCC metadata table, driving the CREATE TABLE then
4490    /// INSERT statements cooperatively via the supplied [`InitMetadataTableState`]
4491    /// so bootstrap does not block on backends without a synchronous IO pump.
4492    fn initialize_mvcc_metadata_table_nonblock(
4493        &self,
4494        connection: &Arc<Connection>,
4495        st: &mut InitMetadataTableState,
4496    ) -> Result<IOResult<()>> {
4497        loop {
4498            match st {
4499                InitMetadataTableState::Start => {
4500                    let stmt = connection.prepare(format!(
4501                        "CREATE TABLE IF NOT EXISTS {MVCC_META_TABLE_NAME}(k TEXT, v INTEGER NOT NULL)"
4502                    ))?;
4503                    *st = InitMetadataTableState::CreateTable {
4504                        stmt: Box::new(stmt),
4505                    };
4506                }
4507                InitMetadataTableState::CreateTable { stmt } => {
4508                    return_if_io!(stmt.run_ignore_rows_nonblock());
4509                    let stmt = connection.prepare(format!(
4510                        "INSERT OR IGNORE INTO {MVCC_META_TABLE_NAME}(rowid, k, v) VALUES (1, '{MVCC_META_KEY_PERSISTENT_TX_TS_MAX}', 0)"
4511                    ))?;
4512                    *st = InitMetadataTableState::Insert {
4513                        stmt: Box::new(stmt),
4514                    };
4515                }
4516                InitMetadataTableState::Insert { stmt } => {
4517                    return_if_io!(stmt.run_ignore_rows_nonblock());
4518                    *st = InitMetadataTableState::Start;
4519                    return Ok(IOResult::Done(()));
4520                }
4521            }
4522        }
4523    }
4524
4525    /// Shared validation/normalization for the persistent-tx-ts-max metadata
4526    /// value used by both the blocking and non-blocking readers.
4527    fn validate_persistent_tx_ts_max(value: Option<i64>) -> Result<Option<u64>> {
4528        let value = value.ok_or_else(|| {
4529            LimboError::Corrupt(format!(
4530                "Missing MVCC metadata row for key {MVCC_META_KEY_PERSISTENT_TX_TS_MAX}"
4531            ))
4532        })?;
4533
4534        if value < 0 {
4535            return Err(LimboError::Corrupt(format!(
4536                "Invalid MVCC metadata value for {MVCC_META_KEY_PERSISTENT_TX_TS_MAX}: {value}"
4537            )));
4538        }
4539        Ok(Some(value as u64))
4540    }
4541
4542    /// Non-blocking variant of [`Self::try_read_persistent_tx_ts_max`]: runs the
4543    /// metadata SELECT cooperatively via the supplied
4544    /// [`ReadPersistentTxTsMaxState`], yielding IO instead of pumping it. Returns
4545    /// `None` if the metadata table does not exist yet.
4546    fn try_read_persistent_tx_ts_max_nonblock(
4547        &self,
4548        connection: &Arc<Connection>,
4549        st: &mut ReadPersistentTxTsMaxState,
4550    ) -> Result<IOResult<Option<u64>>> {
4551        loop {
4552            match st {
4553                ReadPersistentTxTsMaxState::Start => {
4554                    let query_result = connection.query(format!(
4555                        "SELECT v FROM {MVCC_META_TABLE_NAME}
4556                         WHERE k = '{MVCC_META_KEY_PERSISTENT_TX_TS_MAX}'"
4557                    ));
4558                    let maybe_stmt = match query_result {
4559                        Ok(stmt) => stmt,
4560                        Err(LimboError::ParseError(msg)) if msg.contains("no such table") => {
4561                            return Ok(IOResult::Done(None));
4562                        }
4563                        Err(err) => {
4564                            return Err(LimboError::Corrupt(format!(
4565                                "Failed to read MVCC metadata table: {err}"
4566                            )));
4567                        }
4568                    };
4569                    match maybe_stmt {
4570                        Some(stmt) => {
4571                            *st = ReadPersistentTxTsMaxState::Running {
4572                                stmt: Box::new(stmt),
4573                                value: None,
4574                            };
4575                        }
4576                        // No statement to run — fall through to the missing-row
4577                        // error path (matches the blocking variant).
4578                        None => {
4579                            return Self::validate_persistent_tx_ts_max(None).map(IOResult::Done);
4580                        }
4581                    }
4582                }
4583                ReadPersistentTxTsMaxState::Running { stmt, value } => {
4584                    return_if_io!(stmt.run_with_row_callback_nonblock(|row| {
4585                        *value = Some(row.get::<i64>(0)?);
4586                        Ok(())
4587                    }));
4588                    let value = *value;
4589                    *st = ReadPersistentTxTsMaxState::Start;
4590                    return Self::validate_persistent_tx_ts_max(value).map(IOResult::Done);
4591                }
4592            }
4593        }
4594    }
4595
4596    /// Bootstrap the MV store from the SQLite schema table and logical log.
4597    /// 1. Get all root pages from the already parsed schema object
4598    /// 2. Assign table IDs to the root pages (table_id = -1 * root_page)
4599    /// 3. Complete interrupted WAL/log checkpoint reconciliation, if needed
4600    /// 4. Promote the bootstrap connection to a regular connection so that it reads from the MV store again
4601    /// 5. Recover the logical log
4602    /// 6. Make sure schema changes reflected from deserialized logical log are captured in the schema
4603    ///
4604    /// Blocking shim retained for synchronous callers. The open, attach, and
4605    /// journal-mode state machines drive
4606    /// [`MvStore::bootstrap_nonblock`] directly.
4607    pub fn bootstrap(&self, bootstrap_conn: Arc<Connection>) -> Result<()> {
4608        let mut st = BootstrapState::default();
4609        let io = bootstrap_conn.db.io.clone();
4610        io.block(|| self.bootstrap_nonblock(&bootstrap_conn, &mut st))
4611    }
4612
4613    #[doc(hidden)]
4614    pub fn bootstrap_nonblock(
4615        &self,
4616        bootstrap_conn: &Arc<Connection>,
4617        st: &mut BootstrapState,
4618    ) -> Result<IOResult<()>> {
4619        loop {
4620            match st {
4621                BootstrapState::Start => {
4622                    // Capture built-in table-valued functions before the schema
4623                    // reparse drops them (sync, no IO).
4624                    let tvfs = Self::capture_table_valued_functions(&bootstrap_conn.schema.read());
4625                    *st = BootstrapState::PreCheckpoint {
4626                        tvfs,
4627                        checkpoint_st: CompleteCheckpointState::default(),
4628                    };
4629                }
4630                BootstrapState::PreCheckpoint {
4631                    tvfs,
4632                    checkpoint_st,
4633                } => {
4634                    return_if_io!(self.maybe_complete_interrupted_checkpoint_nonblock(
4635                        bootstrap_conn,
4636                        checkpoint_st
4637                    ));
4638                    let tvfs = std::mem::take(tvfs);
4639                    *st = BootstrapState::PreReparse {
4640                        tvfs,
4641                        reparse_st: crate::connection::ReparseSchemaState::default(),
4642                    };
4643                }
4644                BootstrapState::PreReparse { tvfs, reparse_st } => {
4645                    return_if_io!(bootstrap_conn.reparse_schema_nonblock(reparse_st));
4646                    self.rehydrate_connection_table_valued_functions(bootstrap_conn, tvfs)?;
4647                    // pre_metadata done. Decide whether metadata bootstrap IO is needed.
4648                    if !self.uses_durable_mvcc_metadata(bootstrap_conn) {
4649                        self.bootstrap_map_root_pages(bootstrap_conn)?;
4650                        *st = BootstrapState::Recover {
4651                            recover_st: RecoverLogicalLogState::default(),
4652                        };
4653                    } else {
4654                        *st = BootstrapState::BeginReadTxTs {
4655                            read_st: ReadPersistentTxTsMaxState::default(),
4656                        };
4657                    }
4658                }
4659                BootstrapState::BeginReadTxTs { read_st } => {
4660                    let persistent_tx_ts = return_if_io!(
4661                        self.try_read_persistent_tx_ts_max_nonblock(bootstrap_conn, read_st)
4662                    );
4663                    if persistent_tx_ts.is_some() {
4664                        // Metadata already durable — no bootstrap IO chain needed.
4665                        self.bootstrap_map_root_pages(bootstrap_conn)?;
4666                        *st = BootstrapState::Recover {
4667                            recover_st: RecoverLogicalLogState::default(),
4668                        };
4669                    } else if let Some(next) = self.bootstrap_issue_metadata_io(bootstrap_conn)? {
4670                        *st = BootstrapState::MetadataIo(next);
4671                    } else {
4672                        self.bootstrap_map_root_pages(bootstrap_conn)?;
4673                        *st = BootstrapState::Recover {
4674                            recover_st: RecoverLogicalLogState::default(),
4675                        };
4676                    }
4677                }
4678                BootstrapState::MetadataIo(phase) => {
4679                    if !phase.completion.succeeded() {
4680                        let c = phase.completion.clone();
4681                        io_yield_one!(c);
4682                    }
4683                    let sync_type = phase.sync_type;
4684                    match phase.next {
4685                        MetadataIoStep::AfterTruncate {
4686                            log_size,
4687                            sync_mode_off,
4688                        } => {
4689                            // Truncate done. Maybe issue update_header next.
4690                            if log_size <= LOG_HDR_SIZE as u64 {
4691                                let c = self.storage.update_header()?;
4692                                *phase = MetadataIoInFlight {
4693                                    completion: c,
4694                                    sync_type,
4695                                    next: MetadataIoStep::AfterUpdateHeader { sync_mode_off },
4696                                };
4697                                // Loop to re-check succeeded immediately
4698                            } else {
4699                                *st = BootstrapState::FinishInit {
4700                                    init_st: InitMetadataTableState::default(),
4701                                };
4702                            }
4703                        }
4704                        MetadataIoStep::AfterUpdateHeader { sync_mode_off } => {
4705                            if !sync_mode_off {
4706                                let c = self.storage.sync(sync_type)?;
4707                                *phase = MetadataIoInFlight {
4708                                    completion: c,
4709                                    sync_type,
4710                                    next: MetadataIoStep::AfterSync,
4711                                };
4712                            } else {
4713                                *st = BootstrapState::FinishInit {
4714                                    init_st: InitMetadataTableState::default(),
4715                                };
4716                            }
4717                        }
4718                        MetadataIoStep::AfterSync => {
4719                            *st = BootstrapState::FinishInit {
4720                                init_st: InitMetadataTableState::default(),
4721                            };
4722                        }
4723                    }
4724                }
4725                BootstrapState::FinishInit { init_st } => {
4726                    return_if_io!(
4727                        self.initialize_mvcc_metadata_table_nonblock(bootstrap_conn, init_st)
4728                    );
4729                    *st = BootstrapState::FinishCheckpoint {
4730                        checkpoint_st: CompleteCheckpointState::default(),
4731                    };
4732                }
4733                BootstrapState::FinishCheckpoint { checkpoint_st } => {
4734                    return_if_io!(self.maybe_complete_interrupted_checkpoint_nonblock(
4735                        bootstrap_conn,
4736                        checkpoint_st
4737                    ));
4738                    self.bootstrap_map_root_pages(bootstrap_conn)?;
4739                    *st = BootstrapState::Recover {
4740                        recover_st: RecoverLogicalLogState::default(),
4741                    };
4742                }
4743                BootstrapState::Recover { recover_st } => {
4744                    // Recover the logical log while the bootstrap connection still
4745                    // reads from the pager-backed schema, so recovery can merge
4746                    // checkpointed sqlite_schema rows with non-checkpointed rows
4747                    // from log replay.
4748                    return_if_io!(self.maybe_recover_logical_log(bootstrap_conn, recover_st));
4749                    // Recovery done; switch back to regular MVCC reads.
4750                    bootstrap_conn.promote_to_regular_connection();
4751                    *st = BootstrapState::LoadSequences {
4752                        load_st: crate::connection::LoadSequenceDescriptorsState::default(),
4753                    };
4754                }
4755                BootstrapState::LoadSequences { load_st } => {
4756                    // After log replay, recover sequence descriptors from each
4757                    // backing table (non-blocking). A read failure on an internal
4758                    // backing table is on-disk corruption, surfaced as a hard
4759                    // bootstrap failure rather than a misleading "sequence does
4760                    // not exist" on the next nextval.
4761                    return_if_io!(
4762                        bootstrap_conn.load_sequence_descriptors_via_sql_nonblock(load_st)
4763                    );
4764                    *st = BootstrapState::SyncAutoincrement {
4765                        sync_st: crate::connection::SyncAutoincrementState::default(),
4766                    };
4767                }
4768                BootstrapState::SyncAutoincrement { sync_st } => {
4769                    // WAL→MVCC AUTOINCREMENT watermark compatibility sync (non-
4770                    // blocking). A failure here cannot be downgraded: it would
4771                    // leave the next AUTOINCREMENT INSERT able to re-emit a rowid
4772                    // already on disk, so it propagates as a bootstrap failure.
4773                    return_if_io!(bootstrap_conn
4774                        .sync_autoincrement_backing_tables_from_sqlite_sequence_nonblock(sync_st));
4775                    *bootstrap_conn.db.schema.lock() = bootstrap_conn.schema.read().clone();
4776                    *st = BootstrapState::AwaitingGlobalHeader;
4777                }
4778                BootstrapState::AwaitingGlobalHeader => {
4779                    if self.global_header.read().is_none() {
4780                        let pager = bootstrap_conn.pager.load();
4781                        let header = return_if_io!(pager.with_header(|header| *header));
4782                        self.global_header.write().replace(header);
4783                    }
4784                    return Ok(IOResult::Done(()));
4785                }
4786            }
4787        }
4788    }
4789
4790    /// Issue the first completion of the metadata-bootstrap IO chain, if one is
4791    /// needed. Called from the bootstrap state machine only after it has already
4792    /// confirmed durable MVCC metadata is in use and the persistent tx-ts max is
4793    /// absent. Returns `Some(MetadataIoInFlight)` with the in-flight completion
4794    /// and next step, or `None` if the log is already in a clean state.
4795    fn bootstrap_issue_metadata_io(
4796        &self,
4797        bootstrap_conn: &Arc<Connection>,
4798    ) -> Result<Option<MetadataIoInFlight>> {
4799        let log_size = self.get_logical_log_file().size()?;
4800        let pager = bootstrap_conn.pager.load().clone();
4801        if bootstrap_conn.db.is_readonly() {
4802            return Err(LimboError::Corrupt(
4803                "Missing MVCC metadata table in read-only mode".to_string(),
4804            ));
4805        }
4806        if log_size > LOG_HDR_SIZE as u64 {
4807            return Err(LimboError::Corrupt(
4808                "Missing MVCC metadata table while logical log state exists".to_string(),
4809            ));
4810        }
4811        let sync_type = pager.get_sync_type();
4812        let sync_mode_off = bootstrap_conn.get_sync_mode() == SyncMode::Off;
4813
4814        // First-time MVCC bootstrap: ensure a durable logical-log header exists
4815        // before any metadata-table writes can commit into WAL. If a previous
4816        // crash left a torn header tail (0 < size < LOG_HDR_SIZE), clear it
4817        // before rewriting the header.
4818        if log_size > 0 && log_size < LOG_HDR_SIZE as u64 {
4819            let log_file = self.get_logical_log_file();
4820            let completion = log_file.truncate(0, Completion::new_trunc(|_| {}))?;
4821            return Ok(Some(MetadataIoInFlight {
4822                completion,
4823                sync_type,
4824                next: MetadataIoStep::AfterTruncate {
4825                    log_size,
4826                    sync_mode_off,
4827                },
4828            }));
4829        }
4830        if log_size <= LOG_HDR_SIZE as u64 {
4831            let completion = self.storage.update_header()?;
4832            return Ok(Some(MetadataIoInFlight {
4833                completion,
4834                sync_type,
4835                next: MetadataIoStep::AfterUpdateHeader { sync_mode_off },
4836            }));
4837        }
4838        // Shouldn't reach here given the earlier error returns, but be safe.
4839        Ok(None)
4840    }
4841
4842    /// Sync prelude to logical-log recovery: map all existing checkpointed
4843    /// sqlite_schema root pages to MVCC table ids (root_page=R → table_id=-R).
4844    /// Recovery itself (and the connection promotion) is driven separately by
4845    /// the bootstrap state machine's `Recover` phase so it can yield IO.
4846    fn bootstrap_map_root_pages(&self, bootstrap_conn: &Arc<Connection>) -> Result<()> {
4847        let schema = bootstrap_conn.schema.read();
4848        let sqlite_schema_root_pages = {
4849            schema
4850                .tables
4851                .values()
4852                .filter_map(|t| {
4853                    if let Table::BTree(btree) = t.as_ref() {
4854                        Some(btree.root_page)
4855                    } else {
4856                        None
4857                    }
4858                })
4859                .chain(
4860                    schema
4861                        .indexes
4862                        .values()
4863                        .flatten()
4864                        .map(|index| index.root_page),
4865                )
4866        };
4867        for root_page in sqlite_schema_root_pages {
4868            turso_assert!(root_page > 0, "root_page={root_page} must be positive");
4869            let root_page_as_table_id = MVTableId::from(-(root_page));
4870            self.insert_table_id_to_rootpage(root_page_as_table_id, Some(root_page as u64));
4871        }
4872        Ok(())
4873    }
4874
4875    /// MVCC does not use the pager/btree cursors to create pages until checkpoint.
4876    /// This method is used to assign root page numbers when Insn::CreateBtree is used.
4877    /// MVCC table ids are always negative. Their corresponding rootpage entry in sqlite_schema
4878    /// is the same negative value if the table has not been checkpointed yet. Otherwise, the root page
4879    /// will be positive and corresponds to the actual physical page.
4880    pub fn get_next_table_id(&self) -> i64 {
4881        self.next_table_id.fetch_sub(1, Ordering::SeqCst)
4882    }
4883
4884    pub fn get_next_rowid(&self) -> i64 {
4885        self.next_rowid.fetch_add(1, Ordering::SeqCst) as i64
4886    }
4887
4888    /// Inserts a new row into a table in the database.
4889    ///
4890    /// This function inserts a new `row` into the database within the context
4891    /// of the transaction `tx_id`.
4892    ///
4893    /// # Arguments
4894    ///
4895    /// * `tx_id` - the ID of the transaction in which to insert the new row.
4896    /// * `row` - the row object containing the values to be inserted.
4897    ///
4898    pub fn insert(&self, tx_id: TxID, row: Row) -> Result<()> {
4899        self.insert_to_table_or_index(tx_id, row, None)
4900    }
4901
4902    /// Same as insert() but can insert to a table or an index, indicated by the `maybe_index_id` argument.
4903    pub fn insert_to_table_or_index(
4904        &self,
4905        tx_id: TxID,
4906        row: Row,
4907        maybe_index_id: Option<MVTableId>,
4908    ) -> Result<()> {
4909        tracing::trace!("insert(tx_id={}, row.id={:?})", tx_id, row.id);
4910        let tx = self
4911            .txs
4912            .get(&tx_id)
4913            .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
4914        let tx = tx.value();
4915        turso_assert_eq!(tx.state, TransactionState::Active);
4916        let id = row.id.clone();
4917        match maybe_index_id {
4918            Some(index_id) => {
4919                let version_id = self.get_version_id();
4920                let row_version = RowVersion {
4921                    id: version_id,
4922                    begin: crate::mvcc::database::PackedTs::pack(Some(TxTimestampOrID::TxID(
4923                        tx.tx_id,
4924                    ))),
4925                    end: crate::mvcc::database::PackedTs::pack(None),
4926                    row: row.clone(),
4927                    btree_resident: false,
4928                    materialized_at: crate::mvcc::database::WalPos::ORIGIN,
4929                };
4930                let RowKey::Record(sortable_key) = row.id.row_id else {
4931                    panic!("Index writes must be to a record");
4932                };
4933                // Single SkipMap traversal: pass in a fresh Arc; the SkipMap
4934                // returns the canonical Arc (ours on miss, an existing one
4935                // on hit), which we hand to savepoint tracking.
4936                let (canonical_key, row_versions) =
4937                    self.insert_index_version(index_id, sortable_key, row_version)?;
4938                tx.insert_to_write_set(
4939                    RowID::new(id.table_id, RowKey::Record(canonical_key.clone())),
4940                    row_versions,
4941                );
4942                tx.record_created_index_version((index_id, canonical_key), version_id);
4943            }
4944            None => {
4945                // NOTE: We do NOT check for conflicts at insert time (pure optimistic).
4946                // Conflicts are detected at commit time using end_ts comparison.
4947                // This allows multiple transactions to insert the same rowid,
4948                // with first-committer-wins semantics.
4949
4950                let version_id = self.get_version_id();
4951                let row_version = RowVersion {
4952                    id: version_id,
4953                    begin: crate::mvcc::database::PackedTs::pack(Some(TxTimestampOrID::TxID(
4954                        tx.tx_id,
4955                    ))),
4956                    end: crate::mvcc::database::PackedTs::pack(None),
4957                    row,
4958                    btree_resident: false,
4959                    materialized_at: crate::mvcc::database::WalPos::ORIGIN,
4960                };
4961                let row_versions = self.insert_version(id.clone(), row_version)?;
4962                let allocator = self.get_rowid_allocator(&id.table_id);
4963                allocator.insert_row_id_maybe_update(id.row_id.to_int_or_panic());
4964                tx.record_created_table_version(id.clone(), version_id);
4965                tx.insert_to_write_set(id, row_versions);
4966            }
4967        }
4968        Ok(())
4969    }
4970
4971    /// Inserts a deletion record for a row that does not currently have any versions in the MV store.
4972    /// This is used in cases where the BTree contains that record, but it is logically deleted.
4973    pub fn insert_tombstone_to_table_or_index(
4974        &self,
4975        tx_id: TxID,
4976        id: RowID,
4977        row: Row,
4978        maybe_index_id: Option<MVTableId>,
4979    ) -> Result<()> {
4980        let version_id = self.get_version_id();
4981        let row_version = RowVersion {
4982            id: version_id,
4983            // Tombstones over B-tree-resident rows have no MVCC creator begin.
4984            // They invalidate B-tree visibility via end timestamp only.
4985            begin: crate::mvcc::database::PackedTs::pack(None),
4986            end: crate::mvcc::database::PackedTs::pack(Some(TxTimestampOrID::TxID(tx_id))),
4987            row: row.clone(),
4988            btree_resident: true,
4989            materialized_at: crate::mvcc::database::WalPos::ORIGIN,
4990        };
4991        let tx = self
4992            .txs
4993            .get(&tx_id)
4994            .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
4995        let tx = tx.value();
4996        match maybe_index_id {
4997            Some(index_id) => {
4998                let RowKey::Record(sortable_key) = row.id.row_id else {
4999                    panic!("Index writes must be to a record");
5000                };
5001                let (canonical_key, row_versions) =
5002                    self.insert_index_version(index_id, sortable_key, row_version)?;
5003                tx.insert_to_write_set(
5004                    RowID::new(id.table_id, RowKey::Record(canonical_key.clone())),
5005                    row_versions,
5006                );
5007                tx.record_created_index_version((index_id, canonical_key), version_id);
5008            }
5009            None => {
5010                let row_versions = self.insert_version(id.clone(), row_version)?;
5011                tx.record_created_table_version(id.clone(), version_id);
5012                tx.insert_to_write_set(id, row_versions);
5013            }
5014        }
5015        Ok(())
5016    }
5017
5018    /// Inserts a row that was read from the B-tree (not in MvStore).
5019    /// This is used when updating a row that exists in B-tree but hasn't been
5020    /// modified in MVCC yet. The btree_resident flag helps the checkpoint logic
5021    /// determine if subsequent deletes should be checkpointed to the B-tree file.
5022    pub fn insert_btree_resident_to_table_or_index(
5023        &self,
5024        tx_id: TxID,
5025        row: Row,
5026        maybe_index_id: Option<MVTableId>,
5027    ) -> Result<()> {
5028        tracing::trace!(
5029            "insert_btree_resident(tx_id={}, row.id={:?})",
5030            tx_id,
5031            row.id
5032        );
5033        let tx = self
5034            .txs
5035            .get(&tx_id)
5036            .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
5037        let tx = tx.value();
5038        turso_assert_eq!(tx.state, TransactionState::Active);
5039        let id = row.id.clone();
5040        match maybe_index_id {
5041            Some(index_id) => {
5042                let version_id = self.get_version_id();
5043                let row_version = RowVersion {
5044                    id: version_id,
5045                    begin: crate::mvcc::database::PackedTs::pack(Some(TxTimestampOrID::TxID(
5046                        tx.tx_id,
5047                    ))),
5048                    end: crate::mvcc::database::PackedTs::pack(None),
5049                    row: row.clone(),
5050                    btree_resident: true,
5051                    materialized_at: crate::mvcc::database::WalPos::ORIGIN,
5052                };
5053                let RowKey::Record(sortable_key) = row.id.row_id else {
5054                    panic!("Index writes must be to a record");
5055                };
5056                let (canonical_key, row_versions) =
5057                    self.insert_index_version(index_id, sortable_key, row_version)?;
5058                tx.insert_to_write_set(
5059                    RowID::new(id.table_id, RowKey::Record(canonical_key.clone())),
5060                    row_versions,
5061                );
5062                tx.record_created_index_version((index_id, canonical_key), version_id);
5063            }
5064            None => {
5065                let version_id = self.get_version_id();
5066                let row_version = RowVersion {
5067                    id: version_id,
5068                    begin: crate::mvcc::database::PackedTs::pack(Some(TxTimestampOrID::TxID(
5069                        tx.tx_id,
5070                    ))),
5071                    end: crate::mvcc::database::PackedTs::pack(None),
5072                    row,
5073                    btree_resident: true,
5074                    materialized_at: crate::mvcc::database::WalPos::ORIGIN,
5075                };
5076                let row_versions = self.insert_version(id.clone(), row_version)?;
5077                tx.record_created_table_version(id.clone(), version_id);
5078                tx.insert_to_write_set(id, row_versions);
5079            }
5080        }
5081        Ok(())
5082    }
5083
5084    /// Updates a row in a table in the database with new values.
5085    ///
5086    /// This function updates an existing row in the database within the
5087    /// context of the transaction `tx_id`. The `row` argument identifies the
5088    /// row to be updated as `id` and contains the new values to be inserted.
5089    ///
5090    /// If the row identified by the `id` does not exist, this function does
5091    /// nothing and returns `false`. Otherwise, the function updates the row
5092    /// with the new values and returns `true`.
5093    ///
5094    /// # Arguments
5095    ///
5096    /// * `tx_id` - the ID of the transaction in which to update the new row.
5097    /// * `row` - the row object containing the values to be updated.
5098    ///
5099    /// # Returns
5100    ///
5101    /// Returns `true` if the row was successfully updated, and `false` otherwise.
5102    pub fn update(&self, tx_id: TxID, row: Row) -> Result<bool> {
5103        self.update_to_table_or_index(tx_id, row, None)
5104    }
5105
5106    /// Same as update() but can update a table or an index, indicated by the `maybe_index_id` argument.
5107    pub fn update_to_table_or_index(
5108        &self,
5109        tx_id: TxID,
5110        row: Row,
5111        maybe_index_id: Option<MVTableId>,
5112    ) -> Result<bool> {
5113        tracing::trace!("update(tx_id={}, row.id={:?})", tx_id, row.id);
5114        if !self.delete_from_table_or_index(tx_id, row.id.clone(), maybe_index_id)? {
5115            return Ok(false);
5116        }
5117        self.insert_to_table_or_index(tx_id, row, maybe_index_id)?;
5118        Ok(true)
5119    }
5120
5121    /// Inserts a row into a table in the database with new values, previously deleting
5122    /// any old data if it existed. Bails on a delete error, e.g. write-write conflict.
5123    pub fn upsert(&self, tx_id: TxID, row: Row) -> Result<()> {
5124        self.upsert_to_table_or_index(tx_id, row, None)
5125    }
5126
5127    /// Same as upsert() but can upsert to a table or an index, indicated by the `maybe_index_id` argument.
5128    pub fn upsert_to_table_or_index(
5129        &self,
5130        tx_id: TxID,
5131        row: Row,
5132        maybe_index_id: Option<MVTableId>,
5133    ) -> Result<()> {
5134        tracing::trace!("upsert(tx_id={}, row.id={:?})", tx_id, row.id);
5135        self.delete_from_table_or_index(tx_id, row.id.clone(), maybe_index_id)?;
5136        self.insert_to_table_or_index(tx_id, row, maybe_index_id)?;
5137        Ok(())
5138    }
5139
5140    /// Deletes a row from the table with the given `id`.
5141    ///
5142    /// This function deletes an existing row `id` in the database within the
5143    /// context of the transaction `tx_id`.
5144    ///
5145    /// # Arguments
5146    ///
5147    /// * `tx_id` - the ID of the transaction in which to delete the new row.
5148    /// * `id` - the ID of the row to delete.
5149    ///
5150    /// # Returns
5151    ///
5152    /// Returns `true` if the row was successfully deleted, and `false` otherwise.
5153    ///
5154    pub fn delete(&self, tx_id: TxID, id: RowID) -> Result<bool> {
5155        self.delete_from_table_or_index(tx_id, id, None)
5156    }
5157
5158    /// Same as delete() but can delete from a table or an index, indicated by the `maybe_index_id` argument.
5159    pub fn delete_from_table_or_index(
5160        &self,
5161        tx_id: TxID,
5162        id: RowID,
5163        maybe_index_id: Option<MVTableId>,
5164    ) -> Result<bool> {
5165        tracing::trace!("delete(tx_id={}, id={:?})", tx_id, id);
5166        match maybe_index_id {
5167            Some(index_id) => {
5168                let rows = self.get_or_create_index_rows(index_id)?;
5169                let rows = rows.value();
5170                let RowKey::Record(sortable_key) = id.row_id.clone() else {
5171                    panic!("Index deletes must have a record row_id");
5172                };
5173                if let Some(ref row_versions_entry) = rows.get(&sortable_key) {
5174                    // Get the Arc key from the map entry for savepoint tracking
5175                    let arc_key = row_versions_entry.key().clone();
5176                    let row_versions = row_versions_entry.value().clone();
5177                    for rv in row_versions.write().iter_mut().rev() {
5178                        let tx = self
5179                            .txs
5180                            .get(&tx_id)
5181                            .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
5182                        let tx = tx.value();
5183                        turso_assert_eq!(tx.state, TransactionState::Active);
5184                        // A transaction cannot delete a version that it cannot see,
5185                        // nor can it conflict with it.
5186                        if !rv.is_visible_to(tx, &self.txs, &self.finalized_tx_states) {
5187                            continue;
5188                        }
5189                        if is_write_write_conflict(&self.txs, &self.finalized_tx_states, tx, rv) {
5190                            turso_assert_reachable!("write-write conflict on delete");
5191                            return Err(LimboError::WriteWriteConflict);
5192                        }
5193
5194                        let version_id = rv.id;
5195                        rv.set_end(Some(TxTimestampOrID::TxID(tx.tx_id)));
5196                        let tx = self
5197                            .txs
5198                            .get(&tx_id)
5199                            .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
5200                        let tx = tx.value();
5201                        tx.insert_to_write_set(id, row_versions.clone());
5202                        tx.record_deleted_index_version((index_id, arc_key), version_id);
5203                        return Ok(true);
5204                    }
5205                }
5206                Ok(false)
5207            }
5208            None => {
5209                let row_versions_opt = self.rows.get(&id);
5210                if let Some(ref row_versions_entry) = row_versions_opt {
5211                    let row_versions = row_versions_entry.value().clone();
5212                    let mut locked_row_versions = row_versions.write();
5213                    for rv in locked_row_versions.iter_mut().rev() {
5214                        let tx = self
5215                            .txs
5216                            .get(&tx_id)
5217                            .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
5218                        let tx = tx.value();
5219                        turso_assert_eq!(tx.state, TransactionState::Active);
5220                        // A transaction cannot delete a version that it cannot see,
5221                        // nor can it conflict with it.
5222                        if !rv.is_visible_to(tx, &self.txs, &self.finalized_tx_states) {
5223                            continue;
5224                        }
5225                        if is_write_write_conflict(&self.txs, &self.finalized_tx_states, tx, rv) {
5226                            turso_assert_reachable!("write-write conflict on delete");
5227                            return Err(LimboError::WriteWriteConflict);
5228                        }
5229
5230                        let version_id = rv.id;
5231                        rv.set_end(Some(TxTimestampOrID::TxID(tx.tx_id)));
5232                        drop(locked_row_versions);
5233                        drop(row_versions_opt);
5234                        let tx = self
5235                            .txs
5236                            .get(&tx_id)
5237                            .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
5238                        let tx = tx.value();
5239                        tx.insert_to_write_set(id.clone(), row_versions.clone());
5240                        tx.record_deleted_table_version(id.clone(), version_id);
5241                        return Ok(true);
5242                    }
5243                }
5244                Ok(false)
5245            }
5246        }
5247    }
5248
5249    /// Retrieves a row from the table with the given `id`.
5250    ///
5251    /// This operation is performed within the scope of the transaction identified
5252    /// by `tx_id`.
5253    ///
5254    /// # Arguments
5255    ///
5256    /// * `tx_id` - The ID of the transaction to perform the read operation in.
5257    /// * `id` - The ID of the row to retrieve.
5258    ///
5259    /// # Returns
5260    ///
5261    /// Returns `Some(row)` with the row data if the row with the given `id` exists,
5262    /// and `None` otherwise.
5263    pub fn read(&self, tx_id: TxID, id: &RowID) -> Result<Option<Row>> {
5264        self.read_from_table_or_index(tx_id, id, None)
5265    }
5266
5267    /// Same as read() but can read from a table or an index, indicated by the `maybe_index_id` argument.
5268    pub fn read_from_table_or_index(
5269        &self,
5270        tx_id: TxID,
5271        id: &RowID,
5272        maybe_index_id: Option<MVTableId>,
5273    ) -> Result<Option<Row>> {
5274        tracing::trace!("read(tx_id={}, id={:?})", tx_id, id);
5275
5276        let tx = self
5277            .txs
5278            .get(&tx_id)
5279            .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
5280        let tx = tx.value();
5281        turso_assert_eq!(tx.state, TransactionState::Active);
5282        match maybe_index_id {
5283            Some(index_id) => {
5284                let rows = self.get_or_create_index_rows(index_id)?;
5285                let rows = rows.value();
5286                let RowKey::Record(sortable_key) = &id.row_id else {
5287                    panic!("Index reads must have a record row_id");
5288                };
5289                let row_versions_opt = rows.get(sortable_key);
5290                if let Some(ref row_versions) = row_versions_opt {
5291                    let row_versions = row_versions.value().read();
5292                    if let Some(rv) = row_versions
5293                        .iter()
5294                        .rev()
5295                        .find(|rv| rv.is_visible_to(tx, &self.txs, &self.finalized_tx_states))
5296                    {
5297                        return Ok(Some(rv.row.clone()));
5298                    }
5299                }
5300                Ok(None)
5301            }
5302            None => {
5303                if let Some(row_versions) = self.rows.get(id) {
5304                    let row_versions = row_versions.value().read();
5305                    if let Some(rv) = row_versions
5306                        .iter()
5307                        .rev()
5308                        .find(|rv| rv.is_visible_to(tx, &self.txs, &self.finalized_tx_states))
5309                    {
5310                        return Ok(Some(rv.row.clone()));
5311                    }
5312                }
5313                Ok(None)
5314            }
5315        }
5316    }
5317
5318    /// Like the table branch of [`read_from_table_or_index`], but reads from an
5319    /// already-resolved version chain instead of looking the row up in
5320    /// `self.rows`. Used on the scan path where the cursor's range iterator
5321    /// already located the `Arc`, so the second skiplist traversal is avoided.
5322    pub(crate) fn read_visible_from_versions(
5323        &self,
5324        tx_id: TxID,
5325        versions: &RowVersions<A>,
5326    ) -> Result<Option<Row>> {
5327        let tx = self
5328            .txs
5329            .get(&tx_id)
5330            .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
5331        let tx = tx.value();
5332        turso_assert_eq!(tx.state, TransactionState::Active);
5333        let versions = versions.read();
5334        if let Some(rv) = versions
5335            .iter()
5336            .rev()
5337            .find(|rv| rv.is_visible_to(tx, &self.txs, &self.finalized_tx_states))
5338        {
5339            return Ok(Some(rv.row.clone()));
5340        }
5341        Ok(None)
5342    }
5343
5344    /// Like [`read_visible_from_versions`] but serializes the visible row's
5345    /// payload directly into `record` instead of cloning a `Row`. Mirrors the
5346    /// btree cursor, which serializes a cell straight into its reusable record.
5347    /// Returns true if a visible version was found. The version-chain read lock
5348    /// is held only for the serialization copy.
5349    pub(crate) fn read_visible_into_record(
5350        &self,
5351        tx_id: TxID,
5352        versions: &RowVersions<A>,
5353        record: &mut ImmutableRecord,
5354    ) -> Result<bool> {
5355        let tx = self
5356            .txs
5357            .get(&tx_id)
5358            .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
5359        let tx = tx.value();
5360        turso_assert_eq!(tx.state, TransactionState::Active);
5361        let versions = versions.read();
5362        if let Some(rv) = versions
5363            .iter()
5364            .rev()
5365            .find(|rv| rv.is_visible_to(tx, &self.txs, &self.finalized_tx_states))
5366        {
5367            record.invalidate();
5368            record.start_serialization(rv.row.payload())?;
5369            return Ok(true);
5370        }
5371        Ok(false)
5372    }
5373
5374    /// Gets all row ids in the database.
5375    pub fn scan_row_ids(&self) -> Result<Vec<RowID>> {
5376        tracing::trace!("scan_row_ids");
5377        let keys = self.rows.iter().map(|entry| entry.key().clone());
5378        Ok(keys.collect())
5379    }
5380
5381    pub fn get_row_id_range(
5382        &self,
5383        table_id: MVTableId,
5384        start: i64,
5385        bucket: &mut Vec<RowID>,
5386        max_items: u64,
5387    ) -> Result<()> {
5388        tracing::trace!(
5389            "get_row_id_in_range(table_id={}, range_start={})",
5390            table_id,
5391            start,
5392        );
5393        let start_id = RowID {
5394            table_id,
5395            row_id: RowKey::Int(start),
5396        };
5397
5398        let end_id = RowID {
5399            table_id,
5400            row_id: RowKey::Int(i64::MAX),
5401        };
5402
5403        self.rows
5404            .range(start_id..end_id)
5405            .take(max_items as usize)
5406            .for_each(|entry| bucket.push(entry.key().clone()));
5407
5408        Ok(())
5409    }
5410
5411    pub(crate) fn advance_cursor_and_get_row_id_for_table(
5412        &self,
5413        table_id: MVTableId,
5414        mv_store_iterator: &mut Option<MvccIterator<'static, RowID, A>>,
5415        tx_id: TxID,
5416    ) -> Option<(RowID, RowVersions<A>)> {
5417        let mv_store_iterator = mv_store_iterator.as_mut().expect(
5418            "mv_store_iterator must be initialized when calling get_row_id_for_table_in_direction",
5419        );
5420
5421        let tx = self
5422            .txs
5423            .get(&tx_id)
5424            .expect("transaction should exist in txs map");
5425        let tx = tx.value();
5426        loop {
5427            // We are moving forward, so if a row was deleted we just need to skip it. Therefore, we need
5428            // to loop either until we find a row that is not deleted or until we reach the end of the table.
5429            let next_row = mv_store_iterator.next();
5430            let row = next_row?;
5431            if row.key().table_id != table_id {
5432                // In case of table rows, we store the rows of all tables in a single map,
5433                // so we must stop iteration if we reach a row that is on a different table.
5434                // In the case of indexes we have a separate map per table so this is not
5435                // relevant.
5436                return None;
5437            }
5438
5439            // We found a row, let's check if it's visible to the transaction.
5440            if let Some(visible_row) = self.find_last_visible_version(tx, &row) {
5441                return Some(visible_row);
5442            }
5443            // If this row is not visible, continue to the next row
5444        }
5445    }
5446
5447    pub(crate) fn advance_cursor_and_get_row_id_for_index(
5448        &self,
5449        mv_store_iterator: &mut Option<MvccIterator<'static, Arc<SortableIndexKey>, A>>,
5450        tx_id: TxID,
5451    ) -> Option<RowID> {
5452        let mv_store_iterator = mv_store_iterator.as_mut().expect(
5453            "mv_store_iterator must be initialized when calling get_row_id_for_index_in_direction",
5454        );
5455
5456        let tx = self
5457            .txs
5458            .get(&tx_id)
5459            .expect("transaction should exist in txs map");
5460        let tx = tx.value();
5461
5462        self.find_next_visible_index_row(tx, mv_store_iterator)
5463    }
5464
5465    /// Whether an already-resolved index version chain shadows (invalidates) the
5466    /// corresponding B-tree row for `tx_id`.
5467    ///
5468    /// This is exactly the predicate used by the `RowKey::Record` branch of
5469    /// [`Self::query_btree_version_is_valid`], but it takes the version chain
5470    /// directly instead of looking it up by key. A forward index scan keeps a
5471    /// skiplist finger co-positioned with the B-tree and calls this on the
5472    /// chain the finger already points at, replacing one `index_rows.get()`
5473    /// (O(log N)) per scanned row with an amortized-O(1) merge step.
5474    pub(crate) fn index_chain_invalidates_btree(
5475        &self,
5476        versions: &RwLock<RowVersionChain<A>>,
5477        tx_id: TxID,
5478    ) -> bool {
5479        let tx = self
5480            .txs
5481            .get(&tx_id)
5482            .expect("transaction should exist in txs map");
5483        let tx = tx.value();
5484        let versions = versions.read();
5485        versions.iter().rev().any(|version| {
5486            version.is_btree_invalidating_version(tx, &self.txs, &self.finalized_tx_states)
5487        })
5488    }
5489
5490    /// Check if the B-tree version of a row should be shown to the given transaction.
5491    ///
5492    /// Returns true if the B-tree version is valid (should be shown).
5493    /// Returns false if the B-tree version is shadowed or deleted by MVCC.
5494    pub fn query_btree_version_is_valid(
5495        &self,
5496        table_id: MVTableId,
5497        row_id: &RowKey,
5498        tx_id: TxID,
5499    ) -> bool {
5500        let tx = self
5501            .txs
5502            .get(&tx_id)
5503            .expect("transaction should exist in txs map");
5504        let tx = tx.value();
5505
5506        match row_id {
5507            RowKey::Int(_) => {
5508                let row_id_full = RowID {
5509                    table_id,
5510                    row_id: row_id.clone(),
5511                };
5512                let Some(versions) = self.rows.get(&row_id_full) else {
5513                    // No MVCC version -> B-tree is valid
5514                    return true;
5515                };
5516                let versions = versions.value().read();
5517
5518                // Check if any version invalidates the B-tree row
5519                let btree_is_invalid = versions.iter().rev().any(|version| {
5520                    version.is_btree_invalidating_version(tx, &self.txs, &self.finalized_tx_states)
5521                });
5522
5523                !btree_is_invalid
5524            }
5525            RowKey::Record(record) => {
5526                // Dont allocate new SkipList here to avoid introducing concerns around error handling
5527                let Some(index_rows) = self.index_rows.get(&table_id) else {
5528                    return true;
5529                };
5530                let index_rows = index_rows.value();
5531                let Some(versions) = index_rows.get(record.as_ref()) else {
5532                    // No MVCC version -> B-tree is valid
5533                    return true;
5534                };
5535                let versions = versions.value().read();
5536
5537                // Check if any version invalidates the B-tree row
5538                let btree_is_invalid = versions.iter().rev().any(|version| {
5539                    version.is_btree_invalidating_version(tx, &self.txs, &self.finalized_tx_states)
5540                });
5541
5542                !btree_is_invalid
5543            }
5544        }
5545    }
5546
5547    fn find_last_visible_version(
5548        &self,
5549        tx: &Transaction<A>,
5550        row: &TableRowEntry<'_, A>,
5551    ) -> Option<(RowID, RowVersions<A>)> {
5552        row.value()
5553            .read()
5554            .iter()
5555            .rev()
5556            .find(|version| version.is_visible_to(tx, &self.txs, &self.finalized_tx_states))
5557            .map(|_| (row.key().clone(), row.value().clone()))
5558    }
5559
5560    fn find_last_visible_index_version(
5561        &self,
5562        tx: &Transaction<A>,
5563        row: IndexRowEntry<'_, A>,
5564    ) -> Option<RowID> {
5565        row.value()
5566            .read()
5567            .iter()
5568            .rev()
5569            .find(|version| version.is_visible_to(tx, &self.txs, &self.finalized_tx_states))
5570            .map(|version| version.row.id.clone())
5571    }
5572
5573    fn find_next_visible_index_row<'a, I>(&self, tx: &Transaction<A>, mut rows: I) -> Option<RowID>
5574    where
5575        I: Iterator<Item = IndexRowEntry<'a, A>>,
5576    {
5577        loop {
5578            let row = rows.next()?;
5579            if let Some(visible_row) = self.find_last_visible_index_version(tx, row) {
5580                return Some(visible_row);
5581            }
5582        }
5583    }
5584
5585    fn find_next_visible_table_row<'a, I>(
5586        &self,
5587        tx: &Transaction<A>,
5588        mut rows: I,
5589        table_id: MVTableId,
5590    ) -> Option<(RowID, RowVersions<A>)>
5591    where
5592        I: Iterator<Item = TableRowEntry<'a, A>>,
5593    {
5594        loop {
5595            let row = rows.next()?;
5596            if row.key().table_id != table_id {
5597                return None;
5598            }
5599            if let Some(visible_row) = self.find_last_visible_version(tx, &row) {
5600                return Some(visible_row);
5601            }
5602        }
5603    }
5604
5605    pub fn seek_rowid(
5606        &self,
5607        start: RowID,
5608        inclusive: bool,
5609        eq_only: bool,
5610        direction: IterationDirection,
5611        tx_id: TxID,
5612        table_iterator: &mut Option<MvccIterator<'static, RowID, A>>,
5613    ) -> Option<RowID> {
5614        let table_id = start.table_id;
5615        let iter_box = {
5616            let range = if eq_only {
5617                // An eq-only rowid seek (point lookup, NotExists / rowid-uniqueness
5618                // probe) only cares about the single key `start`. Bound BOTH ends of
5619                // the range to that key so the skiplist walk stops immediately instead
5620                // of scanning forward over every invisible neighbor until it happens to
5621                // find the next visible row. Without this bound a single probe costs
5622                // O(pending invisible versions), which compounds into quadratic work
5623                // when each row of a concurrent batch insert pays it. This mirrors the
5624                // eq-only bound in `seek_index`. The cursor's final position is still
5625                // decided by `PickWinner` from the merged MVCC/B-tree peeks, so bounding
5626                // the range here does not change where the cursor lands.
5627                (Bound::Included(start.clone()), Bound::Included(start))
5628            } else {
5629                let start = if inclusive {
5630                    Bound::Included(start)
5631                } else {
5632                    Bound::Excluded(start)
5633                };
5634                create_seek_range(start, direction)
5635            };
5636            match direction {
5637                IterationDirection::Forwards => {
5638                    Box::new(self.rows.range(range)) as TableRowIterator<'_, A>
5639                }
5640                IterationDirection::Backwards => {
5641                    Box::new(self.rows.range(range).rev()) as TableRowIterator<'_, A>
5642                }
5643            }
5644        };
5645        *table_iterator = Some(static_iterator_hack!(iter_box, RowID, A));
5646
5647        let mv_store_iterator = table_iterator
5648            .as_mut()
5649            .expect("table_iterator was assigned above if it was None");
5650
5651        let tx = self
5652            .txs
5653            .get(&tx_id)
5654            .expect("transaction should exist in txs map");
5655        let tx = tx.value();
5656
5657        self.find_next_visible_table_row(tx, mv_store_iterator, table_id)
5658            .map(|(row_id, _versions)| row_id)
5659    }
5660
5661    #[allow(clippy::too_many_arguments)]
5662    pub fn seek_index(
5663        &self,
5664        index_id: MVTableId,
5665        start: SortableIndexKey,
5666        inclusive: bool,
5667        eq_only: bool,
5668        direction: IterationDirection,
5669        tx_id: TxID,
5670        index_iterator: &mut Option<MvccIterator<'static, Arc<SortableIndexKey>, A>>,
5671    ) -> Result<Option<RowID>> {
5672        let index_rows = self.get_or_create_index_rows(index_id)?;
5673        let index_rows = index_rows.value();
5674        let range = if eq_only {
5675            // An eq-only seek (point lookup, NoConflict, unique-constraint probe,
5676            // index delete) only cares about entries whose key matches `start`.
5677            // Bound BOTH ends of the range to the probe key so the skiplist walk
5678            // stops at the matching cluster instead of scanning forward over every
5679            // invisible neighbor until it happens to find the next visible row.
5680            //
5681            // `SortableIndexKey` ordering compares only the probe's columns (see
5682            // `SortableIndexKey::compare`, which clamps to `min(num_cols)`), so
5683            // `start..=start` captures all entries sharing the probed prefix
5684            // regardless of their trailing rowid — exactly the set an eq-only seek
5685            // may match. Without this bound a single seek costs O(pending invisible
5686            // versions), which compounds into quadratic work when each row of a
5687            // concurrent batch insert pays it. The cursor's position after the seek
5688            // is still set by `PickWinner` from the merged MVCC/B-tree peeks, so
5689            // returning early here does not change where the cursor lands.
5690            (Bound::Included(start.clone()), Bound::Included(start))
5691        } else {
5692            let start = if inclusive {
5693                Bound::Included(start)
5694            } else {
5695                Bound::Excluded(start)
5696            };
5697            create_seek_range(start, direction)
5698        };
5699        let iter_box = match direction {
5700            IterationDirection::Forwards => {
5701                Box::new(index_rows.range(range)) as IndexRowIterator<'_, A>
5702            }
5703            IterationDirection::Backwards => {
5704                Box::new(index_rows.range(range).rev()) as IndexRowIterator<'_, A>
5705            }
5706        };
5707        *index_iterator = Some(static_iterator_hack!(iter_box, Arc<SortableIndexKey>, A));
5708        let mv_store_iterator = index_iterator
5709            .as_mut()
5710            .expect("index_iterator was assigned above if it was None");
5711
5712        let tx = self
5713            .txs
5714            .get(&tx_id)
5715            .expect("transaction should exist in txs map");
5716        let tx = tx.value();
5717        Ok(self.find_next_visible_index_row(tx, mv_store_iterator))
5718    }
5719
5720    /// Begins an exclusive write transaction that prevents concurrent writes.
5721    ///
5722    /// This is used for IMMEDIATE and EXCLUSIVE transaction types where we need
5723    /// to ensure exclusive write access as per SQLite semantics.
5724    #[instrument(skip_all, level = Level::DEBUG)]
5725    pub fn begin_exclusive_tx(
5726        &self,
5727        pager: Arc<Pager>,
5728        maybe_existing_tx_id: Option<TxID>,
5729        connection: &Connection,
5730        expected_schema_generation: Option<u64>,
5731    ) -> Result<TxID> {
5732        #[cfg(not(any(clt_turso_tests, injected_yields)))]
5733        let _ = connection;
5734        // Existing transactions already hold one blocking-checkpoint read guard
5735        // from begin_tx() (truncate path only). When upgrading read->write, do not acquire another one.
5736        let passive = self.experimental_mvcc_passive_checkpoint;
5737        let acquires_checkpoint_guard = maybe_existing_tx_id.is_none() && !passive;
5738        // Fresh write begins gate on the connection's prepared schema generation (same as
5739        // begin_tx); upgrades keep the original snapshot, so the caller passes None. See begin_tx
5740        // for why this closes the passive publish/begin race without a Busy.
5741        let expected_schema_generation = if maybe_existing_tx_id.is_none() {
5742            expected_schema_generation
5743        } else {
5744            None
5745        };
5746        if acquires_checkpoint_guard && !self.blocking_checkpoint_lock.read() {
5747            // If there is a stop-the-world checkpoint in progress, we cannot begin any transaction at all.
5748            return Err(LimboError::Busy);
5749        }
5750        let unlock_checkpoint_guard = || {
5751            if acquires_checkpoint_guard {
5752                self.blocking_checkpoint_lock.unlock();
5753            }
5754        };
5755        let tx_id = maybe_existing_tx_id.unwrap_or_else(|| self.get_tx_id());
5756        let begin_ts = if let Some(tx_id) = maybe_existing_tx_id {
5757            // Upgrade path: the transaction is already published in `txs`
5758            // (from begin_tx), so it is already visible to compute_lwm().
5759            self.txs
5760                .get(&tx_id)
5761                .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?
5762                .value()
5763                .begin_ts
5764        } else {
5765            // Fresh path: publish the transaction into `txs` atomically with
5766            // begin_ts allocation, under the clock lock — same begin-publish
5767            // window fix as begin_tx(). Without this, inline GC on a concurrent
5768            // committer's commit path could compute an LWM above our begin_ts
5769            // and reclaim a version this snapshot still needs. The header read
5770            // (possibly blocking I/O) happens before the clock lock is taken;
5771            // the error paths below remove this tx if begin fails. The real
5772            // header is set here, so the tail only flips `pager_commit_lock_held`.
5773            // Ensure page 1 / global_header is initialized (pager I/O only on first init). The
5774            // header + schema_generation are re-read inside the clock callback below, not captured
5775            // here, so a passive publish (which swaps global_header + bumps schema_generation under
5776            // this same clock) cannot interleave between the capture and `txs.insert`.
5777            self.get_new_transaction_database_header(&pager);
5778            pager.mvcc_refresh_if_db_changed();
5779            let read_mark = WalPos::from_pair(pager.wal_pos());
5780            let mut schema_stale = false;
5781            let begin_ts = self.clock.get_timestamp(|ts| {
5782                let schema_generation = self.schema_generation();
5783                if expected_schema_generation.is_some_and(|exp| exp != schema_generation) {
5784                    schema_stale = true;
5785                    return;
5786                }
5787                let header = self
5788                    .global_header
5789                    .read()
5790                    .expect("global_header initialized above");
5791                self.txs.insert(
5792                    tx_id,
5793                    Transaction::new(tx_id, ts, header, read_mark, schema_generation),
5794                );
5795            });
5796            if schema_stale {
5797                unlock_checkpoint_guard();
5798                return Err(LimboError::SchemaUpdated);
5799            }
5800            if acquires_checkpoint_guard {
5801                if let Some(entry) = self.txs.get(&tx_id) {
5802                    entry
5803                        .value()
5804                        .holds_blocking_checkpoint_read
5805                        .store(true, Ordering::Release);
5806                }
5807            }
5808            begin_ts
5809        };
5810        #[cfg(any(clt_turso_tests, injected_yields))]
5811        let exclusive_yield_context = YieldContext::new(
5812            connection.yield_injector(),
5813            None,
5814            connection.next_yield_instance_id(),
5815            tx_id,
5816        );
5817        #[cfg(any(clt_turso_tests, injected_yields))]
5818        let exclusive_yield_context = Some(&exclusive_yield_context);
5819        #[cfg(not(any(clt_turso_tests, injected_yields)))]
5820        let exclusive_yield_context: Option<&YieldContext> = None;
5821
5822        let already_exclusive = self.is_exclusive_tx(&tx_id);
5823        if !already_exclusive {
5824            self.acquire_exclusive_tx(&tx_id, exclusive_yield_context)
5825                .inspect_err(|_| {
5826                    // Fresh txns were already published into `txs` above; undo
5827                    // that so a failed begin doesn't leave a phantom Active txn
5828                    // pinning the LWM forever.
5829                    if maybe_existing_tx_id.is_none() {
5830                        self.txs.remove(&tx_id);
5831                    }
5832                    unlock_checkpoint_guard();
5833                })?;
5834        }
5835
5836        // Hoist: validate the existing tx still exists and snapshot the
5837        // `pager_commit_lock_held` flag BEFORE acquiring `pager_commit_lock`,
5838        // so a vanished tx cannot strand the lock (#6905).
5839        let already_holds_commit_lock = match maybe_existing_tx_id {
5840            Some(existing_tx_id) => {
5841                let tx = self.txs.get(&existing_tx_id).ok_or_else(|| {
5842                    if !already_exclusive {
5843                        self.release_exclusive_tx(&tx_id);
5844                    }
5845                    unlock_checkpoint_guard();
5846                    LimboError::NoSuchTransactionID(existing_tx_id.to_string())
5847                })?;
5848                tx.value().pager_commit_lock_held.load(Ordering::Acquire)
5849            }
5850            None => false,
5851        };
5852
5853        if !already_holds_commit_lock {
5854            let locked = self.commit_coordinator.pager_commit_lock.write();
5855            if !locked {
5856                tracing::debug!(
5857                    "begin_exclusive_tx: tx_id={} failed with Busy on pager_commit_lock",
5858                    tx_id
5859                );
5860                if maybe_existing_tx_id.is_none() {
5861                    self.txs.remove(&tx_id);
5862                }
5863                if !already_exclusive {
5864                    self.release_exclusive_tx(&tx_id);
5865                }
5866                unlock_checkpoint_guard();
5867                return Err(LimboError::Busy);
5868            }
5869        }
5870
5871        let handle_err = || {
5872            if !already_holds_commit_lock {
5873                self.commit_coordinator.pager_commit_lock.unlock();
5874            }
5875            if !already_exclusive {
5876                self.release_exclusive_tx(&tx_id);
5877            }
5878            unlock_checkpoint_guard();
5879        };
5880
5881        if let Some(existing_tx_id) = maybe_existing_tx_id {
5882            // Upgrade path: read the (possibly blocking) header now that all
5883            // locks are held, then apply it to the already-published txn.
5884            let header = self.get_new_transaction_database_header(&pager);
5885            // Re-fetch the Ref now that all blocking I/O is done. If the tx
5886            // vanished between the earlier validation and now (extraordinarily
5887            // narrow window — only checkpoint can remove a tx and it cannot
5888            // race a commit-lock holder), release what we acquired and bail.
5889            let tx = self.txs.get(&existing_tx_id).ok_or_else(|| {
5890                handle_err();
5891                LimboError::NoSuchTransactionID(existing_tx_id.to_string())
5892            })?;
5893            tx.value()
5894                .pager_commit_lock_held
5895                .store(true, Ordering::Release);
5896            *tx.value().header.write() = header;
5897            tracing::trace!(
5898                "begin_exclusive_tx(tx_id={}, begin_ts={}) - upgraded existing transaction",
5899                tx_id,
5900                begin_ts
5901            );
5902            tracing::debug!("begin_exclusive_tx: tx_id={} succeeded", tx_id);
5903            return Ok(tx_id);
5904        }
5905
5906        // Fresh path: the transaction (with its header) was already published
5907        // into `txs` under the clock lock during begin_ts allocation. Now that
5908        // we hold the commit lock, just record that.
5909        let tx = self
5910            .txs
5911            .get(&tx_id)
5912            .expect("fresh exclusive tx was published during begin_ts allocation");
5913        tx.value()
5914            .pager_commit_lock_held
5915            .store(true, Ordering::Release);
5916        tracing::trace!(
5917            "begin_exclusive_tx(tx_id={}, begin_ts={}) - exclusive write logical log transaction",
5918            tx_id,
5919            begin_ts
5920        );
5921        tracing::debug!("begin_exclusive_tx: tx_id={} succeeded", tx_id);
5922        Ok(tx_id)
5923    }
5924
5925    /// Begins a new transaction in the database.
5926    ///
5927    /// This function starts a new transaction in the database and returns a `TxID` value
5928    /// that you can use to perform operations within the transaction. All changes made within the
5929    /// transaction are isolated from other transactions until you commit the transaction.
5930    pub fn begin_tx(&self, pager: Arc<Pager>) -> Result<TxID> {
5931        self.begin_tx_with_schema_generation(pager, None)
5932    }
5933
5934    /// `begin_tx` with the connection's validated `schema_generation` gate (see
5935    /// [`Connection::mvcc_begin_schema_generation`]). Used by the statement begin path so a passive
5936    /// checkpoint that republishes physical roots into the begin window forces a reprepare instead
5937    /// of a transaction beginning against stale roots.
5938    pub fn begin_tx_with_schema_generation(
5939        &self,
5940        pager: Arc<Pager>,
5941        expected_schema_generation: Option<u64>,
5942    ) -> Result<TxID> {
5943        let passive = self.experimental_mvcc_passive_checkpoint;
5944        if !passive && !self.blocking_checkpoint_lock.read() {
5945            // Stop-the-world truncate checkpoint in progress.
5946            return Err(LimboError::Busy);
5947        }
5948        let tx_id = self.get_tx_id();
5949
5950        // Ensure page 1 / global_header is initialized. The (possibly blocking) init I/O
5951        // happens here, BEFORE the clock lock; the header value itself is re-read inside the
5952        // clock callback below so it pairs atomically with begin_ts + schema_generation.
5953        self.get_new_transaction_database_header(&pager);
5954
5955        // Allocate begin_ts and publish the transaction into `txs` atomically
5956        // under the clock lock. This closes the "begin-publish window": between
5957        // allocating a snapshot timestamp and inserting into `txs`, the txn is
5958        // invisible to `compute_lwm`. Inline GC runs on the commit path holding
5959        // only `blocking_checkpoint_lock.read()` (truncate path), so a writer that
5960        // commits in that window could compute an LWM above our begin_ts and
5961        // reclaim a version this snapshot still needs — a snapshot-isolation
5962        // violation. Publishing under the clock lock orders us strictly before
5963        // or after any commit timestamp (commits also take the clock lock), so
5964        // any GC that runs after a later commit already sees our begin_ts.
5965        //
5966        // Truncate mode also holds `blocking_checkpoint_lock.read()` for the txn lifetime so
5967        // publish cannot interleave with read_mark capture. Passive mode relies on the clock:
5968        // its publish window runs under `get_timestamp` too, so begin and publish serialize on
5969        // the clock and never need to block each other — a begin that orders after a publish
5970        // simply observes the bumped `schema_generation` and reprepares (below).
5971        pager.mvcc_refresh_if_db_changed();
5972        let read_mark = WalPos::from_pair(pager.wal_pos());
5973        let mut schema_stale = false;
5974        let begin_ts = self.clock.get_timestamp(|ts| {
5975            // Capture header (cookie) + schema_generation INSIDE the clock so they are
5976            // consistent with the root map at insert time: a passive publish runs under this
5977            // same clock, so it cannot interleave between this capture and the insert.
5978            let schema_generation = self.schema_generation();
5979            // A publish that ordered into our begin window bumped the generation past the value
5980            // the caller validated its prepared schema against: reprepare instead of beginning
5981            // with stale physical roots.
5982            if expected_schema_generation.is_some_and(|exp| exp != schema_generation) {
5983                schema_stale = true;
5984                return;
5985            }
5986            let header = self
5987                .global_header
5988                .read()
5989                .expect("global_header initialized above");
5990            self.txs.insert(
5991                tx_id,
5992                Transaction::new(tx_id, ts, header, read_mark, schema_generation),
5993            );
5994        });
5995        if schema_stale {
5996            if !passive {
5997                self.blocking_checkpoint_lock.unlock();
5998            }
5999            return Err(LimboError::SchemaUpdated);
6000        }
6001        if !passive {
6002            if let Some(entry) = self.txs.get(&tx_id) {
6003                entry
6004                    .value()
6005                    .holds_blocking_checkpoint_read
6006                    .store(true, Ordering::Release);
6007            }
6008        }
6009        tracing::trace!("begin_tx(tx_id={}, begin_ts={})", tx_id, begin_ts);
6010
6011        Ok(tx_id)
6012    }
6013
6014    #[turso_macros::allocation_site(crate::alloc::MvStoreAllocationSite::TxInsert)]
6015    fn insert_tx_entry(&self, tx_id: TxID, tx: Transaction<A>) -> Result<(), TryReserveError> {
6016        self.txs.try_insert(tx_id, tx)?;
6017        Ok(())
6018    }
6019
6020    pub fn remove_tx(&self, tx_id: TxID) -> Result<(), TryReserveError> {
6021        self.remove_sequence_allocations(tx_id);
6022        if let Some(entry) = self.txs.get(&tx_id) {
6023            let tx = entry.value();
6024            let held_checkpoint_read = tx.holds_blocking_checkpoint_read.load(Ordering::Acquire);
6025            if let TransactionState::Committed(commit_ts) = tx.state.load() {
6026                // Read-only transactions cannot leave row versions with stale TxID
6027                // references, so they do not need finalized-state caching.
6028                if !tx.write_set.lock().is_empty() {
6029                    crate::without_allocation_faults!(self
6030                        .insert_finalized_tx_state(tx_id, commit_ts)
6031                        .expect(ALLOC_ERR_MSG));
6032                }
6033            }
6034            let dep_set = std::mem::take(&mut *tx.commit_dep_set.lock());
6035            // Invariant: commit_dep_set must be drained before removing the transaction.
6036            // CommitEnd and rollback_tx both drain the commit_dep_set to notify dependencies.
6037            // If we remove a transaction with non-empty commit_dep_set, those dependencies will wait
6038            // forever (deadlock).
6039            turso_assert!(
6040                dep_set.is_empty(),
6041                "remove_tx({tx_id}): commit_dep_set is not empty"
6042            );
6043            self.txs.remove(&tx_id);
6044            if held_checkpoint_read {
6045                self.blocking_checkpoint_lock.unlock();
6046            }
6047            return Ok(());
6048        }
6049        self.txs.remove(&tx_id);
6050        Ok(())
6051    }
6052
6053    #[turso_macros::allocation_site(crate::alloc::MvStoreAllocationSite::FinalizedTxStateInsert)]
6054    fn insert_finalized_tx_state(
6055        &self,
6056        tx_id: TxID,
6057        commit_ts: u64,
6058    ) -> Result<(), TryReserveError> {
6059        self.finalized_tx_states
6060            .try_insert(tx_id, TransactionState::Committed(commit_ts))?;
6061        Ok(())
6062    }
6063
6064    pub fn register_sequence_allocation(
6065        &self,
6066        tx_id: TxID,
6067        sequence_name: &str,
6068        sequence_value: i64,
6069    ) -> Result<()> {
6070        let Some(tx) = self.txs.get(&tx_id) else {
6071            return Err(LimboError::NoSuchTransactionID(tx_id.to_string()));
6072        };
6073        turso_assert!(
6074            matches!(
6075                tx.value().state.load(),
6076                TransactionState::Active | TransactionState::Preparing(_)
6077            ),
6078            "sequence allocation must be registered while the transaction is active or preparing"
6079        );
6080
6081        let sequence_name = crate::util::normalize_ident(sequence_name);
6082        let mut allocations = self.sequence_allocations.lock();
6083        let tx_allocations = allocations.entry(sequence_name).or_default();
6084        tx_allocations
6085            .entry(tx_id)
6086            .and_modify(|value| *value = (*value).min(sequence_value))
6087            .or_insert(sequence_value);
6088        Ok(())
6089    }
6090
6091    pub fn set_sequence_watermark(&self, sequence_name: &str, watermark: i64) {
6092        let sequence_name = crate::util::normalize_ident(sequence_name);
6093        self.sequence_watermarks
6094            .lock()
6095            .insert(sequence_name, watermark);
6096    }
6097
6098    /// Returns the first sequence value that is not safe for cursor scans to pass.
6099    ///
6100    /// Readers can safely consume rows with sequence values less than this
6101    /// watermark. The value is the minimum of the current sequence boundary and
6102    /// any lower value already allocated by an active transaction.
6103    pub fn sequence_watermark(&self, sequence_name: &str) -> Option<i64> {
6104        let sequence_name = crate::util::normalize_ident(sequence_name);
6105        let mut allocations = self.sequence_allocations.lock();
6106        let mut remove_allocations = false;
6107        let active_watermark = {
6108            allocations
6109                .get_mut(&sequence_name)
6110                .and_then(|tx_allocations| {
6111                    tx_allocations.retain(|tx_id, _| {
6112                        self.txs.get(tx_id).is_some_and(|tx| {
6113                            matches!(
6114                                tx.value().state.load(),
6115                                TransactionState::Active | TransactionState::Preparing(_)
6116                            )
6117                        })
6118                    });
6119                    let watermark = tx_allocations.values().copied().min();
6120                    if tx_allocations.is_empty() {
6121                        remove_allocations = true;
6122                    }
6123                    watermark
6124                })
6125        };
6126        if remove_allocations {
6127            allocations.remove(&sequence_name);
6128        }
6129        let current_watermark = self.sequence_watermarks.lock().get(&sequence_name).copied();
6130        match (current_watermark, active_watermark) {
6131            (Some(current), Some(active)) => Some(current.min(active)),
6132            (Some(current), None) => Some(current),
6133            (None, active) => active,
6134        }
6135    }
6136
6137    fn remove_sequence_allocations(&self, tx_id: TxID) {
6138        let mut allocations = self.sequence_allocations.lock();
6139        allocations.retain(|_, tx_allocations| {
6140            tx_allocations.remove(&tx_id);
6141            !tx_allocations.is_empty()
6142        });
6143    }
6144
6145    /// Atomically retire a committed tx: clear the connection's mv_tx_id cache
6146    /// for `db_id`, then remove the tx from `txs`. Pairs the two mutations so
6147    /// no concurrent observer (or in-flight statement) can see the divergent
6148    /// `(cache=Some, txs=None)` state — the production-panic shape from
6149    /// `release_named_savepoint` and `NoSuchTransactionID` read-path errors.
6150    ///
6151    /// Order matches `rollback_tx` (cache cleared before `remove_tx`) so the
6152    /// commit and rollback paths are symmetric.
6153    ///
6154    /// Use this anywhere the commit state machine would otherwise call
6155    /// `remove_tx` directly. Other call sites that don't have a connection
6156    /// context (e.g. tests poking internal state) keep using `remove_tx`.
6157    pub fn finish_committed_tx(
6158        &self,
6159        tx_id: TxID,
6160        conn: &Connection,
6161        db_id: usize,
6162    ) -> Result<(), TryReserveError> {
6163        conn.set_mv_tx_for_db(db_id, None);
6164        self.remove_tx(tx_id)
6165    }
6166
6167    fn get_new_transaction_database_header(&self, pager: &Arc<Pager>) -> DatabaseHeader {
6168        if self.global_header.read().is_none() {
6169            pager
6170                .io
6171                .block(|| pager.maybe_allocate_page1())
6172                .expect("failed to allocate page1");
6173            let header = pager
6174                .io
6175                .block(|| pager.with_header(|header| *header))
6176                .expect("failed to read database header");
6177            // TODO: We initialize header here, maybe this needs more careful handling
6178            self.global_header.write().replace(header);
6179            tracing::debug!(
6180                "get_transaction_database_header create: header={:?}",
6181                header
6182            );
6183            header
6184        } else {
6185            let header = self
6186                .global_header
6187                .read()
6188                .expect("global_header should be initialized");
6189            // The header could be stored, but not persisted yet
6190            pager
6191                .io
6192                .block(|| pager.maybe_allocate_page1())
6193                .expect("failed to allocate page1");
6194            tracing::debug!("get_transaction_database_header read: header={:?}", header);
6195            header
6196        }
6197    }
6198
6199    pub fn get_transaction_database_header(&self, tx_id: &TxID) -> DatabaseHeader {
6200        let tx = self
6201            .txs
6202            .get(tx_id)
6203            .expect("transaction not found when trying to get header");
6204        let header = tx.value();
6205        let header = header.header.read();
6206        tracing::debug!("get_transaction_database_header read: header={:?}", header);
6207        *header
6208    }
6209
6210    /// Update the cached global header's page size to match a fresh `PRAGMA page_size`.
6211    ///
6212    /// `global_header` is captured from the pager during MVCC bootstrap, before any PRAGMA
6213    /// can run, so it always starts at the default 4 KiB. Subsequent transactions copy from
6214    /// it, which means a `PRAGMA page_size = N` issued on the connection would otherwise be
6215    /// invisible to MVCC header lookups even though the pager itself honors `N` for on-disk
6216    /// page allocation. Only valid before any data has been written; matches the same
6217    /// precondition `Connection::reset_page_size` enforces via `db.initialized()`.
6218    pub fn set_global_page_size(&self, size: PageSize) {
6219        let mut header = self.global_header.write();
6220        if let Some(header) = header.as_mut() {
6221            header.page_size = size;
6222        }
6223    }
6224
6225    pub fn with_header<T, F>(&self, f: F, tx_id: Option<&TxID>) -> Result<T>
6226    where
6227        F: Fn(&DatabaseHeader) -> T,
6228    {
6229        if let Some(tx_id) = tx_id {
6230            let tx = self
6231                .txs
6232                .get(tx_id)
6233                .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
6234            let header = tx.value();
6235            let header = header.header.read();
6236            tracing::debug!("with_header read: header={:?}", header);
6237            Ok(f(&header))
6238        } else {
6239            let header = self.global_header.read();
6240            tracing::debug!("with_header read: header={:?}", header);
6241            Ok(f(header.as_ref().ok_or_else(|| {
6242                LimboError::InternalError("global_header not initialized".to_string())
6243            })?))
6244        }
6245    }
6246
6247    pub fn with_header_mut<T, F>(&self, f: F, tx_id: Option<&TxID>) -> Result<T>
6248    where
6249        F: Fn(&mut DatabaseHeader) -> T,
6250    {
6251        if let Some(tx_id) = tx_id {
6252            let tx = self
6253                .txs
6254                .get(tx_id)
6255                .ok_or_else(|| LimboError::NoSuchTransactionID(tx_id.to_string()))?;
6256            let header = tx.value();
6257            let mut header = header.header.write();
6258            tracing::debug!("with_header_mut read: header={:?}", header);
6259            let out = f(&mut header);
6260            // Commit path consults this flag to decide whether a header-only logical-log record
6261            // is required even when write_set stays empty.
6262            tx.value().header_dirty.store(true, Ordering::Release);
6263            Ok(out)
6264        } else {
6265            let mut header = self.global_header.write();
6266            let header = header.as_mut().ok_or_else(|| {
6267                LimboError::InternalError("global_header not initialized".to_string())
6268            })?;
6269            tracing::debug!("with_header_mut write: header={:?}", header);
6270            Ok(f(header))
6271        }
6272    }
6273
6274    /// Commits a transaction with the specified transaction ID.
6275    ///
6276    /// This function commits the changes made within the specified transaction and finalizes the
6277    /// transaction. Once a transaction has been committed, all changes made within the transaction
6278    /// are visible to other transactions that access the same data.
6279    ///
6280    /// # Arguments
6281    ///
6282    /// * `tx_id` - The ID of the transaction to commit.
6283    pub fn commit_tx(
6284        self: &Arc<Self>,
6285        tx_id: TxID,
6286        connection: &Arc<Connection>,
6287        db_id: usize,
6288    ) -> Result<StateMachine<Box<CommitStateMachine<Clock, A>>>> {
6289        let state = Box::new(CommitStateMachine::new(
6290            CommitState::Initial,
6291            tx_id,
6292            self.clone(),
6293            connection.clone(),
6294            db_id,
6295            self.commit_coordinator.clone(),
6296            self.global_header.clone(),
6297            connection.get_sync_mode(),
6298        ));
6299        let state_machine = StateMachine::new(state);
6300        Ok(state_machine)
6301    }
6302
6303    /// Returns true if the transaction can be rolled back (Active or Preparing).
6304    pub fn is_tx_rollbackable(&self, tx_id: TxID) -> bool {
6305        self.txs.get(&tx_id).is_some_and(|tx| {
6306            matches!(
6307                tx.value().state.load(),
6308                TransactionState::Active | TransactionState::Preparing(_)
6309            )
6310        })
6311    }
6312
6313    /// Rolls back a transaction with the specified ID.
6314    ///
6315    /// This function rolls back a transaction with the specified `tx_id` by
6316    /// discarding any changes made by the transaction.
6317    ///
6318    /// # Arguments
6319    ///
6320    /// * `tx_id` - The ID of the transaction to abort.
6321    /// * `db` - The database index this transaction belongs to.
6322    pub fn rollback_tx(&self, tx_id: TxID, _pager: Arc<Pager>, connection: &Connection, db: usize) {
6323        self.rollback_tx_inner(tx_id, Some(connection), db);
6324    }
6325
6326    fn rollback_tx_inner(&self, tx_id: TxID, connection: Option<&Connection>, db: usize) {
6327        let tx_unlocked = self
6328            .txs
6329            .get(&tx_id)
6330            .expect("transaction should exist in txs map");
6331        let tx = tx_unlocked.value();
6332        if let Some(connection) = connection {
6333            connection.set_mv_tx_for_db(db, None);
6334        }
6335        turso_assert!(matches!(
6336            tx.state.load(),
6337            TransactionState::Active | TransactionState::Preparing(_)
6338        ));
6339        tx.state.store(TransactionState::Aborted);
6340        tracing::trace!("abort(tx_id={})", tx_id);
6341        self.unlock_commit_lock_if_held(tx);
6342
6343        // Hekaton Section 3.3: "If it aborted, it forces the dependent transactions
6344        // to also abort by setting their AbortNow flags."
6345        let dependents = std::mem::take(&mut *tx.commit_dep_set.lock());
6346        // a txn cannot depend on itself
6347        turso_assert!(
6348            !dependents.contains(&tx_id),
6349            "rollback_tx: transaction has itself in its own commit_dep_set"
6350        );
6351        for dep_tx_id in dependents {
6352            if let Some(dep_tx_entry) = self.txs.get(&dep_tx_id) {
6353                let dep_tx = dep_tx_entry.value();
6354                dep_tx.abort_now.store(true, Ordering::Release);
6355                dep_tx.commit_dep_counter.fetch_sub(1, Ordering::AcqRel);
6356            }
6357        }
6358
6359        if self.is_exclusive_tx(&tx_id) {
6360            self.release_exclusive_tx(&tx_id);
6361        }
6362
6363        // Snapshot under the lock so we can drop it before recursing into
6364        // `rollback_rowid` (which may take other locks).
6365        let write_set_snapshot: Vec<(RowID, RowVersions<A>)> = tx.write_set.lock().to_vec();
6366        for (_rowid, row_versions) in &write_set_snapshot {
6367            for rv in row_versions.write().iter_mut() {
6368                rollback_row_version(tx_id, rv);
6369            }
6370        }
6371
6372        if let Some(connection) = connection {
6373            if connection.schema.read().schema_version > connection.db.schema.lock().schema_version
6374            {
6375                // Connection made schema changes during tx and rolled back -> revert connection-local schema.
6376                *connection.schema.write() = connection.db.clone_schema();
6377            }
6378        }
6379
6380        let tx = tx_unlocked.value();
6381        tx.state.store(TransactionState::Terminated);
6382        tracing::trace!("terminate(tx_id={})", tx_id);
6383        // Safe to remove here: the rollback loop above acquired the write lock on
6384        // every row version chain in the write set, clearing all TxID references.
6385        // Any concurrent reader that held a read lock on one of those chains has
6386        // already completed its register_commit_dependency call (it runs under the
6387        // read lock), so no future txs.get() for this tx_id can come from a
6388        // speculative read path.
6389        crate::without_allocation_faults!(self.remove_tx(tx_id).expect(ALLOC_ERR_MSG));
6390    }
6391
6392    fn cleanup_dropped_commit(&self, tx_id: TxID, connection: &Connection, db_id: usize) {
6393        let tx_state = self.txs.get(&tx_id).map(|tx| tx.value().state.load());
6394        match tx_state {
6395            Some(TransactionState::Active | TransactionState::Preparing(_)) => {
6396                self.rollback_tx_inner(tx_id, Some(connection), db_id);
6397            }
6398            Some(TransactionState::Committed(end_ts)) => {
6399                // The dropped statement may have been interrupted mid
6400                // `RewriteLiveVersions`, leaving live row versions (e.g. b-tree
6401                // tombstones) that still reference this TxID. Finish the rewrite
6402                // synchronously before removing the tx from `txs`, otherwise later
6403                // visibility/conflict checks would find versions pointing at a
6404                // removed TxID (https://github.com/tursodatabase/turso/issues/7477).
6405                self.rewrite_live_versions_for_committed_tx(tx_id, end_ts);
6406                if let Some(tx) = self.txs.get(&tx_id) {
6407                    self.unlock_commit_lock_if_held(tx.value());
6408                }
6409                if self.is_exclusive_tx(&tx_id) {
6410                    self.release_exclusive_tx(&tx_id);
6411                }
6412                crate::without_allocation_faults!(self
6413                    .finish_committed_tx(tx_id, connection, db_id)
6414                    .expect(ALLOC_ERR_MSG));
6415            }
6416            Some(TransactionState::Aborted | TransactionState::Terminated) | None => {
6417                if connection.get_mv_tx_id_for_db(db_id) == Some(tx_id) {
6418                    connection.set_mv_tx_for_db(db_id, None);
6419                }
6420                if self.is_exclusive_tx(&tx_id) {
6421                    self.release_exclusive_tx(&tx_id);
6422                }
6423            }
6424        }
6425    }
6426
6427    /// Rewrite every live row version in `tx_id`'s write set that still
6428    /// references the TxID to the committed timestamp `end_ts`.
6429    ///
6430    /// Synchronous (unchunked) counterpart of `step_rewrite_live_versions`:
6431    /// a commit statement dropped mid-`RewriteLiveVersions` must finish
6432    /// publishing its timestamps before the tx is removed from `txs`, so no
6433    /// row version is left referencing a TxID that no longer resolves.
6434    fn rewrite_live_versions_for_committed_tx(&self, tx_id: TxID, end_ts: u64) {
6435        let Some(tx_entry) = self.txs.get(&tx_id) else {
6436            return;
6437        };
6438        turso_assert!(
6439            matches!(tx_entry.value().state.load(), TransactionState::Committed(ts) if ts == end_ts),
6440            "rewrite_live_versions_for_committed_tx requires a committed transaction state"
6441        );
6442        let write_set = tx_entry.value().write_set.lock();
6443        for (_id, row_versions) in write_set.iter() {
6444            let mut row_versions = row_versions.write();
6445            for row_version in row_versions.iter_mut() {
6446                row_version.rewrite_txid_to_timestamp(tx_id, end_ts);
6447            }
6448        }
6449    }
6450
6451    fn unlock_commit_lock_if_held(&self, tx: &Transaction<A>) {
6452        if tx.pager_commit_lock_held.swap(false, Ordering::AcqRel) {
6453            self.commit_coordinator.pager_commit_lock.unlock();
6454        }
6455    }
6456
6457    /// Begin a savepoint for the transaction.
6458    /// This should be called at the start of a statement in an interactive transaction.
6459    pub fn begin_savepoint(&self, tx_id: TxID) {
6460        let tx = self
6461            .txs
6462            .get(&tx_id)
6463            .unwrap_or_else(|| panic!("Transaction {tx_id} not found while beginning savepoint"));
6464        tx.value().begin_savepoint();
6465    }
6466
6467    /// Begin a user-visible named savepoint inside an existing transaction.
6468    ///
6469    /// `starts_transaction` is true when the savepoint was opened in autocommit mode and therefore
6470    /// releasing the root savepoint should commit the transaction.
6471    pub fn begin_named_savepoint(
6472        &self,
6473        tx_id: TxID,
6474        name: String,
6475        starts_transaction: bool,
6476        deferred_fk_violations: isize,
6477    ) {
6478        let tx = self.txs.get(&tx_id).unwrap_or_else(|| {
6479            panic!("Transaction {tx_id} not found while beginning named savepoint")
6480        });
6481        tx.value()
6482            .begin_named_savepoint(name, starts_transaction, deferred_fk_violations);
6483    }
6484
6485    /// Release the newest savepoint for the transaction.
6486    /// This should be called when a statement completes successfully.
6487    /// Silently returns if the transaction doesn't exist (e.g., already committed).
6488    pub fn release_savepoint(&self, tx_id: TxID) {
6489        if let Some(tx) = self.txs.get(&tx_id) {
6490            tx.value().release_savepoint();
6491        }
6492        // If transaction doesn't exist, it was already committed - nothing to release
6493    }
6494
6495    /// Releases a named savepoint and nested savepoints above it.
6496    ///
6497    /// Returns [SavepointResult::Commit] when releasing the root savepoint should commit the
6498    /// transaction.
6499    pub fn release_named_savepoint(&self, tx_id: TxID, name: &str) -> Result<SavepointResult> {
6500        let tx = self
6501            .txs
6502            .get(&tx_id)
6503            .unwrap_or_else(|| panic!("Transaction {tx_id} not found while releasing savepoint"));
6504        Ok(tx.value().release_named_savepoint(name))
6505    }
6506
6507    /// Rolls back a savepoint within a transaction.
6508    /// Returns true if a savepoint was rolled back, false if no savepoint was active.
6509    pub fn rollback_first_savepoint(&self, tx_id: u64) -> Result<bool> {
6510        let tx = self.txs.get(&tx_id).unwrap_or_else(|| {
6511            panic!("Transaction {tx_id} not found while rolling back savepoint")
6512        });
6513
6514        let tx = tx.value();
6515        let savepoint = tx.pop_statement_savepoint();
6516
6517        if let Some(savepoint) = savepoint {
6518            self.rollback_savepoint_changes(tx_id, savepoint);
6519            Ok(true)
6520        } else {
6521            tracing::debug!(
6522                "rollback_savepoint(tx_id={}): no savepoint was active",
6523                tx_id
6524            );
6525            Ok(false)
6526        }
6527    }
6528
6529    /// Rolls back to the newest matching named savepoint while keeping that savepoint active.
6530    ///
6531    /// Returns the deferred FK snapshot stored on the named savepoint, or `None` if no matching
6532    /// savepoint exists.
6533    pub fn rollback_to_named_savepoint(&self, tx_id: TxID, name: &str) -> Result<Option<isize>> {
6534        let tx = self.txs.get(&tx_id).unwrap_or_else(|| {
6535            panic!("Transaction {tx_id} not found while rolling back named savepoint")
6536        });
6537        let Some(SavepointRollbackResult {
6538            rolledback_savepoints,
6539            deferred_fk_violations,
6540        }) = tx.value().rollback_to_named_savepoint(name)
6541        else {
6542            return Ok(None);
6543        };
6544
6545        for savepoint in rolledback_savepoints.into_iter().rev() {
6546            self.rollback_savepoint_changes(tx_id, savepoint);
6547        }
6548
6549        Ok(Some(deferred_fk_violations))
6550    }
6551
6552    fn rollback_savepoint_changes(&self, tx_id: TxID, savepoint: Savepoint<A>) {
6553        let Savepoint {
6554            header,
6555            header_dirty,
6556            created_table_versions,
6557            created_index_versions,
6558            deleted_table_versions,
6559            deleted_index_versions,
6560            newly_added_to_write_set,
6561            ..
6562        } = savepoint;
6563
6564        tracing::debug!(
6565            "rollback_savepoint(tx_id={}, created_table={}, created_index={}, deleted_table={}, deleted_index={})",
6566            tx_id,
6567            created_table_versions.len(),
6568            created_index_versions.len(),
6569            deleted_table_versions.len(),
6570            deleted_index_versions.len()
6571        );
6572
6573        let mut touched_rowids = BTreeSet::new();
6574
6575        for (rowid, version_id) in created_table_versions {
6576            touched_rowids.insert(rowid.clone());
6577            if let Some(entry) = self.rows.get(&rowid) {
6578                let mut versions = entry.value().write();
6579                let before = versions.len();
6580                versions.retain(|rv| rv.id != version_id);
6581                self.dec_live_version_count_approx(before - versions.len());
6582                tracing::debug!(
6583                    "rollback_savepoint: removed table version(table_id={}, row_id={}, version_id={})",
6584                    rowid.table_id,
6585                    rowid.row_id,
6586                    version_id
6587                );
6588            }
6589        }
6590
6591        for ((table_id, key), version_id) in created_index_versions {
6592            if let Some(index) = self.index_rows.get(&table_id) {
6593                if let Some(entry) = index.value().get(&key) {
6594                    let mut versions = entry.value().write();
6595                    let before = versions.len();
6596                    versions.retain(|rv| rv.id != version_id);
6597                    self.dec_live_version_count_approx(before - versions.len());
6598                    tracing::debug!(
6599                        "rollback_savepoint: removed index version(table_id={}, version_id={})",
6600                        table_id,
6601                        version_id
6602                    );
6603                }
6604            }
6605        }
6606
6607        for (rowid, version_id) in deleted_table_versions {
6608            touched_rowids.insert(rowid.clone());
6609            if let Some(entry) = self.rows.get(&rowid) {
6610                let mut versions = entry.value().write();
6611                for rv in versions.iter_mut() {
6612                    if rv.id == version_id {
6613                        rv.set_end(None);
6614                        tracing::debug!(
6615                            "rollback_savepoint: restored table version(table_id={}, row_id={}, version_id={})",
6616                            rowid.table_id,
6617                            rowid.row_id,
6618                            version_id
6619                        );
6620                        break;
6621                    }
6622                }
6623            }
6624        }
6625
6626        for ((table_id, key), version_id) in deleted_index_versions {
6627            if let Some(index) = self.index_rows.get(&table_id) {
6628                if let Some(entry) = index.value().get(&key) {
6629                    let mut versions = entry.value().write();
6630                    for rv in versions.iter_mut() {
6631                        if rv.id == version_id {
6632                            rv.set_end(None);
6633                            tracing::debug!(
6634                                "rollback_savepoint: restored index version(table_id={}, version_id={})",
6635                                table_id,
6636                                version_id
6637                            );
6638                            break;
6639                        }
6640                    }
6641                }
6642            }
6643        }
6644
6645        touched_rowids.extend(newly_added_to_write_set.into_iter().map(|(id, _)| id));
6646        self.remove_rolled_back_rows_from_write_set(tx_id, touched_rowids.clone());
6647
6648        let tx = self
6649            .txs
6650            .get(&tx_id)
6651            .unwrap_or_else(|| panic!("Transaction {tx_id} not found while restoring savepoint"));
6652        let tx = tx.value();
6653        *tx.header.write() = header;
6654        tx.header_dirty.store(header_dirty, Ordering::Release);
6655    }
6656
6657    fn row_has_uncommitted_version_for_tx(&self, rowid: &RowID, tx_id: TxID) -> bool {
6658        if rowid.row_id.is_int_key() {
6659            let Some(entry) = self.rows.get(rowid) else {
6660                return false;
6661            };
6662            let versions = entry.value().read();
6663            return versions.iter().any(|rv| {
6664                rv.begin() == Some(TxTimestampOrID::TxID(tx_id))
6665                    || rv.end() == Some(TxTimestampOrID::TxID(tx_id))
6666            });
6667        }
6668
6669        let RowKey::Record(ref record) = rowid.row_id else {
6670            return false;
6671        };
6672        let Some(index) = self.index_rows.get(&rowid.table_id) else {
6673            return false;
6674        };
6675        let Some(entry) = index.value().get(record.as_ref()) else {
6676            return false;
6677        };
6678        let versions = entry.value().read();
6679        versions.iter().any(|rv| {
6680            rv.begin() == Some(TxTimestampOrID::TxID(tx_id))
6681                || rv.end() == Some(TxTimestampOrID::TxID(tx_id))
6682        })
6683    }
6684
6685    fn remove_rolled_back_rows_from_write_set(&self, tx_id: TxID, rowids: BTreeSet<RowID>) {
6686        if rowids.is_empty() {
6687            return;
6688        }
6689        let Some(tx) = self.txs.get(&tx_id) else {
6690            return;
6691        };
6692        let tx = tx.value();
6693        // Single pass: drop entries that appear in `rowids` AND have no
6694        // surviving uncommitted version (parent savepoints may still pin
6695        // them).
6696        let mut write_set = tx.write_set.lock();
6697        write_set.retain(|rowid, _rv| {
6698            if !rowids.contains(rowid) {
6699                return true;
6700            }
6701            self.row_has_uncommitted_version_for_tx(rowid, tx_id)
6702        });
6703    }
6704
6705    /// Returns true if the given transaction is the exclusive transaction.
6706    #[inline]
6707    pub fn is_exclusive_tx(&self, tx_id: &TxID) -> bool {
6708        self.exclusive_tx.load(Ordering::Acquire) == *tx_id
6709    }
6710
6711    /// Returns true if there is an exclusive transaction ongoing.
6712    #[inline]
6713    fn has_exclusive_tx(&self) -> bool {
6714        self.exclusive_tx.load(Ordering::Acquire) != NO_EXCLUSIVE_TX
6715    }
6716
6717    fn has_preparing_tx_other_than(&self, tx_id: TxID) -> bool {
6718        self.txs.iter().any(|entry| {
6719            *entry.key() != tx_id
6720                && matches!(entry.value().state.load(), TransactionState::Preparing(_))
6721        })
6722    }
6723
6724    /// Acquires the exclusive transaction lock to the given transaction ID.
6725    fn acquire_exclusive_tx(
6726        &self,
6727        tx_id: &TxID,
6728        yield_context: Option<&YieldContext>,
6729    ) -> Result<()> {
6730        #[cfg(not(any(clt_turso_tests, injected_yields)))]
6731        let _ = yield_context;
6732        if self.exclusive_tx.load(Ordering::Acquire) == *tx_id {
6733            // Re-entrant upgrade attempt for the same transaction.
6734            return Ok(());
6735        }
6736        // if some other transaction is in preparing state, then we cannot let this tx to
6737        // continue, as the preparing txn will eventually commit
6738        if self.has_preparing_tx_other_than(*tx_id) {
6739            return Err(LimboError::Busy);
6740        }
6741        // after we acquired begin_ts, we will check if some other txn committed in the meantime.
6742        // If so, no point in letting this txn to progress as it's begin_ts is less than
6743        // other txn's commit ts.
6744        // do note that this is an optimistic / early check. We need to check this again after this
6745        // txn gets exclusive txn status. check below after the CAS
6746        if let Some(tx) = self.txs.get(tx_id) {
6747            let tx = tx.value();
6748            if tx.begin_ts < self.last_committed_tx_ts.load(Ordering::Acquire) {
6749                // Another transaction committed after this transaction's begin timestamp, do not allow exclusive lock.
6750                // This mimics regular (non-CONCURRENT) sqlite transaction behavior.
6751                return Err(LimboError::Busy);
6752            }
6753        }
6754        #[cfg(any(clt_turso_tests, injected_yields))]
6755        if let Some(yield_context) = yield_context {
6756            if yield_context.injector.as_ref().is_some_and(|injector| {
6757                injector.should_yield(
6758                    yield_context.instance_id,
6759                    yield_context.selection_key,
6760                    ExclusiveTxYieldPoint::AfterTimestampCheckBeforeCas.point(),
6761                )
6762            }) {
6763                tracing::debug!(
6764                    tx_id,
6765                    "injected exclusive acquisition interleaving before CAS"
6766                );
6767            }
6768        }
6769        match self.exclusive_tx.compare_exchange(
6770            NO_EXCLUSIVE_TX,
6771            *tx_id,
6772            Ordering::AcqRel,
6773            Ordering::Acquire,
6774        ) {
6775            Ok(_) => {
6776                if self.has_preparing_tx_other_than(*tx_id) {
6777                    self.release_exclusive_tx(tx_id);
6778                    return Err(LimboError::Busy);
6779                }
6780                // we will check again, if some other txn committed in the meantime.
6781                // we did this check previously too, but we will have to do this again.
6782                // consider this timeline of events:
6783                //
6784                // t0 - no txn is preparing, and last_committed_ts is smaller than begin_ts
6785                //      we proceed to do CAS
6786                // t1 - we are yet to do CAS, but another txn comes in, goes into
6787                //      preparing state, and commits with commit_ts greater than our begin_ts
6788                // t3 - we proceed with CAS and get exclusive txn status
6789                //
6790                // at this point, we have got exclusive txn but last_committed_tx_ts is greater than
6791                // our begin_ts, violating the isolation guarantee.
6792                //
6793                // we want to prevent this. so we acquire the exclusive txn and then check again.
6794                // we can also be sure that once we get the exclusive txn status, no other txn can sneak in and commit,
6795                // because to commit, we make sure that there is no other exclusive txn. Check
6796                // `CommitState::Initial`.
6797                if let Some(tx) = self.txs.get(tx_id) {
6798                    let tx = tx.value();
6799                    if tx.begin_ts < self.last_committed_tx_ts.load(Ordering::Acquire) {
6800                        self.release_exclusive_tx(tx_id);
6801                        return Err(LimboError::Busy);
6802                    }
6803                }
6804                Ok(())
6805            }
6806            Err(_) => {
6807                // Another transaction already holds the exclusive lock
6808                Err(LimboError::Busy)
6809            }
6810        }
6811    }
6812
6813    /// Release the exclusive transaction lock if held by the this transaction.
6814    fn release_exclusive_tx(&self, tx_id: &TxID) {
6815        tracing::trace!("release_exclusive_tx(tx_id={})", tx_id);
6816        let prev = self.exclusive_tx.swap(NO_EXCLUSIVE_TX, Ordering::Release);
6817        turso_assert_eq!(prev, *tx_id, "exclusive lock released by wrong tx", { "expected_tx_id": *tx_id, "actual_tx_id": prev });
6818    }
6819
6820    /// Generates next unique transaction id
6821    pub fn get_tx_id(&self) -> u64 {
6822        self.tx_ids.fetch_add(1, Ordering::SeqCst)
6823    }
6824
6825    /// Generates next unique version ID for RowVersion tracking.
6826    pub fn get_version_id(&self) -> u64 {
6827        self.version_id_counter.fetch_add(1, Ordering::SeqCst)
6828    }
6829
6830    /// Generate a begin timestamp. No side-effect needed alongside generation.
6831    pub fn get_begin_timestamp(&self) -> u64 {
6832        self.clock.get_timestamp(crate::mvcc::clock::no_op)
6833    }
6834
6835    /// Generate a commit timestamp and call `f` with it while the clock
6836    /// lock is held, atomically publishing the timestamp before release.
6837    /// See [`MvccClock`] for the full explanation.
6838    pub fn get_commit_timestamp<F: FnOnce(u64)>(&self, f: F) -> u64 {
6839        self.clock.get_timestamp(f)
6840    }
6841
6842    /// Snapshot timestamp for a checkpoint, clamped below any in-flight (`Preparing`)
6843    /// commit. The published durable boundary (`durable_txid_max_new`) derives from this.
6844    /// `last_committed_tx_ts` is a `fetch_max` high-water mark, so a transaction that
6845    /// already assigned a *lower* `end_ts` and is still `Preparing` can sit below it; the
6846    /// checkpoint would skip that transaction (not yet `Committed`) yet publish a boundary
6847    /// above its `end_ts`, and a crash after it finalizes would discard its log frame
6848    /// (`commit_ts <= boundary`) even though it was never written to the B-tree — silent
6849    /// data loss. Clamping below the lowest `Preparing` end_ts prevents the straddle.
6850    ///
6851    /// Computed while holding the clock lock (via `get_timestamp`) so a transaction
6852    /// mid-(end_ts assignment + `Preparing` publish, which happen together under that
6853    /// lock) cannot be missed by the scan. Active transactions need no clamp: their future
6854    /// `end_ts` is drawn from the monotonic clock and is therefore `> snapshot_ts`.
6855    pub fn checkpoint_snapshot_ts(&self) -> u64 {
6856        let mut snapshot_ts = 0;
6857        self.clock.get_timestamp(|_now| {
6858            let last_committed = self.last_committed_tx_ts.load(Ordering::Acquire);
6859            let inflight_floor = self
6860                .txs
6861                .iter()
6862                .filter_map(|entry| match entry.value().state.load() {
6863                    TransactionState::Preparing(ts) => Some(ts),
6864                    _ => None,
6865                })
6866                .min()
6867                .unwrap_or(u64::MAX);
6868            snapshot_ts = last_committed.min(inflight_floor.saturating_sub(1));
6869        });
6870        snapshot_ts
6871    }
6872
6873    /// Try to enter the passive publish window. Returns false if another publish is in flight.
6874    pub(crate) fn try_begin_passive_publish_window(&self) -> bool {
6875        debug_assert!(self.experimental_mvcc_passive_checkpoint);
6876        self.checkpoint_publish_in_progress
6877            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
6878            .is_ok()
6879    }
6880
6881    /// Release the passive publish drain bit after a successful or failed publish attempt.
6882    pub(crate) fn end_passive_publish_window(&self) {
6883        self.checkpoint_publish_in_progress
6884            .store(false, Ordering::Release);
6885    }
6886
6887    pub(crate) fn schema_generation(&self) -> u64 {
6888        self.schema_generation.load(Ordering::Acquire)
6889    }
6890
6891    pub(crate) fn bump_schema_generation(&self) {
6892        self.schema_generation.fetch_add(1, Ordering::Release);
6893    }
6894
6895    /// Passive checkpoint published new physical roots after this transaction began.
6896    pub fn schema_still_valid_for_tx(&self, tx_id: TxID) -> Result<()> {
6897        if !self.experimental_mvcc_passive_checkpoint {
6898            return Ok(());
6899        }
6900        let Some(entry) = self.txs.get(&tx_id) else {
6901            return Ok(());
6902        };
6903        if entry.value().schema_generation_at_begin != self.schema_generation() {
6904            return Err(LimboError::SchemaUpdated);
6905        }
6906        Ok(())
6907    }
6908
6909    /// Compute the low-water mark: the minimum begin_ts of all active or
6910    /// preparing transactions. Returns u64::MAX if no transactions are active.
6911    /// Used by GC to determine which row versions are safe to reclaim.
6912    pub fn compute_lwm(&self) -> u64 {
6913        self.txs
6914            .iter()
6915            .filter_map(|entry| {
6916                let tx = entry.value();
6917                match tx.state.load() {
6918                    TransactionState::Active | TransactionState::Preparing(_) => Some(tx.begin_ts),
6919                    _ => None,
6920                }
6921            })
6922            .min()
6923            .unwrap_or(u64::MAX)
6924    }
6925
6926    /// Default `mvcc_gc_threshold`: run an incremental GC pass roughly every
6927    /// this many newly inserted versions. Small enough that steady-state
6928    /// memory stays bounded under heavy short-txn concurrency, large enough
6929    /// that small workloads (and most unit tests) never trigger a pass.
6930    pub const DEFAULT_GC_VERSION_THRESHOLD: i64 = 16 * 1024;
6931
6932    /// Upper bound on table-row chains scanned by one inline `gc_incremental`
6933    /// pass on the commit path. Keeps a pass cheap (sub-millisecond) so it
6934    /// doesn't noticeably slow the committing connection; steady state relies
6935    /// on frequent passes resuming via `gc_table_cursor`.
6936    pub const MAX_CHAINS_PER_GC: usize = 4096;
6937
6938    /// Current approximate live row-version count. Heuristic only (see the
6939    /// `live_version_count_approx` field) — never use for correctness decisions.
6940    pub fn live_version_count_approx(&self) -> usize {
6941        self.live_version_count_approx.load(Ordering::Relaxed)
6942    }
6943
6944    /// Saturating decrement of the live-version heuristic. The counter is
6945    /// approximate, so clamp at zero rather than risk an underflow wrap that
6946    /// would make `should_gc` fire on every commit.
6947    fn dec_live_version_count_approx(&self, n: usize) {
6948        if n == 0 {
6949            return;
6950        }
6951        let mut current = self.live_version_count_approx.load(Ordering::Relaxed);
6952        loop {
6953            let next = current.saturating_sub(n);
6954            match self.live_version_count_approx.compare_exchange_weak(
6955                current,
6956                next,
6957                Ordering::Relaxed,
6958                Ordering::Relaxed,
6959            ) {
6960                Ok(_) => break,
6961                Err(actual) => current = actual,
6962            }
6963        }
6964    }
6965
6966    /// Set the inline-GC trigger threshold (growth in live versions since the
6967    /// last GC pass). Negative disables inline GC. Mirrors
6968    /// `set_checkpoint_threshold`; wired to the `mvcc_gc_threshold` PRAGMA.
6969    pub fn set_gc_threshold(&self, threshold: i64) {
6970        self.gc_version_threshold
6971            .store(threshold, Ordering::Relaxed);
6972    }
6973
6974    pub fn gc_threshold(&self) -> i64 {
6975        self.gc_version_threshold.load(Ordering::Relaxed)
6976    }
6977
6978    /// Whether an incremental GC pass should run now: inline GC is enabled
6979    /// (threshold >= 0) and `live_version_count_approx` has grown past the threshold
6980    /// since the last pass. Heuristic — drift only changes GC frequency.
6981    pub fn should_gc(&self) -> bool {
6982        let threshold = self.gc_version_threshold.load(Ordering::Relaxed);
6983        if threshold < 0 {
6984            return false;
6985        }
6986        let current = self.live_version_count_approx.load(Ordering::Relaxed);
6987        let at_last = self.live_versions_at_last_gc.load(Ordering::Relaxed);
6988        current.saturating_sub(at_last) >= threshold as usize
6989    }
6990
6991    /// Garbage-collects row versions that are invisible to all active transactions.
6992    /// Uses the low-water mark (LWM) to determine reclaimability in O(1) per version.
6993    /// Covers both table rows (`self.rows`) and index rows (`self.index_rows`).
6994    /// Returns the number of removed versions.
6995    pub fn drop_unused_row_versions(&self) -> usize {
6996        self.drop_unused_row_versions_inner(false)
6997    }
6998
6999    /// Like [`Self::drop_unused_row_versions`], but additionally removes chain
7000    /// slots that end up empty from the skip maps, bounding their entry counts.
7001    ///
7002    /// The caller must hold the blocking checkpoint lock (or otherwise guarantee
7003    /// no concurrent writers): slot removal happens after the chain write lock
7004    /// is dropped, so without that guarantee it races a concurrent
7005    /// `get_or_insert_with` on the same key — see the TOCTOU note in
7006    /// `gc_table_row_versions`.
7007    pub fn drop_unused_row_versions_and_slots(&self) -> usize {
7008        self.drop_unused_row_versions_inner(true)
7009    }
7010
7011    /// Incremental, non-blocking GC pass — the inline counterpart to
7012    /// [`Self::drop_unused_row_versions`], driven from the commit path.
7013    ///
7014    /// Reclaims invisible versions (same rules as `gc_version_chain`) from up
7015    /// to `max_chains` table-row chains, resuming from where the previous pass
7016    /// stopped (`gc_table_cursor`) so repeated calls eventually cover the whole
7017    /// `rows` map without scanning it all at once.
7018    ///
7019    /// Safety / design notes:
7020    /// - **Lazy mode only.** Empty SkipMap slots are left in place (no
7021    ///   `entry.remove()`), so the pass needs no blocking checkpoint lock — it
7022    ///   races no concurrent `get_or_insert_with` (see the TOCTOU note in
7023    ///   `gc_table_row_versions`). Physical slot removal stays exclusive to the
7024    ///   checkpoint's `_and_slots` sweep.
7025    /// - `finalized_tx_states` pruning is intentionally skipped here: it needs
7026    ///   the *complete* referenced-txid set across all chains, which a partial
7027    ///   sweep cannot produce. The checkpoint path still prunes it.
7028    pub fn gc_incremental(&self, max_chains: usize) -> usize {
7029        // Truncate checkpoints hold the write side for the whole pass; pin a read
7030        // guard so inline GC cannot race them. Passive checkpoints use the publish
7031        // drain bit instead, so skip the lifetime-style pin here.
7032        let _ckpt_guard = if self.experimental_mvcc_passive_checkpoint {
7033            None
7034        } else if self.blocking_checkpoint_lock.read() {
7035            struct CheckpointReadGuard<'a>(&'a TursoRwLock);
7036            impl Drop for CheckpointReadGuard<'_> {
7037                fn drop(&mut self) {
7038                    self.0.unlock();
7039                }
7040            }
7041            Some(CheckpointReadGuard(&self.blocking_checkpoint_lock))
7042        } else {
7043            return 0;
7044        };
7045
7046        // Single-flight: only one inline GC pass runs at a time across all
7047        // connections (see `gc_in_progress`). Losing the race is a no-op — the
7048        // growth that triggered this commit's `should_gc` will retrigger soon,
7049        // or the in-flight pass already covers the same chains. The RAII guard
7050        // releases the gate even if the body panics, so a stuck flag can never
7051        // wedge GC for the lifetime of the store.
7052        if self
7053            .gc_in_progress
7054            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
7055            .is_err()
7056        {
7057            return 0;
7058        }
7059        struct GcGate<'a>(&'a AtomicBool);
7060        impl Drop for GcGate<'_> {
7061            fn drop(&mut self) {
7062                self.0.store(false, Ordering::Release);
7063            }
7064        }
7065        let _gate = GcGate(&self.gc_in_progress);
7066
7067        let passive = self.experimental_mvcc_passive_checkpoint;
7068        let lwm = if passive {
7069            let mut sampled = u64::MAX;
7070            self.clock.get_timestamp(|_| sampled = self.compute_lwm());
7071            sampled
7072        } else {
7073            self.compute_lwm()
7074        };
7075
7076        // Short-circuit when a long-running transaction has pinned the LWM at
7077        // the same value since the last pass: nothing newly reclaimable can
7078        // exist below it (any version superseded since then ends above the
7079        // pinning txn's begin_ts). `u64::MAX` (no active txns) never
7080        // short-circuits — that is exactly when the most is reclaimable. Reset
7081        // the trigger baseline so `should_gc` doesn't spin on every commit
7082        // while the LWM is stuck.
7083        if lwm != u64::MAX && lwm == self.gc_last_lwm.load(Ordering::Relaxed) {
7084            self.live_versions_at_last_gc.store(
7085                self.live_version_count_approx.load(Ordering::Relaxed),
7086                Ordering::Relaxed,
7087            );
7088            return 0;
7089        }
7090
7091        let ckpt_max = self.durable_txid_max.load(Ordering::SeqCst);
7092        // Bound by the backfill boundary: never reclaim a version materialized in un-backfilled
7093        // WAL frames — a db-file reader (present or future) needs the version-store copy.
7094        let min_reader_mark = self
7095            .compute_min_reader_mark()
7096            .min(*self.backfill_floor.read());
7097
7098        let mut dropped = 0;
7099        let mut processed = 0;
7100        let mut last_key: Option<RowID> = None;
7101
7102        // Resume strictly after the last key processed by the previous pass.
7103        let start_bound = match self.gc_table_cursor.lock().clone() {
7104            Some(k) => Bound::Excluded(k),
7105            None => Bound::Unbounded,
7106        };
7107        for entry in self.rows.range((start_bound, Bound::Unbounded)) {
7108            if processed >= max_chains {
7109                break;
7110            }
7111            // GC floor: retain rows of a freshly-materialized btree not yet visible to all readers.
7112            if !self.rootpage_gc_protected(&entry.key().table_id, min_reader_mark) {
7113                if passive {
7114                    self.clock.get_timestamp(|_| {
7115                        let lwm = self.compute_lwm();
7116                        let mut versions = entry.value().write();
7117                        dropped += Self::gc_version_chain(
7118                            &mut versions,
7119                            lwm,
7120                            ckpt_max,
7121                            true,
7122                            min_reader_mark,
7123                        );
7124                    });
7125                } else {
7126                    let mut versions = entry.value().write();
7127                    dropped += Self::gc_version_chain(
7128                        &mut versions,
7129                        lwm,
7130                        ckpt_max,
7131                        false,
7132                        min_reader_mark,
7133                    );
7134                }
7135            }
7136            last_key = Some(entry.key().clone());
7137            processed += 1;
7138        }
7139
7140        // If we processed fewer chains than the budget, the range was
7141        // exhausted — wrap the cursor to the start for the next pass.
7142        let table_wrapped = processed < max_chains;
7143        *self.gc_table_cursor.lock() = if table_wrapped { None } else { last_key };
7144
7145        // Sweep index chains with their own bounded, resumable budget (see
7146        // `gc_index_incremental`). Each map has an independent cursor so a
7147        // single huge index can't force an unbounded pass.
7148        dropped += self.gc_index_incremental(lwm, ckpt_max, max_chains);
7149
7150        self.dec_live_version_count_approx(dropped);
7151        // Reset the trigger baseline so `should_gc` measures growth from here.
7152        self.live_versions_at_last_gc.store(
7153            self.live_version_count_approx.load(Ordering::Relaxed),
7154            Ordering::Relaxed,
7155        );
7156
7157        // Record the LWM only once a full cycle (both cursors wrapped back to
7158        // the start) has completed at this LWM, so the short-circuit above
7159        // never skips chains we haven't yet swept at the current LWM. Until the
7160        // cycle finishes, leave `gc_last_lwm` unchanged so subsequent passes
7161        // keep scanning.
7162        let full_cycle =
7163            self.gc_table_cursor.lock().is_none() && self.gc_index_cursor.lock().is_none();
7164        if full_cycle {
7165            self.gc_last_lwm.store(lwm, Ordering::Relaxed);
7166        }
7167
7168        if dropped > 0 {
7169            tracing::trace!(
7170                "gc_incremental: reclaimed {dropped} versions ({processed} table chains, table_wrapped={table_wrapped}), live~{}",
7171                self.live_version_count_approx.load(Ordering::Relaxed)
7172            );
7173        }
7174        dropped
7175    }
7176
7177    /// Resumable index-chain GC: the index-map counterpart to the table sweep
7178    /// in [`Self::gc_incremental`]. Applies `gc_version_chain` to up to
7179    /// `max_chains` index version chains, resuming strictly after the
7180    /// `(index id, key)` the previous pass stopped at (`gc_index_cursor`) and
7181    /// wrapping to the start when the nested maps are exhausted. Lazy mode: it
7182    /// never removes empty slots (no blocking lock), exactly like the table
7183    /// sweep. Returns the number of versions reclaimed.
7184    ///
7185    /// `index_rows` is nested (`MVTableId -> key -> chain`), so the cursor is a
7186    /// `(MVTableId, key)` pair: the outer scan resumes at the saved index id
7187    /// (inclusive, to finish its remaining keys) and the inner scan resumes
7188    /// after the saved key; later indexes start from their first key.
7189    fn gc_index_incremental(&self, lwm: u64, ckpt_max: u64, max_chains: usize) -> usize {
7190        let passive = self.experimental_mvcc_passive_checkpoint;
7191        let mut dropped = 0;
7192        let mut processed = 0;
7193        let mut last: Option<(MVTableId, Arc<SortableIndexKey>)> = None;
7194        // Bound by the backfill boundary: never reclaim a version materialized in un-backfilled
7195        // WAL frames — a db-file reader (present or future) needs the version-store copy.
7196        let min_reader_mark = self
7197            .compute_min_reader_mark()
7198            .min(*self.backfill_floor.read());
7199
7200        let cursor = self.gc_index_cursor.lock().clone();
7201        let outer_start = match &cursor {
7202            Some((index_id, _)) => Bound::Included(*index_id),
7203            None => Bound::Unbounded,
7204        };
7205        'outer: for outer in self.index_rows.range((outer_start, Bound::Unbounded)) {
7206            if processed >= max_chains {
7207                break;
7208            }
7209            let index_id = *outer.key();
7210            // GC floor: retain a freshly-materialized index not yet visible to all readers.
7211            if self.rootpage_gc_protected(&index_id, min_reader_mark) {
7212                continue;
7213            }
7214            let inner = outer.value();
7215            // Resume after the saved key only within the index that the cursor
7216            // pointed into; every later index starts from its first key.
7217            let inner_start = match &cursor {
7218                Some((cursor_id, key)) if *cursor_id == index_id => Bound::Excluded(key.clone()),
7219                _ => Bound::Unbounded,
7220            };
7221            for inner_entry in inner.range((inner_start, Bound::Unbounded)) {
7222                if processed >= max_chains {
7223                    break 'outer;
7224                }
7225                if passive {
7226                    self.clock.get_timestamp(|_| {
7227                        let lwm = self.compute_lwm();
7228                        let mut versions = inner_entry.value().write();
7229                        dropped += Self::gc_version_chain(
7230                            &mut versions,
7231                            lwm,
7232                            ckpt_max,
7233                            true,
7234                            min_reader_mark,
7235                        );
7236                    });
7237                } else {
7238                    let mut versions = inner_entry.value().write();
7239                    dropped += Self::gc_version_chain(
7240                        &mut versions,
7241                        lwm,
7242                        ckpt_max,
7243                        false,
7244                        min_reader_mark,
7245                    );
7246                }
7247                last = Some((index_id, inner_entry.key().clone()));
7248                processed += 1;
7249            }
7250        }
7251
7252        // Fewer chains than the budget => nested maps exhausted; wrap to start.
7253        let wrapped = processed < max_chains;
7254        *self.gc_index_cursor.lock() = if wrapped { None } else { last };
7255        dropped
7256    }
7257
7258    fn drop_unused_row_versions_inner(&self, remove_empty_slots: bool) -> usize {
7259        let lwm = self.compute_lwm();
7260        let ckpt_max = self.durable_txid_max.load(Ordering::SeqCst);
7261        let mut referenced_tx_ids = HashSet::default();
7262
7263        let dropped =
7264            self.gc_table_row_versions(lwm, ckpt_max, &mut referenced_tx_ids, remove_empty_slots)
7265                + self.gc_index_row_versions(
7266                    lwm,
7267                    ckpt_max,
7268                    &mut referenced_tx_ids,
7269                    remove_empty_slots,
7270                );
7271        self.dec_live_version_count_approx(dropped);
7272        let pruned_finalized = self.prune_finalized_tx_states(&referenced_tx_ids);
7273
7274        tracing::trace!(
7275            "drop_unused_row_versions() -> dropped {dropped}, pruned_finalized={pruned_finalized}, txs: {}, finalized_tx_states: {}, rows: {}",
7276            self.txs.len(),
7277            self.finalized_tx_states.len(),
7278            self.rows.len()
7279        );
7280        dropped
7281    }
7282
7283    fn gc_table_row_versions(
7284        &self,
7285        lwm: u64,
7286        ckpt_max: u64,
7287        referenced_tx_ids: &mut HashSet<TxID>,
7288        remove_empty_slots: bool,
7289    ) -> usize {
7290        let mut dropped = 0;
7291        // Bound by the backfill boundary: never reclaim a version materialized in un-backfilled
7292        // WAL frames — a db-file reader (present or future) needs the version-store copy.
7293        let min_reader_mark = self
7294            .compute_min_reader_mark()
7295            .min(*self.backfill_floor.read());
7296
7297        for entry in self.rows.iter() {
7298            // GC floor: retain rows of a freshly-materialized btree not yet visible to all readers.
7299            if self.rootpage_gc_protected(&entry.key().table_id, min_reader_mark) {
7300                continue;
7301            }
7302            let is_now_empty = {
7303                let mut versions = entry.value().write();
7304                dropped += Self::gc_version_chain(
7305                    &mut versions,
7306                    lwm,
7307                    ckpt_max,
7308                    self.experimental_mvcc_passive_checkpoint,
7309                    min_reader_mark,
7310                );
7311                Self::collect_referenced_txids(&versions, referenced_tx_ids);
7312                versions.is_empty()
7313            };
7314            // Unless the caller holds the blocking checkpoint lock
7315            // (`remove_empty_slots`), empty entries are left in the SkipMap
7316            // (lazy removal). This avoids a TOCTOU race where a concurrent
7317            // writer inserts a version between the emptiness check and
7318            // SkipMap::remove(). Empty entries are reused by
7319            // get_or_insert_with on subsequent inserts and cleaned up by
7320            // checkpoint-time GC which runs under the blocking lock.
7321            if remove_empty_slots && is_now_empty {
7322                entry.remove();
7323            }
7324        }
7325        dropped
7326    }
7327
7328    fn gc_index_row_versions(
7329        &self,
7330        lwm: u64,
7331        ckpt_max: u64,
7332        referenced_tx_ids: &mut HashSet<TxID>,
7333        remove_empty_slots: bool,
7334    ) -> usize {
7335        let mut dropped = 0;
7336        // Bound by the backfill boundary: never reclaim a version materialized in un-backfilled
7337        // WAL frames — a db-file reader (present or future) needs the version-store copy.
7338        let min_reader_mark = self
7339            .compute_min_reader_mark()
7340            .min(*self.backfill_floor.read());
7341
7342        for outer_entry in self.index_rows.iter() {
7343            // GC floor: retain a freshly-materialized index not yet visible to all readers.
7344            if self.rootpage_gc_protected(outer_entry.key(), min_reader_mark) {
7345                continue;
7346            }
7347            let inner_map = outer_entry.value();
7348
7349            for inner_entry in inner_map.iter() {
7350                let is_now_empty = {
7351                    let mut versions = inner_entry.value().write();
7352                    dropped += Self::gc_version_chain(
7353                        &mut versions,
7354                        lwm,
7355                        ckpt_max,
7356                        self.experimental_mvcc_passive_checkpoint,
7357                        min_reader_mark,
7358                    );
7359                    Self::collect_referenced_txids(&versions, referenced_tx_ids);
7360                    versions.is_empty()
7361                };
7362                // Same TOCTOU rationale as table rows. The outer per-index map
7363                // is kept even when emptied — it is bounded by index count.
7364                if remove_empty_slots && is_now_empty {
7365                    inner_entry.remove();
7366                }
7367            }
7368        }
7369        dropped
7370    }
7371
7372    fn collect_referenced_txids(versions: &[RowVersion], referenced_tx_ids: &mut HashSet<TxID>) {
7373        for version in versions {
7374            if let Some(TxTimestampOrID::TxID(tx_id)) = version.begin() {
7375                referenced_tx_ids.insert(tx_id);
7376            }
7377            if let Some(TxTimestampOrID::TxID(tx_id)) = version.end() {
7378                referenced_tx_ids.insert(tx_id);
7379            }
7380        }
7381    }
7382
7383    fn prune_finalized_tx_states(&self, referenced_tx_ids: &HashSet<TxID>) -> usize {
7384        if self.finalized_tx_states.is_empty() {
7385            return 0;
7386        }
7387
7388        let to_remove: Vec<TxID> = self
7389            .finalized_tx_states
7390            .iter()
7391            .filter_map(|entry| {
7392                let tx_id = *entry.key();
7393                (!referenced_tx_ids.contains(&tx_id)).then_some(tx_id)
7394            })
7395            .collect();
7396
7397        for tx_id in &to_remove {
7398            self.finalized_tx_states.remove(tx_id);
7399        }
7400
7401        to_remove.len()
7402    }
7403
7404    /// Apply GC rules to a single version chain. Returns the number removed.
7405    ///
7406    /// Rule 1: aborted garbage (begin=None, end=None) — always remove.
7407    /// Rule 2: superseded (end=Timestamp(e)) — remove once no reader can see it.
7408    /// Rule 3: checkpointed sole-survivor (end=None) — remove.
7409    ///
7410    /// Passive gates Rules 2/3 on `materialized_at` + `min_reader_mark`: reclaim only once the
7411    /// version is in the B-tree AND every reader's mark has reached that frame, so a reader
7412    /// pinned at an older frame never loses a version it can still see. The blocking path is
7413    /// stop-the-world and uses the logical `ckpt_max` proxy instead.
7414    fn gc_version_chain(
7415        versions: &mut RowVersionChain<A>,
7416        lwm: u64,
7417        ckpt_max: u64,
7418        passive: bool,
7419        min_reader_mark: WalPos,
7420    ) -> usize {
7421        let before = versions.len();
7422
7423        // Rule 1: aborted garbage
7424        versions.retain(|rv| !matches!((&rv.begin(), &rv.end()), (None, None)));
7425
7426        let has_current = versions.iter().any(|rv| {
7427            matches!(rv.begin(), Some(TxTimestampOrID::Timestamp(_))) && rv.end().is_none()
7428        });
7429
7430        // A version's current state is reclaimable iff it is materialized in the B-tree and no
7431        // active reader is pinned below that materialization frame.
7432        let materialized_for_readers = |rv: &RowVersion| {
7433            rv.materialized_at() != WalPos::ORIGIN && min_reader_mark >= rv.materialized_at()
7434        };
7435
7436        // Rule 2: superseded version (end=Timestamp(e)) no reader can see (e <= lwm).
7437        versions.retain(|rv| match &rv.end() {
7438            Some(TxTimestampOrID::Timestamp(e)) if *e <= lwm => {
7439                if passive {
7440                    // Keep until this delete is in the B-tree and reachable by every reader.
7441                    !materialized_for_readers(rv)
7442                } else {
7443                    // Retain superseded versions until checkpoint makes the physical change
7444                    // durable. btree_resident markers and tombstones without a committed
7445                    // current successor must survive even when a newer current exists.
7446                    *e > ckpt_max && (rv.btree_resident || !has_current)
7447                }
7448            }
7449            _ => true,
7450        });
7451
7452        // Rule 3: checkpointed sole-survivor current version (end=None).
7453        if versions.len() == 1 {
7454            if let (Some(TxTimestampOrID::Timestamp(b)), None) =
7455                (&versions[0].begin(), &versions[0].end())
7456            {
7457                let removable = if passive {
7458                    materialized_for_readers(&versions[0]) && *b < lwm
7459                } else {
7460                    *b <= ckpt_max && *b < lwm
7461                };
7462                if removable {
7463                    versions.clear();
7464                }
7465            }
7466        }
7467
7468        Self::shrink_version_chain_allocation(versions);
7469
7470        before - versions.len()
7471    }
7472
7473    /// Stamp each version this checkpoint materialized with the WAL `frame` it became durable at.
7474    /// A version's current state is in the B-tree iff its terminal event (delete `end`, else
7475    /// insert `begin`) committed at or before `snapshot_ts`. `gc_version_chain` then reclaims it
7476    /// only once every reader's mark reaches `frame`.
7477    fn stamp_chain_materialized(
7478        &self,
7479        versions: &mut [RowVersion],
7480        frame: WalPos,
7481        snapshot_ts: u64,
7482    ) {
7483        let resolve = |t: Option<TxTimestampOrID>| -> Option<u64> {
7484            match t {
7485                Some(TxTimestampOrID::Timestamp(ts)) => Some(ts),
7486                Some(TxTimestampOrID::TxID(id)) => {
7487                    match lookup_tx_state(&self.txs, &self.finalized_tx_states, id) {
7488                        Some(TransactionState::Committed(ts)) => Some(ts),
7489                        _ => None,
7490                    }
7491                }
7492                None => None,
7493            }
7494        };
7495        for v in versions.iter_mut() {
7496            let terminal = if v.end().is_some() {
7497                resolve(v.end())
7498            } else {
7499                resolve(v.begin())
7500            };
7501            if terminal.is_some_and(|t| t <= snapshot_ts) {
7502                v.set_materialized_at(frame);
7503            }
7504        }
7505    }
7506
7507    /// Chains with capacity at or below this are never shrunk — the
7508    /// allocation is too small to be worth a realloc.
7509    const CHAIN_SHRINK_MIN_CAPACITY: usize = 16;
7510
7511    /// Release excess version-chain capacity after GC trimmed the chain.
7512    /// `retain`/`clear` keep the Vec's allocation, so a one-off burst of
7513    /// versions (e.g. a hot row between checkpoints) would otherwise pin its
7514    /// peak allocation forever. Capacity drops to a quarter of its current
7515    /// value — deliberately not to fit — when the survivors occupy less than
7516    /// a quarter of it, so steady-state chains keep slack for new versions.
7517    fn shrink_version_chain_allocation(versions: &mut RowVersionChain<A>) {
7518        let capacity = versions.capacity();
7519        if capacity > Self::CHAIN_SHRINK_MIN_CAPACITY && versions.len() < capacity / 4 {
7520            versions.shrink_to(capacity / 4);
7521        }
7522    }
7523
7524    // Extracts the begin timestamp from a transaction
7525    #[inline]
7526    fn resolve_begin_timestamp(&self, ts_or_id: &Option<TxTimestampOrID>) -> u64 {
7527        match ts_or_id {
7528            Some(TxTimestampOrID::Timestamp(ts)) => *ts,
7529            Some(TxTimestampOrID::TxID(tx_id)) => {
7530                self.txs
7531                    .get(tx_id)
7532                    .expect("transaction should exist in txs map")
7533                    .value()
7534                    .begin_ts
7535            }
7536            // This function is intended to be used in the ordering of row versions within the row version chain in `insert_version_raw`.
7537            //
7538            // The row version chain should be append-only (aside from garbage collection),
7539            // so the specific ordering handled by this function may not be critical. We might
7540            // be able to append directly to the row version chain in the future.
7541            //
7542            // The value 0 is used here to represent an infinite timestamp value. This is a deliberate
7543            // choice for a planned future bitpacking optimization, reserving 0 for this purpose,
7544            // while actual timestamps will start from 1.
7545            None => 0,
7546        }
7547    }
7548
7549    /// Inserts a new row version into the database, while making sure that the row version
7550    /// is inserted in the correct order. Returns a reference to the modified version chain.
7551    fn insert_version(
7552        &self,
7553        id: RowID,
7554        row_version: RowVersion,
7555    ) -> Result<RowVersions<A>, TryReserveError> {
7556        let row_versions = self.get_or_create_table_row_versions(id)?;
7557        self.insert_version_raw(&mut row_versions.write(), row_version)?;
7558        Ok(row_versions)
7559    }
7560
7561    #[turso_macros::allocation_site(crate::alloc::MvStoreAllocationSite::TableRowsEntry)]
7562    fn get_or_create_table_row_versions(
7563        &self,
7564        id: RowID,
7565    ) -> Result<RowVersions<A>, TryReserveError> {
7566        let alloc = self.alloc.clone();
7567        let versions = self.rows.try_get_or_insert_with(id, move || {
7568            Arc::new(RwLock::new(<RowVersionChain<A> as TursoVecInExt<
7569                RowVersion,
7570                A,
7571            >>::new_in(alloc)))
7572        })?;
7573        Ok(versions.value().clone())
7574    }
7575
7576    /// Gets an existing Arc<SortableIndexKey> from the index if the key exists,
7577    /// otherwise creates a new Arc. This ensures we reuse Arc instances for the same key.
7578    fn get_or_create_index_key_arc(
7579        &self,
7580        index_id: MVTableId,
7581        key: Arc<SortableIndexKey>,
7582    ) -> Result<Arc<SortableIndexKey>> {
7583        let index = self.get_or_create_index_rows(index_id)?;
7584        let index = index.value();
7585        let existing = index.get(&*key).map(|entry| entry.key().clone());
7586        Ok(existing.unwrap_or(key))
7587    }
7588
7589    /// Inserts (or appends to) the version chain for an index entry and returns
7590    /// the id and versions of the modified row.
7591    pub fn insert_index_version(
7592        &self,
7593        index_id: MVTableId,
7594        key: Arc<SortableIndexKey>,
7595        mut row_version: RowVersion,
7596    ) -> Result<(Arc<SortableIndexKey>, RowVersions<A>)> {
7597        // Publish the key-set mutation *before* the key becomes visible in the
7598        // map: a concurrent shadow finger that races with this insert may then
7599        // reset spuriously, but can never miss the new key (#7578).
7600        self.index_rows_epoch.fetch_add(1, Ordering::SeqCst);
7601        let index = self.get_or_create_index_rows(index_id)?;
7602        let index = index.value();
7603        let entry = self.get_or_create_index_key_entry(index, key)?;
7604        // The Arc that's actually stored in the SkipMap may be the one we
7605        // passed in (on miss) or a pre-existing one (on hit). Return that
7606        // canonical Arc so savepoint tracking and the SkipMap stay in sync.
7607        let canonical_key = entry.key().clone();
7608        row_version.row.id.row_id = RowKey::Record(canonical_key.clone());
7609        let row_versions = entry.value().clone();
7610        self.insert_version_raw(&mut row_versions.write(), row_version)?;
7611        Ok((canonical_key, row_versions))
7612    }
7613
7614    /// Current epoch of `index_rows` key-set mutations; see the field docs.
7615    pub(crate) fn index_rows_epoch(&self) -> u64 {
7616        self.index_rows_epoch.load(Ordering::SeqCst)
7617    }
7618
7619    #[turso_macros::allocation_site(crate::alloc::MvStoreAllocationSite::IndexRowsEntry)]
7620    pub(crate) fn get_or_create_index_rows(
7621        &self,
7622        index_id: MVTableId,
7623    ) -> Result<IndexRowsEntry<'_, A>, TryReserveError> {
7624        let alloc = self.alloc.clone();
7625        let index = self
7626            .index_rows
7627            .try_get_or_insert_with(index_id, move || SkipMap::new_in(alloc))?;
7628        Ok(index)
7629    }
7630
7631    #[turso_macros::allocation_site(crate::alloc::MvStoreAllocationSite::IndexKeyEntry)]
7632    fn get_or_create_index_key_entry<'a>(
7633        &self,
7634        index: &'a IndexRowsMap<A>,
7635        key: Arc<SortableIndexKey>,
7636    ) -> Result<IndexRowEntry<'a, A>, TryReserveError> {
7637        let alloc = self.alloc.clone();
7638        let entry = index.try_get_or_insert_with(key, move || {
7639            Arc::new(RwLock::new(<RowVersionChain<A> as TursoVecInExt<
7640                RowVersion,
7641                A,
7642            >>::new_in(alloc)))
7643        })?;
7644        Ok(entry)
7645    }
7646
7647    /// Inserts a new row version into the internal data structure for versions,
7648    /// while making sure that the row version is inserted in the correct order.
7649    #[turso_macros::allocation_site(crate::alloc::MvStoreAllocationSite::RowVersionReserve)]
7650    pub fn insert_version_raw(
7651        &self,
7652        versions: &mut RowVersionChain<A>,
7653        row_version: RowVersion,
7654    ) -> Result<(), TryReserveError> {
7655        // NOTICE: this is an insert a'la insertion sort, with pessimistic linear complexity.
7656        // However, we expect the number of versions to be nearly sorted, so we deem it worthy
7657        // to search linearly for the insertion point instead of paying the price of using
7658        // another data structure, e.g. a BTreeSet. If it proves to be too quadratic empirically,
7659        // we can either switch to a tree-like structure, or at least use partition_point()
7660        // which performs a binary search for the insertion point.
7661        versions.try_reserve(1)?;
7662        let mut position = 0_usize;
7663        for (i, v) in versions.iter().enumerate().rev() {
7664            let existing_begin = self.resolve_begin_timestamp(&v.begin());
7665            let new_begin = self.resolve_begin_timestamp(&row_version.begin());
7666            if existing_begin <= new_begin {
7667                // Recovery can replay multiple operations for the same row from one transaction
7668                // (e.g. insert then delete), which share the same begin timestamp.
7669                // Keep only the latest version for that begin timestamp so visibility checks don't
7670                // surface a stale intermediate version.
7671                // Only collapse duplicate "begin" values when both are concrete begins.
7672                // `begin=None` is used for committed tombstones over B-tree-resident rows and
7673                // must never be conflated with a later statement's transient tombstone.
7674                if versions[i].row.id == row_version.row.id
7675                    && matches!(
7676                        (&versions[i].begin(), &row_version.begin()),
7677                        (
7678                            Some(TxTimestampOrID::Timestamp(existing)),
7679                            Some(TxTimestampOrID::Timestamp(new))
7680                        ) if existing == new
7681                    )
7682                {
7683                    versions[i] = row_version;
7684                    return Ok(());
7685                }
7686                position = i + 1;
7687                break;
7688            }
7689        }
7690        // Memory pre allocated already
7691        versions.insert(position, row_version);
7692        // A genuine insert (the collapse branch above `return`s without
7693        // reaching here). Track it for the GC trigger heuristic.
7694        self.live_version_count_approx
7695            .fetch_add(1, Ordering::Relaxed);
7696        Ok(())
7697    }
7698
7699    pub fn write_row_to_pager(
7700        &self,
7701        row: &Row,
7702        cursor: Arc<RwLock<BTreeCursor>>,
7703        requires_seek: bool,
7704    ) -> Result<StateMachine<WriteRowStateMachine>> {
7705        let state_machine: StateMachine<WriteRowStateMachine> =
7706            StateMachine::<WriteRowStateMachine>::new(WriteRowStateMachine::new(
7707                row.clone(),
7708                cursor,
7709                requires_seek,
7710            ));
7711
7712        Ok(state_machine)
7713    }
7714
7715    pub fn delete_row_from_pager(
7716        &self,
7717        rowid: RowID,
7718        cursor: Arc<RwLock<BTreeCursor>>,
7719    ) -> Result<StateMachine<DeleteRowStateMachine>> {
7720        let state_machine: StateMachine<DeleteRowStateMachine> =
7721            StateMachine::<DeleteRowStateMachine>::new(DeleteRowStateMachine::new(rowid, cursor));
7722
7723        Ok(state_machine)
7724    }
7725
7726    /// Clear every version-chain entry for `rowid`. Used by checkpoint-time
7727    /// compaction that deletes the corresponding B-tree row outside the
7728    /// normal MVCC delete path (e.g. `SeqCompactDriver` for sequence
7729    /// backing tables) so the two layers stay in sync — without this the
7730    /// version chain would keep `RowVersion { begin: Timestamp(T), end:
7731    /// None, btree_resident: true }` entries pointing at B-tree rows that
7732    /// no longer exist, until `drop_unused_row_versions` Rule 3 caught up.
7733    ///
7734    /// **Caller contract** — the caller must hold a guarantee that no
7735    /// concurrent reader can observe mid-purge state. Today the only
7736    /// caller is `SeqCompactDriver`, which runs inside the checkpoint
7737    /// while the `pager_commit_lock` is held; nextval allocators serialize
7738    /// through that same lock, so they cannot see the chain in a partially
7739    /// purged state. Callers without that guarantee must add proper
7740    /// tombstones via the normal write path instead.
7741    ///
7742    /// Empty chain slots are left in the `SkipMap` (lazy removal). The
7743    /// same TOCTOU rationale as `gc_table_row_versions` applies: removing
7744    /// the slot would race a concurrent `get_or_insert_with` from a future
7745    /// write to the same key.
7746    pub fn purge_row_versions_during_checkpoint(&self, rowid: RowID) {
7747        if let Some(entry) = self.rows.get(&rowid) {
7748            let mut versions = entry.value().write();
7749            self.dec_live_version_count_approx(versions.len());
7750            versions.clear();
7751            Self::shrink_version_chain_allocation(&mut versions);
7752        }
7753    }
7754
7755    /// Passive sequence compaction: record end-stamped deletes instead of inline B-tree purge.
7756    pub fn seqcompact_commit_delete(&self, rowid: RowID, num_cols: usize, end_ts: u64) {
7757        let Ok(row_versions) = self.get_or_create_table_row_versions(rowid.clone()) else {
7758            return;
7759        };
7760        let mut versions = row_versions.write();
7761        // If a committed current version exists, mark it deleted as of end_ts — the normal
7762        // collection then materializes the B-tree delete (begin <= durable_max => exists_in_db_file).
7763        if let Some(rv) = versions.iter_mut().find(|rv| {
7764            matches!(rv.begin(), Some(TxTimestampOrID::Timestamp(_))) && rv.end().is_none()
7765        }) {
7766            rv.set_end(Some(TxTimestampOrID::Timestamp(end_ts)));
7767            return;
7768        }
7769        // Already tombstoned / no live version: nothing to delete again.
7770        if versions.iter().any(|rv| rv.end().is_some()) {
7771            return;
7772        }
7773        // Row lives only in the B-tree: record a B-tree-resident tombstone so the collection
7774        // (btree_resident => exists_in_db_file) materializes the physical delete.
7775        let version_id = self.get_version_id();
7776        let row = Row::new_table_row(rowid, &[], num_cols).expect("empty tombstone row");
7777        let _ = self.insert_version_raw(
7778            &mut versions,
7779            RowVersion {
7780                id: version_id,
7781                begin: PackedTs::pack(None),
7782                end: PackedTs::pack(Some(TxTimestampOrID::Timestamp(end_ts))),
7783                row,
7784                btree_resident: true,
7785                materialized_at: WalPos::ORIGIN,
7786            },
7787        );
7788    }
7789
7790    pub fn get_last_table_rowid(
7791        &self,
7792        table_id: MVTableId,
7793        table_iterator: &mut Option<MvccIterator<'static, RowID, A>>,
7794        tx_id: TxID,
7795    ) -> Option<RowKey> {
7796        let tx = self
7797            .txs
7798            .get(&tx_id)
7799            .expect("transaction should exist in txs map");
7800        let tx = tx.value();
7801        let max_rowid = RowID {
7802            table_id,
7803            row_id: RowKey::Int(i64::MAX),
7804        };
7805        let range = create_seek_range(Bound::Included(max_rowid), IterationDirection::Backwards);
7806        let iter_box = Box::new(self.rows.range(range).rev());
7807        *table_iterator = Some(static_iterator_hack!(iter_box, RowID, A));
7808        let iter = table_iterator
7809            .as_mut()
7810            .expect("table_iterator was assigned above");
7811        loop {
7812            let entry = iter.next()?;
7813            // Rowid is not part of the table, therefore we already reached the end of the table.
7814            // NOTE: Shouldn't range already prevent this?
7815            tracing::trace!(
7816                "get_last_table_rowid: entry.key().table_id={}, table_id={}, row_id={}",
7817                entry.key().table_id,
7818                table_id,
7819                entry.key().row_id
7820            );
7821            if entry.key().table_id != table_id {
7822                tracing::trace!("get_last_table_rowid: reached end of table");
7823                return None;
7824            }
7825            if let Some(_visible_row) = self.find_last_visible_version(tx, &entry) {
7826                tracing::trace!(
7827                    "get_last_table_rowid: found visible row: {:?}",
7828                    _visible_row
7829                );
7830                // There is a visible version for this rowid, so we return it
7831                return Some(RowKey::Int(match &entry.key().row_id {
7832                    RowKey::Int(i) => *i,
7833                    _ => panic!("Expected RowKey::Int for table rowid"),
7834                }));
7835            }
7836        }
7837    }
7838
7839    pub fn get_last_table_rowid_without_visibility_check(
7840        &self,
7841        table_id: MVTableId,
7842    ) -> Option<RowKey> {
7843        let max_rowid = RowID {
7844            table_id,
7845            row_id: RowKey::Int(i64::MAX),
7846        };
7847        let range = create_seek_range(Bound::Included(max_rowid), IterationDirection::Backwards);
7848        let mut range = self.rows.range(range).rev();
7849        let entry = range.next()?;
7850        if entry.key().table_id != table_id {
7851            return None;
7852        }
7853        Some(entry.key().row_id.clone())
7854    }
7855
7856    pub fn get_last_index_rowid(
7857        &self,
7858        index_id: MVTableId,
7859        tx_id: TxID,
7860        index_iterator: &mut Option<MvccIterator<'static, Arc<SortableIndexKey>, A>>,
7861    ) -> Result<Option<RowKey>> {
7862        let index = self.get_or_create_index_rows(index_id)?;
7863        let index = index.value();
7864        let iter_box = Box::new(index.iter().rev());
7865        *index_iterator = Some(static_iterator_hack!(iter_box, Arc<SortableIndexKey>, A));
7866        let iter = index_iterator
7867            .as_mut()
7868            .expect("index_iterator was assigned above");
7869        let tx = self
7870            .txs
7871            .get(&tx_id)
7872            .expect("transaction should exist in txs map");
7873        let tx = tx.value();
7874        Ok(self
7875            .find_next_visible_index_row(tx, iter)
7876            .map(|row| row.row_id))
7877    }
7878
7879    pub fn get_logical_log_file(&self) -> Arc<dyn File> {
7880        self.storage.get_logical_log_file()
7881    }
7882
7883    pub fn logical_log_offset(&self) -> u64 {
7884        self.storage.logical_log_offset()
7885    }
7886
7887    /// Replace the logical log with a fresh valid header after the database
7888    /// file was restored outside MVCC.
7889    ///
7890    /// The returned completion must finish before reopening/recovering MVCC
7891    /// state. Otherwise recovery could replay stale local logical-log frames on
7892    /// top of the restored database image.
7893    pub fn reset_logical_log_after_external_restore(&self) -> Result<Completion> {
7894        self.storage.reset_to_fresh_header()
7895    }
7896
7897    /// Return the durable sync completion for the freshly reset logical log.
7898    ///
7899    /// This is separate from `reset_logical_log_after_external_restore` so
7900    /// callers can drive the reset completion cooperatively, then issue the
7901    /// ordered sync only after the header/truncate group has completed.
7902    pub fn sync_logical_log_after_external_restore(
7903        &self,
7904        connection: &Arc<Connection>,
7905    ) -> Result<Option<Completion>> {
7906        if connection.get_sync_mode() != SyncMode::Off {
7907            let pager = connection.pager.load().clone();
7908            return Ok(Some(self.storage.sync(pager.get_sync_type())?));
7909        }
7910        Ok(None)
7911    }
7912
7913    /// Reconcile WAL state left by a prior crash or incomplete checkpoint.
7914    /// Classifies startup state by WAL frame count and logical-log header
7915    /// validity, then either completes the interrupted checkpoint
7916    /// (backfill WAL → DB, sync, truncate) or fails closed on corrupt /
7917    /// inconsistent artifacts. Non-blocking: all IO yields through the
7918    /// supplied [`CompleteCheckpointState`].
7919    /// See RECOVERY_SEMANTICS.md "Startup Case Classification" for the full case table.
7920    pub(crate) fn maybe_complete_interrupted_checkpoint_nonblock(
7921        &self,
7922        connection: &Arc<Connection>,
7923        st: &mut CompleteCheckpointState,
7924    ) -> Result<IOResult<()>> {
7925        let pager = connection.pager.load().clone();
7926        let Some(wal) = &pager.wal else {
7927            return Ok(IOResult::Done(()));
7928        };
7929        loop {
7930            match st {
7931                CompleteCheckpointState::Start => {
7932                    // The bootstrap connection may have acquired a WAL read lock during
7933                    // earlier bootstrap steps (e.g. schema parsing). Drop it so the
7934                    // TRUNCATE checkpoint below isn't blocked by our own read lock.
7935                    // Idempotent across re-entry (holds_read_lock is checked first).
7936                    if wal.holds_read_lock() {
7937                        wal.end_read_tx();
7938                    }
7939                    let file = self.get_logical_log_file();
7940                    // Header is never encrypted; no EncryptionContext needed.
7941                    *st = CompleteCheckpointState::ReadingHeader {
7942                        reader: Box::new(StreamingLogicalLogReader::new(file, None)),
7943                    };
7944                }
7945                CompleteCheckpointState::ReadingHeader { reader } => {
7946                    let header_result = return_if_io!(reader.try_read_header_nonblock());
7947                    let wal_max_frame = wal.get_max_frame_in_wal();
7948                    let is_readonly = connection.db.is_readonly();
7949                    if wal_max_frame == 0 {
7950                        if is_readonly {
7951                            // Nothing to reconcile in read-only mode with no committed frames.
7952                            *st = CompleteCheckpointState::Start;
7953                            return Ok(IOResult::Done(()));
7954                        }
7955                        *st = CompleteCheckpointState::DriveEarlyTruncate {
7956                            header_result,
7957                            checkpoint_result: CheckpointResult::new(0, 0, 0),
7958                        };
7959                        continue;
7960                    }
7961                    if is_readonly {
7962                        return Err(LimboError::Corrupt(
7963                            "Cannot reconcile interrupted MVCC checkpoint in read-only mode"
7964                                .to_string(),
7965                        ));
7966                    }
7967                    match header_result {
7968                        HeaderReadResult::Valid(header) => {
7969                            // Interrupted checkpoint with the logical log still
7970                            // present: reuse its header so the fresh-header write in
7971                            // RetryHeader keeps the existing salt chain.
7972                            self.storage.set_header(header);
7973                        }
7974                        HeaderReadResult::NoLog => {
7975                            if !connection.experimental_mvcc_passive_checkpoint_enabled() {
7976                                return Err(LimboError::Corrupt(
7977                                    "WAL has committed frames but logical log header is missing"
7978                                        .to_string(),
7979                                ));
7980                            }
7981                        }
7982                        HeaderReadResult::Invalid => {
7983                            // Present but undecodable: a torn header write / genuine
7984                            // corruption, not a clean truncation — fail closed.
7985                            return Err(LimboError::Corrupt(
7986                                "WAL has committed frames but logical log header is invalid"
7987                                    .to_string(),
7988                            ));
7989                        }
7990                    }
7991                    // Enter the checkpoint lifecycle before any WAL→DB backfill so
7992                    // that `DurableStorage` implementations observing the
7993                    // start/end pairing (e.g. the diskless server, which arms its
7994                    // next-generation metadata here) see a checkpoint in progress
7995                    // when `wal.checkpoint` writes pages into the DB file. Called
7996                    // exactly once at this transition (never on `DriveCheckpoint`
7997                    // re-entry) since `on_checkpoint_start` is not idempotent.
7998                    self.storage.on_checkpoint_start()?;
7999                    *st = CompleteCheckpointState::DriveCheckpoint;
8000                }
8001                CompleteCheckpointState::DriveEarlyTruncate {
8002                    header_result,
8003                    checkpoint_result,
8004                } => {
8005                    return_if_io!(wal.truncate_wal(checkpoint_result, pager.get_sync_type()));
8006                    if let HeaderReadResult::Valid(header) = header_result {
8007                        self.storage.set_header(header.clone());
8008                    }
8009                    *st = CompleteCheckpointState::Start;
8010                    return Ok(IOResult::Done(()));
8011                }
8012                CompleteCheckpointState::DriveCheckpoint => {
8013                    // NOTE: uses `CheckpointMode::Truncate` to drive WAL backfill
8014                    // only; we still truncate the WAL explicitly below to preserve
8015                    // WAL-last ordering in recovery.
8016                    let checkpoint_result = return_if_io!(wal.checkpoint(
8017                        &pager,
8018                        CheckpointMode::Truncate {
8019                            upper_bound_inclusive: None,
8020                        },
8021                    ));
8022                    if !checkpoint_result.everything_backfilled() {
8023                        let err = LimboError::Corrupt(
8024                            "Unable to fully backfill committed WAL frames during MVCC recovery"
8025                                .to_string(),
8026                        );
8027                        // Close out the lifecycle opened in `ReadingHeader` so the
8028                        // start/end pairing stays balanced on this error path.
8029                        self.storage.on_checkpoint_end(Err(err.clone()))?;
8030                        return Err(err);
8031                    }
8032                    let need_db_sync = connection.get_sync_mode() != SyncMode::Off
8033                        && checkpoint_result.wal_checkpoint_backfilled > 0;
8034                    if need_db_sync {
8035                        let c = match pager
8036                            .db_file
8037                            .sync(Completion::new_sync(|_| {}), pager.get_sync_type())
8038                        {
8039                            Ok(c) => c,
8040                            Err(err) => {
8041                                self.storage.on_checkpoint_end(Err(err.clone()))?;
8042                                return Err(err);
8043                            }
8044                        };
8045                        *st = CompleteCheckpointState::AwaitDbFileSync {
8046                            completion: c,
8047                            checkpoint_result,
8048                        };
8049                    } else {
8050                        *st = CompleteCheckpointState::RetryHeader {
8051                            checkpoint_result,
8052                            retried_crc: false,
8053                            phase: RetryHeaderPhase::NeedUpdateHeader,
8054                        };
8055                    }
8056                }
8057                CompleteCheckpointState::AwaitDbFileSync {
8058                    completion,
8059                    checkpoint_result,
8060                } => {
8061                    if !completion.succeeded() {
8062                        let c = completion.clone();
8063                        io_yield_one!(c);
8064                    }
8065                    let checkpoint_result =
8066                        std::mem::replace(checkpoint_result, CheckpointResult::new(0, 0, 0));
8067                    *st = CompleteCheckpointState::RetryHeader {
8068                        checkpoint_result,
8069                        retried_crc: false,
8070                        phase: RetryHeaderPhase::NeedUpdateHeader,
8071                    };
8072                }
8073                CompleteCheckpointState::RetryHeader {
8074                    checkpoint_result,
8075                    retried_crc,
8076                    phase,
8077                } => match phase {
8078                    RetryHeaderPhase::NeedUpdateHeader => {
8079                        let c = match self.storage.update_header() {
8080                            Ok(c) => c,
8081                            Err(err) => {
8082                                self.storage.on_checkpoint_end(Err(err.clone()))?;
8083                                return Err(err);
8084                            }
8085                        };
8086                        *phase = RetryHeaderPhase::AwaitUpdateHeader(c);
8087                    }
8088                    RetryHeaderPhase::AwaitUpdateHeader(completion) => {
8089                        if !completion.succeeded() {
8090                            let c = completion.clone();
8091                            io_yield_one!(c);
8092                        }
8093                        if connection.get_sync_mode() != SyncMode::Off {
8094                            let c = match self.storage.sync(pager.get_sync_type()) {
8095                                Ok(c) => c,
8096                                Err(err) => {
8097                                    self.storage.on_checkpoint_end(Err(err.clone()))?;
8098                                    return Err(err);
8099                                }
8100                            };
8101                            *phase = RetryHeaderPhase::AwaitLogSync(c);
8102                        } else {
8103                            let file = self.get_logical_log_file();
8104                            *phase = RetryHeaderPhase::AwaitCrcCheck {
8105                                reader: Box::new(StreamingLogicalLogReader::new(file, None)),
8106                            };
8107                        }
8108                    }
8109                    RetryHeaderPhase::AwaitLogSync(completion) => {
8110                        if !completion.succeeded() {
8111                            let c = completion.clone();
8112                            io_yield_one!(c);
8113                        }
8114                        let file = self.get_logical_log_file();
8115                        *phase = RetryHeaderPhase::AwaitCrcCheck {
8116                            reader: Box::new(StreamingLogicalLogReader::new(file, None)),
8117                        };
8118                    }
8119                    RetryHeaderPhase::AwaitCrcCheck { reader } => {
8120                        let header_result = return_if_io!(reader.try_read_header_nonblock());
8121                        let crc_valid = matches!(header_result, HeaderReadResult::Valid(_));
8122                        if crc_valid {
8123                            let checkpoint_result = std::mem::replace(
8124                                checkpoint_result,
8125                                CheckpointResult::new(0, 0, 0),
8126                            );
8127                            *st = CompleteCheckpointState::DriveFinalTruncate { checkpoint_result };
8128                        } else {
8129                            if *retried_crc {
8130                                let err = LimboError::Corrupt(
8131                                    "Logical log header CRC mismatch after retry".to_string(),
8132                                );
8133                                self.storage.on_checkpoint_end(Err(err.clone()))?;
8134                                return Err(err);
8135                            }
8136                            *retried_crc = true;
8137                            *phase = RetryHeaderPhase::NeedUpdateHeader;
8138                        }
8139                    }
8140                },
8141                CompleteCheckpointState::DriveFinalTruncate { checkpoint_result } => {
8142                    match wal.truncate_wal(checkpoint_result, pager.get_sync_type()) {
8143                        Ok(IOResult::Done(())) => {
8144                            self.storage.on_checkpoint_end(Ok(checkpoint_result))?;
8145                        }
8146                        Ok(IOResult::IO(c)) => {
8147                            return Ok(IOResult::IO(c));
8148                        }
8149                        Err(err) => {
8150                            self.storage.on_checkpoint_end(Err(err.clone()))?;
8151                            return Err(err);
8152                        }
8153                    }
8154                    *st = CompleteCheckpointState::Start;
8155                    return Ok(IOResult::Done(()));
8156                }
8157            }
8158        }
8159    }
8160
8161    /// Rewrite placeholder (negative) root pages in `schema` to the real positive roots a
8162    /// checkpoint has since materialized. A negative root whose `table_id_to_rootpage` entry
8163    /// resolves to a positive page means the object's btree is durable; pointing the schema at
8164    /// it keeps consumers that ignore negative roots (e.g. `integrity_check`, which skips them)
8165    /// from treating the live page as orphaned. Objects not yet materialized stay negative.
8166    pub(crate) fn resolve_schema_negative_roots(&self, schema: &mut crate::schema::Schema) {
8167        let resolve = |root_page: i64| -> Option<i64> {
8168            if root_page >= 0 {
8169                return None;
8170            }
8171            self.table_id_to_rootpage
8172                .get(&MVTableId::from(root_page))
8173                .filter(|e| e.value().is_live())
8174                .and_then(|e| e.value().root_page)
8175                .map(|r| r as i64)
8176        };
8177        for table in schema.tables.values_mut() {
8178            let needs = table
8179                .btree()
8180                .is_some_and(|b| resolve(b.root_page).is_some());
8181            if !needs {
8182                continue;
8183            }
8184            let table = Arc::make_mut(table);
8185            if let Some(btree_table) = table.btree_mut() {
8186                let btree_table = Arc::make_mut(btree_table);
8187                if let Some(rp) = resolve(btree_table.root_page) {
8188                    btree_table.root_page = rp;
8189                }
8190            }
8191        }
8192        for table_index_list in schema.indexes.values_mut() {
8193            for index in table_index_list.iter_mut() {
8194                if let Some(rp) = resolve(index.root_page) {
8195                    Arc::make_mut(index).root_page = rp;
8196                }
8197            }
8198        }
8199    }
8200
8201    /// Resolve a single (possibly placeholder/negative) root page to the real positive page a
8202    /// checkpoint has materialized for it, or return it unchanged if not yet materialized.
8203    pub(crate) fn resolve_root_page(&self, root_page: i64) -> i64 {
8204        if root_page >= 0 {
8205            return root_page;
8206        }
8207        self.table_id_to_rootpage
8208            .get(&MVTableId::from(root_page))
8209            .filter(|e| e.value().is_live())
8210            .and_then(|e| e.value().root_page)
8211            .map(|r| r as i64)
8212            .unwrap_or(root_page)
8213    }
8214
8215    /// Build an `Arc<Schema>` from a set of sqlite_schema rows (keyed by rowid).
8216    /// Shared by recovery (replayed rows) and the checkpoint's snapshot-consistent
8217    /// `BuildLocalSchemaView` (btree + MVCC-delta merge at snapshot_ts).
8218    pub(crate) fn build_schema_from_rows(
8219        &self,
8220        connection: &Arc<Connection>,
8221        schema_rows: &HashMap<i64, ImmutableRecord>,
8222        preserved_table_valued_functions: &[Arc<crate::vtab::VirtualTable>],
8223    ) -> Result<Arc<Schema>> {
8224        let pager = connection.pager.load().clone();
8225        let cookie = self
8226            .global_header
8227            .read()
8228            .as_ref()
8229            .map(|header| header.schema_cookie.get())
8230            .unwrap_or(
8231                pager
8232                    .io
8233                    .block(|| pager.with_header(|header| header.schema_cookie))?
8234                    .get(),
8235            );
8236        let mut fresh = Schema::new();
8237        fresh.generated_columns_enabled = connection.db.experimental_generated_columns_enabled();
8238        fresh.schema_version = cookie;
8239        let mut from_sql_indexes = crate::alloc::vec![];
8240        let mut automatic_indices = HashMap::default();
8241        let mut dbsp_state_roots: HashMap<String, i64> = HashMap::default();
8242        let mut dbsp_state_index_roots: HashMap<String, i64> = HashMap::default();
8243        let mut materialized_view_info: HashMap<String, (String, i64)> = HashMap::default();
8244        let syms = connection.syms.read();
8245        let mv_store = connection.db.get_mv_store().clone();
8246
8247        let mut sorted_rowids: Vec<i64> = schema_rows.keys().copied().collect();
8248        sorted_rowids.sort_unstable();
8249        for rowid in &sorted_rowids {
8250            let record = &schema_rows[rowid];
8251            let ty = match record.get_value_opt(0) {
8252                Some(ValueRef::Text(v)) => v.as_str(),
8253                _ => {
8254                    return Err(LimboError::Corrupt(
8255                        "sqlite_schema type must be text".to_string(),
8256                    ));
8257                }
8258            };
8259            let name = match record.get_value_opt(1) {
8260                Some(ValueRef::Text(v)) => v.as_str(),
8261                _ => {
8262                    return Err(LimboError::Corrupt(
8263                        "sqlite_schema name must be text".to_string(),
8264                    ));
8265                }
8266            };
8267            let table_name = match record.get_value_opt(2) {
8268                Some(ValueRef::Text(v)) => v.as_str(),
8269                _ => {
8270                    return Err(LimboError::Corrupt(
8271                        "sqlite_schema tbl_name must be text".to_string(),
8272                    ));
8273                }
8274            };
8275            let root_page = match record.get_value_opt(3) {
8276                Some(ValueRef::Numeric(Numeric::Integer(v))) => v,
8277                _ => {
8278                    return Err(LimboError::Corrupt(
8279                        "sqlite_schema root_page must be integer".to_string(),
8280                    ));
8281                }
8282            };
8283            // A negative root is a not-yet-materialized placeholder. If a (passive) checkpoint
8284            // has since materialized this object, resolve to its real positive root so the
8285            // rebuilt schema reflects the btree page — otherwise integrity_check skips the
8286            // negative root and reports its materialized page as orphaned ("never used").
8287            let root_page = if root_page < 0 {
8288                self.table_id_to_rootpage
8289                    .get(&MVTableId::from(root_page))
8290                    .filter(|e| e.value().is_live())
8291                    .and_then(|e| e.value().root_page)
8292                    .map(|r| r as i64)
8293                    .unwrap_or(root_page)
8294            } else {
8295                root_page
8296            };
8297            let sql = match record.get_value_opt(4) {
8298                Some(ValueRef::Text(v)) => Some(v.as_str()),
8299                _ => None,
8300            };
8301            let attached_resolver = |alias: &str| -> Option<usize> {
8302                connection
8303                    .attached_databases()
8304                    .read()
8305                    .get_database_by_name(&crate::util::normalize_ident(alias))
8306                    .map(|(idx, _)| idx)
8307            };
8308            fresh.handle_schema_row(
8309                ty,
8310                name,
8311                table_name,
8312                root_page,
8313                sql,
8314                &syms,
8315                &mut from_sql_indexes,
8316                &mut automatic_indices,
8317                &mut dbsp_state_roots,
8318                &mut dbsp_state_index_roots,
8319                &mut materialized_view_info,
8320                &attached_resolver,
8321            )?;
8322        }
8323        fresh.populate_indices(
8324            &syms,
8325            from_sql_indexes,
8326            automatic_indices,
8327            mv_store.is_some(),
8328        )?;
8329        fresh.populate_materialized_views(
8330            materialized_view_info,
8331            dbsp_state_roots,
8332            dbsp_state_index_roots,
8333        )?;
8334        Self::rehydrate_table_valued_functions(&mut fresh, preserved_table_valued_functions);
8335
8336        Ok(Arc::new(fresh))
8337    }
8338
8339    /// Replays committed logical-log frames into the in-memory MVCC store.
8340    /// Only frames with `commit_ts > persistent_tx_ts_max` (the durable replay
8341    /// boundary from the metadata table) are applied; earlier frames were
8342    /// already checkpointed. On success, reseeds the MVCC clock and sets the log
8343    /// writer offset so torn-tail bytes are overwritten. Returns true if any
8344    /// frames were replayed.
8345    pub fn maybe_recover_logical_log(
8346        &self,
8347        connection: &Arc<Connection>,
8348        st: &mut RecoverLogicalLogState,
8349    ) -> Result<IOResult<bool>> {
8350        loop {
8351            match st {
8352                RecoverLogicalLogState::Start => {
8353                    let file = self.get_logical_log_file();
8354                    let enc_ctx = self.storage.encryption_ctx();
8355                    let reader = Box::new(StreamingLogicalLogReader::new(file, enc_ctx));
8356                    let preserved_tvfs =
8357                        Self::capture_table_valued_functions(&connection.schema.read());
8358                    *st = RecoverLogicalLogState::ReadHeader {
8359                        reader,
8360                        preserved_tvfs,
8361                    };
8362                }
8363                RecoverLogicalLogState::ReadHeader { reader, .. } => {
8364                    let header = match return_if_io!(reader.try_read_header_nonblock()) {
8365                        HeaderReadResult::Valid(header) => Some(header),
8366                        HeaderReadResult::NoLog => None,
8367                        HeaderReadResult::Invalid => {
8368                            return Err(LimboError::Corrupt(
8369                                "Logical log header corrupt and no WAL recovery available"
8370                                    .to_string(),
8371                            ));
8372                        }
8373                    };
8374                    if let Some(header) = &header {
8375                        self.storage.set_header(header.clone());
8376                    }
8377                    let header_present = header.is_some();
8378                    let RecoverLogicalLogState::ReadHeader {
8379                        reader,
8380                        preserved_tvfs,
8381                    } = std::mem::take(st)
8382                    else {
8383                        unreachable!("state is ReadHeader");
8384                    };
8385                    *st = RecoverLogicalLogState::ReadTxTs {
8386                        reader,
8387                        preserved_tvfs,
8388                        header_present,
8389                        txts_st: ReadPersistentTxTsMaxState::default(),
8390                    };
8391                }
8392                RecoverLogicalLogState::ReadTxTs {
8393                    header_present,
8394                    txts_st,
8395                    ..
8396                } => {
8397                    let header_present = *header_present;
8398                    let persistent_tx_ts_max = if self.uses_durable_mvcc_metadata(connection) {
8399                        match return_if_io!(
8400                            self.try_read_persistent_tx_ts_max_nonblock(connection, txts_st)
8401                        ) {
8402                            Some(ts) => ts,
8403                            None if !header_present => 0,
8404                            None => {
8405                                return Err(LimboError::Corrupt(
8406                                    "Missing MVCC metadata table".to_string(),
8407                                ));
8408                            }
8409                        }
8410                    } else {
8411                        0
8412                    };
8413                    self.durable_txid_max
8414                        .store(persistent_tx_ts_max, Ordering::SeqCst);
8415                    self.clock.reset(persistent_tx_ts_max + 1);
8416
8417                    if !header_present || self.get_logical_log_file().size()? <= LOG_HDR_SIZE as u64
8418                    {
8419                        *st = RecoverLogicalLogState::Done;
8420                        return Ok(IOResult::Done(false));
8421                    }
8422                    let RecoverLogicalLogState::ReadTxTs {
8423                        reader,
8424                        preserved_tvfs,
8425                        ..
8426                    } = std::mem::take(st)
8427                    else {
8428                        unreachable!("state is ReadTxTs");
8429                    };
8430                    *st = RecoverLogicalLogState::ReadCookie {
8431                        reader,
8432                        preserved_tvfs,
8433                        persistent_tx_ts_max,
8434                    };
8435                }
8436                RecoverLogicalLogState::ReadCookie { .. } => {
8437                    let fallback_cookie = if self.global_header.read().is_some() {
8438                        0
8439                    } else {
8440                        let pager = connection.pager.load().clone();
8441                        return_if_io!(pager.with_header(|header| header.schema_cookie)).get()
8442                    };
8443                    let RecoverLogicalLogState::ReadCookie {
8444                        reader,
8445                        preserved_tvfs,
8446                        persistent_tx_ts_max,
8447                    } = std::mem::take(st)
8448                    else {
8449                        unreachable!("state is ReadCookie");
8450                    };
8451                    let stmt = connection
8452                        .query(
8453                            "SELECT rowid, type, name, tbl_name, rootpage, sql FROM sqlite_schema",
8454                        )?
8455                        .map(Box::new);
8456                    *st = RecoverLogicalLogState::QuerySchema {
8457                        reader,
8458                        preserved_tvfs,
8459                        persistent_tx_ts_max,
8460                        cookie: fallback_cookie,
8461                        stmt,
8462                        schema_rows: HashMap::default(),
8463                    };
8464                }
8465                RecoverLogicalLogState::QuerySchema {
8466                    stmt, schema_rows, ..
8467                } => {
8468                    if let Some(stmt) = stmt {
8469                        return_if_io!(stmt.run_with_row_callback_nonblock(|row| {
8470                            let rowid = row.get::<i64>(0)?;
8471                            let values = (1..=5)
8472                                .map(|i| row.get_value(i).clone())
8473                                .collect::<Vec<_>>();
8474                            schema_rows.insert(
8475                                rowid,
8476                                ImmutableRecord::from_values(&values, values.len())?,
8477                            );
8478                            Ok(())
8479                        }));
8480                    }
8481                    let RecoverLogicalLogState::QuerySchema {
8482                        reader,
8483                        preserved_tvfs,
8484                        persistent_tx_ts_max,
8485                        cookie,
8486                        schema_rows,
8487                        ..
8488                    } = std::mem::take(st)
8489                    else {
8490                        unreachable!("state is QuerySchema");
8491                    };
8492                    let current_schema = connection.schema.read().clone();
8493                    *st = RecoverLogicalLogState::Replay {
8494                        ctx: Box::new(RecoverCtx {
8495                            reader,
8496                            preserved_table_valued_functions: preserved_tvfs,
8497                            cookie,
8498                            persistent_tx_ts_max,
8499                            replay_cutoff_ts: persistent_tx_ts_max,
8500                            max_commit_ts_seen: persistent_tx_ts_max,
8501                            schema_rows,
8502                            dropped_root_pages: HashSet::default(),
8503                            current_schema,
8504                            index_infos: HashMap::default(),
8505                        }),
8506                    };
8507                }
8508                RecoverLogicalLogState::Replay { ctx } => {
8509                    turso_assert_reachable!("MVCC recovery (replaying a frame)");
8510                    loop {
8511                        let Some(frame) = return_if_io!(ctx.reader.next_frame()) else {
8512                            let recovered_offset = ctx.reader.last_valid_offset() as u64;
8513                            let recovered_running_crc = ctx.reader.running_crc();
8514                            self.storage.restore_logical_log_state_after_recovery(
8515                                recovered_offset,
8516                                recovered_running_crc,
8517                            );
8518                            break;
8519                        };
8520                        self.recover_process_frame(connection, ctx, frame)?;
8521                    }
8522                    let max_commit_ts_seen = ctx.max_commit_ts_seen;
8523                    let persistent_tx_ts_max = ctx.persistent_tx_ts_max;
8524                    let dropped_root_pages = std::mem::take(&mut ctx.dropped_root_pages);
8525                    assert!(
8526                        max_commit_ts_seen >= persistent_tx_ts_max,
8527                        "replay clock would rewind below metadata boundary: max_commit_ts_seen={max_commit_ts_seen} persistent_tx_ts_max={persistent_tx_ts_max}"
8528                    );
8529                    connection.with_schema_mut(|schema| {
8530                        schema.dropped_root_pages = dropped_root_pages;
8531                    })?;
8532                    if let Some(header) = self.global_header.read().as_ref() {
8533                        connection.with_schema_mut(|schema| {
8534                            schema.schema_version = header.schema_cookie.get();
8535                        })?;
8536                    }
8537                    *connection.db.schema.lock() = connection.schema.read().clone();
8538                    self.clock.reset(max_commit_ts_seen + 1);
8539                    self.last_committed_tx_ts
8540                        .store(max_commit_ts_seen, Ordering::SeqCst);
8541                    *st = RecoverLogicalLogState::Done;
8542                    return Ok(IOResult::Done(true));
8543                }
8544                RecoverLogicalLogState::Done => {
8545                    return Ok(IOResult::Done(false));
8546                }
8547            }
8548        }
8549    }
8550
8551    /// Replay a single committed logical-log transaction frame into the MVCC
8552    /// store. Fully synchronous (the only recovery IO is reading the next frame,
8553    /// driven by the caller); operates on accumulators borrowed from `ctx`.
8554    fn recover_process_frame(
8555        &self,
8556        connection: &Arc<Connection>,
8557        ctx: &mut RecoverCtx,
8558        frame: Vec<ParsedOp>,
8559    ) -> Result<()> {
8560        let mut max_commit_ts_seen = ctx.max_commit_ts_seen;
8561        let replay_cutoff_ts = ctx.replay_cutoff_ts;
8562        let cookie = ctx.cookie;
8563        let mut schema_rows = std::mem::take(&mut ctx.schema_rows);
8564        let mut dropped_root_pages = std::mem::take(&mut ctx.dropped_root_pages);
8565        let mut current_schema = ctx.current_schema.clone();
8566        let mut index_infos = std::mem::take(&mut ctx.index_infos);
8567
8568        let install_schema = |schema: Arc<Schema>| {
8569            *connection.schema.write() = schema.clone();
8570            *connection.db.schema.lock() = schema;
8571        };
8572
8573        let root_page_for_index = |index_id: MVTableId| -> i64 {
8574            self.current_root_page(&index_id)
8575                .map(|value| value as i64)
8576                .unwrap_or_else(|| i64::from(index_id))
8577        };
8578
8579        let find_index_info =
8580            |schema: &Schema, root_page: i64| -> Result<Option<Arc<IndexInfo>>, TryReserveError> {
8581                schema
8582                    .indexes
8583                    .values()
8584                    .flatten()
8585                    .find(|idx| idx.root_page == root_page)
8586                    .map(|idx| {
8587                        IndexInfo::new_from_index_in(idx.as_ref(), self.alloc.clone()).map(Arc::new)
8588                    })
8589                    .transpose()
8590            };
8591
8592        let schema_has_index_root = |schema: &Schema, root_page: i64| -> bool {
8593            schema
8594                .indexes
8595                .values()
8596                .flatten()
8597                .any(|idx| idx.root_page == root_page)
8598        };
8599
8600        let parsed_op_commit_ts = |op: &ParsedOp| match op {
8601            ParsedOp::UpsertTable { commit_ts, .. }
8602            | ParsedOp::DeleteTable { commit_ts, .. }
8603            | ParsedOp::UpsertIndex { commit_ts, .. }
8604            | ParsedOp::DeleteIndex { commit_ts, .. }
8605            | ParsedOp::UpdateHeader { commit_ts, .. } => *commit_ts,
8606        };
8607        'frame: {
8608            let frame_commit_ts = parsed_op_commit_ts(
8609                frame
8610                    .first()
8611                    .expect("next_frame should not return an empty frame"),
8612            );
8613            max_commit_ts_seen = max_commit_ts_seen.max(frame_commit_ts);
8614            if frame_commit_ts <= replay_cutoff_ts {
8615                break 'frame;
8616            }
8617
8618            // Work out what sqlite_schema will look like after this transaction,
8619            // before applying any row/index changes from the transaction.
8620            //
8621            // Why this is necessary:
8622            // - CREATE INDEX can log index-entry inserts in the same frame as the
8623            //   sqlite_schema insert for the new index. Those index-entry inserts
8624            //   need the post-frame schema.
8625            // - DROP INDEX can log DELETE_INDEX entries for b-tree index entries
8626            //   that existed before the transaction. Those deletes still need the
8627            //   pre-frame schema, because the final schema no longer has idx.
8628            // - ALTER TABLE can delete and reinsert the same table's sqlite_schema
8629            //   row while also logging table/index DML. Installing the schema after
8630            //   only the delete would create an impossible in-between schema:
8631            //   sqlite_schema may still contain idx while the table row for t is
8632            //   temporarily absent.
8633            //
8634            // So recovery stages sqlite_schema into `schema_rows_after`, builds a
8635            // post-frame Schema from that staged map, and keeps the installed
8636            // `current_schema` unchanged until every op in the frame has replayed.
8637            // Most transaction frames do not change sqlite_schema, so clone the
8638            // schema row map only if this frame actually writes sqlite_schema.
8639            let mut schema_rows_after: Option<HashMap<i64, ImmutableRecord>> = None;
8640            for parsed_op in &frame {
8641                match parsed_op {
8642                    ParsedOp::UpsertTable {
8643                        rowid,
8644                        record_bytes,
8645                        ..
8646                    } if rowid.table_id == SQLITE_SCHEMA_MVCC_TABLE_ID => {
8647                        let schema_rows_after =
8648                            schema_rows_after.get_or_insert_with(|| schema_rows.clone());
8649                        let record = ImmutableRecordRef::from_bin_record(record_bytes);
8650                        let column_count = record.column_count();
8651                        if column_count < 5 {
8652                            return Err(LimboError::Corrupt(format!(
8653                                "sqlite_schema row must have at least 5 columns, got {column_count}",
8654                            )));
8655                        }
8656                        crate::with_mv_store_allocation_site!(SchemaRowPayload, {
8657                            schema_rows_after.insert(
8658                                rowid.row_id.to_int_or_panic(),
8659                                ImmutableRecord::from_bin_record(record_bytes.clone()),
8660                            );
8661                        });
8662                    }
8663                    ParsedOp::DeleteTable { rowid, .. }
8664                        if rowid.table_id == SQLITE_SCHEMA_MVCC_TABLE_ID =>
8665                    {
8666                        let schema_rows_after =
8667                            schema_rows_after.get_or_insert_with(|| schema_rows.clone());
8668                        schema_rows_after.remove(&rowid.row_id.to_int_or_panic());
8669                    }
8670                    _ => {}
8671                }
8672            }
8673            // schema_rows_after is Some if the frame changes the schema
8674            let schema_rows_after = schema_rows_after;
8675
8676            let schema_after = match schema_rows_after.as_ref() {
8677                Some(schema_rows_after) => Some(self.recover_build_schema(
8678                    connection,
8679                    schema_rows_after,
8680                    cookie,
8681                    &ctx.preserved_table_valued_functions,
8682                )?),
8683                None => None,
8684            };
8685
8686            if schema_rows_after.is_some() {
8687                // Cached IndexInfo values are tied to a specific schema. Clear
8688                // before decoding a schema-changing frame so this frame chooses
8689                // from its own before/after schema pair.
8690                index_infos.clear();
8691            }
8692
8693            {
8694                let should_skip_index_op = |parsed_op: &ParsedOp| -> bool {
8695                    let Some(schema_after) = schema_after.as_ref() else {
8696                        return false;
8697                    };
8698
8699                    match parsed_op {
8700                        ParsedOp::UpsertIndex { table_id, .. } => {
8701                            let root_page = root_page_for_index(*table_id);
8702                            !schema_has_index_root(schema_after, root_page)
8703                        }
8704                        ParsedOp::DeleteIndex { table_id, .. } => {
8705                            let root_page = root_page_for_index(*table_id);
8706                            !schema_has_index_root(&current_schema, root_page)
8707                        }
8708                        _ => false,
8709                    }
8710                };
8711
8712                let mut get_index_info = |index_id: MVTableId,
8713                                          op_kind: IndexOpKind|
8714                 -> Result<Arc<IndexInfo>> {
8715                    if let Some(index_info) = index_infos.get(&(index_id, op_kind)) {
8716                        return Ok(index_info.clone());
8717                    }
8718
8719                    let root_page = root_page_for_index(index_id);
8720                    let before = find_index_info(&current_schema, root_page)?;
8721                    let after = schema_after
8722                        .as_ref()
8723                        .map(|schema| find_index_info(schema, root_page))
8724                        .transpose()?
8725                        .flatten();
8726
8727                    // The logical log tells us whether an index entry is being
8728                    // inserted or deleted, but it stores only encoded key bytes
8729                    // plus the index root page. The recovery loop below skips
8730                    // index ops that cannot affect the final state of this
8731                    // frame before those bytes are decoded. For the remaining
8732                    // ops, pick the schema view that owns the entry at the
8733                    // frame boundary:
8734                    //
8735                    // - UPSERT_INDEX writes an entry that survives after the
8736                    //   transaction. In a schema-changing frame, the index must
8737                    //   exist in the post-frame schema.
8738                    // - DELETE_INDEX removes an entry that existed before the
8739                    //   transaction. In a schema-changing frame, the index must
8740                    //   exist in the pre-frame schema.
8741                    // - If the frame does not change schema, `current_schema` is
8742                    //   both the before and after schema.
8743                    let index_info = match op_kind {
8744                        IndexOpKind::Upsert if schema_after.is_some() => after,
8745                        IndexOpKind::Delete if schema_after.is_some() => before,
8746                        IndexOpKind::Upsert | IndexOpKind::Delete => before,
8747                    }
8748                    .ok_or_else(|| {
8749                        let expected_schema = match op_kind {
8750                            IndexOpKind::Upsert if schema_after.is_some() => "post-frame",
8751                            IndexOpKind::Delete if schema_after.is_some() => "pre-frame",
8752                            IndexOpKind::Upsert | IndexOpKind::Delete => "current",
8753                        };
8754                        LimboError::InternalError(format!(
8755                            "Index with root page {root_page} not found in {expected_schema} schema while recovering logical log",
8756                        ))
8757                    })?;
8758                    index_infos.insert((index_id, op_kind), index_info.clone());
8759                    Ok(index_info)
8760                };
8761
8762                for parsed_op in frame {
8763                    // Some index writes are real while the transaction is
8764                    // running, but have no meaning at either durable boundary.
8765                    //
8766                    // Example: UPDATE a row so it writes a new entry into
8767                    // idx_old, then DROP INDEX idx_old before COMMIT. The
8768                    // UPSERT_INDEX for idx_old is not part of the database
8769                    // after the transaction, and the post-frame schema no
8770                    // longer has CREATE INDEX text for idx_old. Decoding it
8771                    // against the pre-frame schema would preserve an index
8772                    // entry for an index that was dropped. Decoding it against
8773                    // the post-frame schema is impossible. The correct action
8774                    // is to skip it.
8775                    //
8776                    // The opposite case is a DELETE_INDEX for an index created
8777                    // earlier in the same frame. There was no pre-frame index
8778                    // entry in the database file, so that delete also has no
8779                    // durable work to do.
8780                    if should_skip_index_op(&parsed_op) {
8781                        continue;
8782                    }
8783
8784                    let next_rec = ctx.reader.parsed_op_to_streaming_in(
8785                        parsed_op,
8786                        &mut get_index_info,
8787                        self.alloc.clone(),
8788                    )?;
8789
8790                    tracing::trace!("next_rec {next_rec:?}");
8791
8792                    match next_rec {
8793                        StreamingResult::UpsertTableRow {
8794                            row,
8795                            rowid,
8796                            commit_ts,
8797                            btree_resident,
8798                        } => {
8799                            max_commit_ts_seen = max_commit_ts_seen.max(commit_ts);
8800                            if commit_ts <= replay_cutoff_ts {
8801                                continue;
8802                            }
8803                            let is_schema_row = rowid.table_id == SQLITE_SCHEMA_MVCC_TABLE_ID;
8804                            if is_schema_row {
8805                                let record = ImmutableRecordRef::from_bin_record(row.payload());
8806                                let column_count = record.column_count();
8807                                if column_count < 5 {
8808                                    return Err(LimboError::Corrupt(format!(
8809                                        "sqlite_schema row must have at least 5 columns, got {column_count}",
8810                                    )));
8811                                }
8812                                let Some(ValueRef::Text(row_type)) = record.get_value_opt(0) else {
8813                                    return Err(LimboError::Corrupt(
8814                                        "sqlite_schema type must be text".to_string(),
8815                                    ));
8816                                };
8817                                let row_type = row_type.as_str();
8818                                let val = match record.get_value_opt(3) {
8819                                    Some(v) => v,
8820                                    None => {
8821                                        return Err(LimboError::InternalError(
8822                                            "Expected at least 5 columns in sqlite_schema"
8823                                                .to_string(),
8824                                        ));
8825                                    }
8826                                };
8827                                let ValueRef::Numeric(crate::numeric::Numeric::Integer(root_page)) =
8828                                    val
8829                                else {
8830                                    panic!("Expected integer value for root page, got {val:?}");
8831                                };
8832                                let sql = match record.get_value_opt(4) {
8833                                    Some(ValueRef::Text(v)) => Some(v.as_str()),
8834                                    _ => None,
8835                                };
8836                                let is_virtual_table = row_type == "table"
8837                                    && sql.is_some_and(crate::util::sql_is_create_virtual_table);
8838                                let has_btree = match row_type {
8839                                    "index" => true,
8840                                    "table" => !is_virtual_table,
8841                                    _ => false,
8842                                };
8843                                if has_btree {
8844                                    if root_page == 0 {
8845                                        return Err(LimboError::Corrupt(format!(
8846                                            "sqlite_schema root_page=0 for btree {row_type}"
8847                                        )));
8848                                    }
8849                                    if root_page < 0 {
8850                                        let table_id = self.get_table_id_from_root_page(root_page);
8851                                        if let Some(entry) =
8852                                            self.table_id_to_rootpage.get(&table_id)
8853                                        {
8854                                            if let Some(value) = entry.value().root_page {
8855                                                panic!(
8856                                                    "Logical log contains an insertion of a sqlite_schema record that has both a negative root page and a positive root page: {root_page} & {value}"
8857                                                );
8858                                            }
8859                                        }
8860                                        self.insert_table_id_to_rootpage(table_id, None);
8861                                    } else {
8862                                        dropped_root_pages.remove(&root_page);
8863                                        let table_id = self.get_table_id_from_root_page(root_page);
8864                                        let Some(entry) = self.table_id_to_rootpage.get(&table_id)
8865                                        else {
8866                                            panic!(
8867                                                "Logical log contains root page reference {root_page} that does not exist in the table_id_to_rootpage map"
8868                                            );
8869                                        };
8870                                        let Some(value) = entry.value().root_page else {
8871                                            panic!(
8872                                                "Logical log contains root page reference {root_page} that does not have a root page in the table_id_to_rootpage map"
8873                                            );
8874                                        };
8875                                        turso_assert_eq!(value, root_page as u64, "logical log root page does not match table_id_to_rootpage map", { "root_page": root_page, "map_value": value });
8876                                    }
8877                                } else if root_page != 0 {
8878                                    return Err(LimboError::Corrupt(format!(
8879                                        "sqlite_schema root_page must be 0 for {row_type}, got {root_page}"
8880                                    )));
8881                                }
8882                                let rowid_int = rowid.row_id.to_int_or_panic();
8883                                crate::with_mv_store_allocation_site!(SchemaRowPayload, {
8884                                    schema_rows.insert(
8885                                        rowid_int,
8886                                        ImmutableRecord::from_bin_record(row.payload().to_vec()),
8887                                    );
8888                                });
8889                            } else if self.table_id_to_rootpage.get(&rowid.table_id).is_none() {
8890                                // Data row references a table_id not yet in the map. This can happen
8891                                // with logs written before the schema-first serialization fix: in a
8892                                // same-transaction CREATE TABLE + INSERT + DROP TABLE, data rows were
8893                                // serialized before the schema INSERT that registers the table_id.
8894                                // The schema INSERT (or DELETE) for this table will follow later in
8895                                // this transaction frame, so we register the table_id now.
8896                                self.insert_table_id_to_rootpage(rowid.table_id, None);
8897                            }
8898
8899                            let version_id = self.get_version_id();
8900                            let row_version = RowVersion {
8901                                id: version_id,
8902                                begin: crate::mvcc::database::PackedTs::pack(Some(
8903                                    TxTimestampOrID::Timestamp(commit_ts),
8904                                )),
8905                                end: crate::mvcc::database::PackedTs::pack(None),
8906                                row: row.clone(),
8907                                btree_resident,
8908                                materialized_at: crate::mvcc::database::WalPos::ORIGIN,
8909                            };
8910                            {
8911                                let versions =
8912                                    self.get_or_create_table_row_versions(rowid.clone())?;
8913                                let mut versions = versions.write();
8914                                self.insert_version_raw(&mut versions, row_version)?;
8915                            }
8916                            let allocator = self.get_rowid_allocator(&rowid.table_id);
8917                            allocator.insert_row_id_maybe_update(rowid.row_id.to_int_or_panic());
8918                        }
8919                        StreamingResult::DeleteTableRow {
8920                            rowid, commit_ts, ..
8921                        } => {
8922                            max_commit_ts_seen = max_commit_ts_seen.max(commit_ts);
8923                            if commit_ts <= replay_cutoff_ts {
8924                                continue;
8925                            }
8926                            if self.table_id_to_rootpage.get(&rowid.table_id).is_none() {
8927                                // See comment in UpsertTableRow: old logs may have data rows
8928                                // serialized before the schema INSERT that registers the table_id.
8929                                self.insert_table_id_to_rootpage(rowid.table_id, None);
8930                            }
8931                            let tombstone_row = crate::with_mv_store_allocation_site!(
8932                                RowPayload,
8933                                if rowid.table_id == SQLITE_SCHEMA_MVCC_TABLE_ID {
8934                                    let rowid_int = rowid.row_id.to_int_or_panic();
8935                                    if let Some(record) = schema_rows.get(&rowid_int) {
8936                                        // Preserve the pre-delete sqlite_schema record in recovered
8937                                        // tombstones so checkpoint can still recover B-tree identity.
8938                                        Row::new_table_row_in(
8939                                            rowid.clone(),
8940                                            record.as_blob(),
8941                                            record.column_count(),
8942                                            self.alloc.clone(),
8943                                        )?
8944                                    } else {
8945                                        Row::new_table_row_in(
8946                                            rowid.clone(),
8947                                            &[],
8948                                            0,
8949                                            self.alloc.clone(),
8950                                        )?
8951                                    }
8952                                } else {
8953                                    Row::new_table_row_in(
8954                                        rowid.clone(),
8955                                        &[],
8956                                        0,
8957                                        self.alloc.clone(),
8958                                    )?
8959                                }
8960                            );
8961                            if let Some(versions) = self.rows.get(&rowid) {
8962                                // Row exists in memory — try to find the current (non-ended) version
8963                                // that was committed before this delete, and mark it as ended. If no
8964                                // such version exists (e.g. it was already GC'd or this is a B-tree
8965                                // resident row not yet in memory), insert a tombstone instead.
8966                                let mut versions = versions.value().write();
8967                                if let Some(existing) = versions.iter_mut().rev().find(|rv| {
8968                                    rv.end().is_none()
8969                                        && matches!(rv.begin(), Some(TxTimestampOrID::Timestamp(b)) if b < commit_ts)
8970                                }) {
8971                                    existing.set_end(Some(TxTimestampOrID::Timestamp(commit_ts)));
8972                                } else {
8973                                    let version_id = self.get_version_id();
8974                                    let row_version = RowVersion {
8975                                        id: version_id,
8976                                        begin: crate::mvcc::database::PackedTs::pack(None),
8977                                        end: crate::mvcc::database::PackedTs::pack(Some(
8978                                            TxTimestampOrID::Timestamp(commit_ts),
8979                                        )),
8980                                        row: tombstone_row.clone(),
8981                                        // The version this delete ends was not replayed, so its
8982                                        // frame is at or below the durable replay boundary: the
8983                                        // deleted row is in the DB file. Mark the tombstone
8984                                        // btree-resident so checkpoint applies the delete even
8985                                        // though the logged flag predates the row becoming durable.
8986                                        btree_resident: true,
8987                                        materialized_at: crate::mvcc::database::WalPos::ORIGIN,
8988                                    };
8989                                    self.insert_version_raw(&mut versions, row_version)?;
8990                                }
8991                            } else {
8992                                let version_id = self.get_version_id();
8993                                let row_version = RowVersion {
8994                                    id: version_id,
8995                                    begin: crate::mvcc::database::PackedTs::pack(None),
8996                                    end: crate::mvcc::database::PackedTs::pack(Some(
8997                                        TxTimestampOrID::Timestamp(commit_ts),
8998                                    )),
8999                                    row: tombstone_row,
9000                                    // Same invariant as above: no replayed version means the
9001                                    // deleted row is already durable in the DB file.
9002                                    btree_resident: true,
9003                                    materialized_at: crate::mvcc::database::WalPos::ORIGIN,
9004                                };
9005                                let versions =
9006                                    self.get_or_create_table_row_versions(rowid.clone())?;
9007                                let mut versions = versions.write();
9008                                self.insert_version_raw(&mut versions, row_version)?;
9009                            }
9010                            if rowid.table_id == SQLITE_SCHEMA_MVCC_TABLE_ID {
9011                                let rowid_int = rowid.row_id.to_int_or_panic();
9012                                let Some(record) = schema_rows.get(&rowid_int) else {
9013                                    // this can happen if a row in sqlite_schema was inserted and then
9014                                    // deleted in the same transaction (ex: a CREATE TABLE followed by a DROP TABLE)
9015                                    continue;
9016                                };
9017                                if record.column_count() < 5 {
9018                                    return Err(LimboError::Corrupt(format!(
9019                                        "sqlite_schema row must have at least 5 columns, got {}",
9020                                        record.column_count()
9021                                    )));
9022                                }
9023                                let (
9024                                    ValueRef::Text(row_type),
9025                                    ValueRef::Numeric(Numeric::Integer(root_page)),
9026                                ) = record.get_two_values(0, 3)?
9027                                else {
9028                                    return Err(LimboError::Corrupt(
9029                                        "sqlite_schema type and root_page must be text and integer"
9030                                            .to_string(),
9031                                    ));
9032                                };
9033                                let row_type = row_type.as_str();
9034                                if (row_type == "table" || row_type == "index") && root_page > 0 {
9035                                    dropped_root_pages.insert(root_page);
9036                                }
9037                                schema_rows.remove(&rowid_int);
9038                            }
9039                        }
9040                        StreamingResult::UpsertIndexRow {
9041                            row,
9042                            rowid,
9043                            commit_ts,
9044                            btree_resident,
9045                        } => {
9046                            max_commit_ts_seen = max_commit_ts_seen.max(commit_ts);
9047                            if commit_ts <= replay_cutoff_ts {
9048                                continue;
9049                            }
9050                            let version_id = self.get_version_id();
9051                            let row_version = RowVersion {
9052                                id: version_id,
9053                                begin: crate::mvcc::database::PackedTs::pack(Some(
9054                                    TxTimestampOrID::Timestamp(commit_ts),
9055                                )),
9056                                end: crate::mvcc::database::PackedTs::pack(None),
9057                                row: row.clone(),
9058                                btree_resident,
9059                                materialized_at: crate::mvcc::database::WalPos::ORIGIN,
9060                            };
9061                            let RowKey::Record(sortable_key) = rowid.row_id.clone() else {
9062                                panic!("Index writes must be to a record");
9063                            };
9064                            self.insert_index_version(rowid.table_id, sortable_key, row_version)?;
9065                        }
9066                        StreamingResult::DeleteIndexRow {
9067                            row,
9068                            rowid,
9069                            commit_ts,
9070                            ..
9071                        } => {
9072                            max_commit_ts_seen = max_commit_ts_seen.max(commit_ts);
9073                            if commit_ts <= replay_cutoff_ts {
9074                                continue;
9075                            }
9076                            let RowKey::Record(sortable_key) = rowid.row_id.clone() else {
9077                                panic!("Index writes must be to a record");
9078                            };
9079                            let sortable_key =
9080                                self.get_or_create_index_key_arc(rowid.table_id, sortable_key)?;
9081                            if let Some(index_map) = self.index_rows.get(&rowid.table_id) {
9082                                if let Some(versions) = index_map.value().get(&sortable_key) {
9083                                    let mut versions = versions.value().write();
9084                                    if let Some(existing) = versions.iter_mut().rev().find(|rv| {
9085                                rv.end().is_none()
9086                                    && matches!(rv.begin(), Some(TxTimestampOrID::Timestamp(b)) if b < commit_ts)
9087                            }) {
9088                                existing.set_end(Some(TxTimestampOrID::Timestamp(commit_ts)));
9089                                continue;
9090                            }
9091                                }
9092                            }
9093                            let version_id = self.get_version_id();
9094                            let row_version = RowVersion {
9095                                id: version_id,
9096                                begin: crate::mvcc::database::PackedTs::pack(None),
9097                                end: crate::mvcc::database::PackedTs::pack(Some(
9098                                    TxTimestampOrID::Timestamp(commit_ts),
9099                                )),
9100                                row: row.clone(),
9101                                // The index entry this delete ends was not replayed, so its
9102                                // frame is at or below the durable replay boundary: the entry
9103                                // is in the DB file. Mark the tombstone btree-resident so
9104                                // checkpoint applies the delete even though the logged flag
9105                                // predates the entry becoming durable (e.g. a checkpoint that
9106                                // failed after its pager commit).
9107                                btree_resident: true,
9108                                materialized_at: crate::mvcc::database::WalPos::ORIGIN,
9109                            };
9110                            self.insert_index_version(rowid.table_id, sortable_key, row_version)?;
9111                        }
9112                        StreamingResult::UpdateHeader { header, commit_ts } => {
9113                            max_commit_ts_seen = max_commit_ts_seen.max(commit_ts);
9114                            if commit_ts <= replay_cutoff_ts {
9115                                continue;
9116                            }
9117                            // Recovery applies only post-boundary header ops; the same value is later
9118                            // staged to pager page-1 during checkpoint.
9119                            self.global_header.write().replace(header);
9120                        }
9121                        StreamingResult::Eof => {
9122                            unreachable!("next_frame does not return EOF records");
9123                        }
9124                    }
9125                }
9126            }
9127
9128            if schema_rows_after.is_some() {
9129                // Now that all table and index ops from this transaction have
9130                // been replayed, publish the frame's final schema. No later index
9131                // op from this frame can observe a half-applied sqlite_schema.
9132                let schema_rows_after =
9133                    schema_rows_after.expect("schema_rows_after must exist when schema changed");
9134                let schema_after = schema_after
9135                    .expect("schema_after must exist when frame_changes_schema is true");
9136                schema_rows = schema_rows_after;
9137                install_schema(schema_after.clone());
9138                current_schema = schema_after;
9139                // The frame may have decoded DROP INDEX entries using the
9140                // pre-frame schema. Do not carry those IndexInfo values into
9141                // later frames after current_schema has changed.
9142                index_infos.clear();
9143            }
9144        }
9145
9146        ctx.max_commit_ts_seen = max_commit_ts_seen;
9147        ctx.schema_rows = schema_rows;
9148        ctx.dropped_root_pages = dropped_root_pages;
9149        ctx.current_schema = current_schema;
9150        ctx.index_infos = index_infos;
9151        Ok(())
9152    }
9153
9154    /// Build a fresh `Schema` from the recovered `sqlite_schema` rows. Sync: the
9155    /// schema cookie comes from `global_header` (in-memory) or `fallback_cookie`
9156    /// (pre-read by the caller); no IO here.
9157    fn recover_build_schema(
9158        &self,
9159        connection: &Arc<Connection>,
9160        schema_rows: &HashMap<i64, ImmutableRecord>,
9161        fallback_cookie: u32,
9162        preserved_table_valued_functions: &[Arc<crate::vtab::VirtualTable>],
9163    ) -> Result<Arc<Schema>> {
9164        let cookie = self
9165            .global_header
9166            .read()
9167            .as_ref()
9168            .map(|header| header.schema_cookie.get())
9169            .unwrap_or(fallback_cookie);
9170        let mut fresh = Schema::new();
9171        fresh.generated_columns_enabled = connection.db.experimental_generated_columns_enabled();
9172        fresh.schema_version = cookie;
9173        let mut from_sql_indexes =
9174            crate::alloc::Vec::try_with_capacity_ext(10).expect(crate::alloc::ALLOC_ERR_MSG);
9175        let mut automatic_indices: HashMap<String, crate::alloc::Vec<(String, i64)>> =
9176            HashMap::default();
9177        let mut dbsp_state_roots: HashMap<String, i64> = HashMap::default();
9178        let mut dbsp_state_index_roots: HashMap<String, i64> = HashMap::default();
9179        let mut materialized_view_info: HashMap<String, (String, i64)> = HashMap::default();
9180        let syms = connection.syms.read();
9181        let mv_store = connection.db.get_mv_store().clone();
9182
9183        let mut sorted_rowids: Vec<i64> = schema_rows.keys().copied().collect();
9184        sorted_rowids.sort_unstable();
9185        for rowid in &sorted_rowids {
9186            let record = &schema_rows[rowid];
9187            let ty = match record.get_value_opt(0) {
9188                Some(ValueRef::Text(v)) => v.as_str(),
9189                _ => {
9190                    return Err(LimboError::Corrupt(
9191                        "sqlite_schema type must be text".to_string(),
9192                    ));
9193                }
9194            };
9195            let name = match record.get_value_opt(1) {
9196                Some(ValueRef::Text(v)) => v.as_str(),
9197                _ => {
9198                    return Err(LimboError::Corrupt(
9199                        "sqlite_schema name must be text".to_string(),
9200                    ));
9201                }
9202            };
9203            let table_name = match record.get_value_opt(2) {
9204                Some(ValueRef::Text(v)) => v.as_str(),
9205                _ => {
9206                    return Err(LimboError::Corrupt(
9207                        "sqlite_schema tbl_name must be text".to_string(),
9208                    ));
9209                }
9210            };
9211            let root_page = match record.get_value_opt(3) {
9212                Some(ValueRef::Numeric(Numeric::Integer(v))) => v,
9213                _ => {
9214                    return Err(LimboError::Corrupt(
9215                        "sqlite_schema root_page must be integer".to_string(),
9216                    ));
9217                }
9218            };
9219            // A negative root is a not-yet-materialized placeholder. If a (passive) checkpoint
9220            // has since materialized this object, resolve to its real positive root so the
9221            // rebuilt schema reflects the btree page — otherwise integrity_check skips the
9222            // negative root and reports its materialized page as orphaned ("never used").
9223            let root_page = if root_page < 0 {
9224                self.table_id_to_rootpage
9225                    .get(&MVTableId::from(root_page))
9226                    .filter(|e| e.value().is_live())
9227                    .and_then(|e| e.value().root_page)
9228                    .map(|r| r as i64)
9229                    .unwrap_or(root_page)
9230            } else {
9231                root_page
9232            };
9233            let sql = match record.get_value_opt(4) {
9234                Some(ValueRef::Text(v)) => Some(v.as_str()),
9235                _ => None,
9236            };
9237            let attached_resolver = |alias: &str| -> Option<usize> {
9238                connection
9239                    .attached_databases()
9240                    .read()
9241                    .get_database_by_name(&crate::util::normalize_ident(alias))
9242                    .map(|(idx, _)| idx)
9243            };
9244            fresh.handle_schema_row(
9245                ty,
9246                name,
9247                table_name,
9248                root_page,
9249                sql,
9250                &syms,
9251                &mut from_sql_indexes,
9252                &mut automatic_indices,
9253                &mut dbsp_state_roots,
9254                &mut dbsp_state_index_roots,
9255                &mut materialized_view_info,
9256                &attached_resolver,
9257            )?;
9258        }
9259        fresh.populate_indices(
9260            &syms,
9261            from_sql_indexes,
9262            automatic_indices,
9263            mv_store.is_some(),
9264        )?;
9265        fresh.populate_materialized_views(
9266            materialized_view_info,
9267            dbsp_state_roots,
9268            dbsp_state_index_roots,
9269        )?;
9270        Self::rehydrate_table_valued_functions(&mut fresh, preserved_table_valued_functions);
9271
9272        Ok(Arc::new(fresh))
9273    }
9274
9275    pub fn set_checkpoint_threshold(&self, threshold: i64) {
9276        self.storage.set_checkpoint_threshold(threshold)
9277    }
9278
9279    pub fn checkpoint_threshold(&self) -> i64 {
9280        self.storage.checkpoint_threshold()
9281    }
9282
9283    pub fn get_real_table_id(&self, table_id: i64) -> i64 {
9284        self.current_root_page(&MVTableId::from(table_id))
9285            .map_or(table_id, |root_page| root_page as i64)
9286    }
9287
9288    pub fn get_rowid_allocator(&self, table_id: &MVTableId) -> Arc<RowidAllocator> {
9289        let mut map = self.table_id_to_last_rowid.write();
9290        if map.contains_key(table_id) {
9291            map.get(table_id).unwrap().clone()
9292        } else {
9293            let allocator = Arc::new(RowidAllocator {
9294                lock: TursoRwLock::new(),
9295                max_rowid: AtomicI64::new(0),
9296                initialized: AtomicBool::new(false),
9297            });
9298            map.insert(*table_id, allocator.clone());
9299            allocator
9300        }
9301    }
9302
9303    /// Whether `table_id` has a *currently live* checkpointed B-tree. Snapshot-agnostic; for
9304    /// transaction reads use [`Self::is_btree_readable_at`].
9305    pub fn is_btree_allocated(&self, table_id: &MVTableId) -> bool {
9306        self.table_id_to_rootpage
9307            .get(table_id)
9308            .is_some_and(|entry| entry.value().root_page.is_some() && entry.value().is_live())
9309    }
9310
9311    /// Whether a transaction may **read** `table_id`'s B-tree, given its logical snapshot
9312    /// `begin_ts` and its frozen WAL `read_mark`. Requires BOTH:
9313    /// - **logical (base validity):** the binding `covers(begin_ts)` — `begin <= begin_ts < end`; and
9314    /// - **physical reachability:** `materialized_at <= read_mark` — the btree's pages are at-or-below
9315    ///   this transaction's read mark (same WAL epoch and frame ≤ mark, or an earlier backfilled
9316    ///   epoch). Without this a transaction that opened before an checkpoint materialization would
9317    ///   seek a page its read mark cannot reach (a torn/foreign/zeroed-page read).
9318    ///
9319    /// When this is false the transaction stays version-store-only; the GC floor
9320    /// ([`Self::compute_min_reader_mark`]) guarantees the version-store copy is still present.
9321    pub fn is_btree_readable_at(
9322        &self,
9323        table_id: &MVTableId,
9324        begin_ts: u64,
9325        read_mark: WalPos,
9326    ) -> bool {
9327        self.table_id_to_rootpage
9328            .get(table_id)
9329            .is_some_and(|entry| {
9330                let e = entry.value();
9331                // A not-yet-committed binding (materialized_at == STAGED) is never readable, even
9332                // by an untracked/no-WAL reader whose mark is also STAGED.
9333                e.root_page.is_some()
9334                    && e.materialized_at != WalPos::STAGED
9335                    && e.covers(begin_ts)
9336                    && e.materialized_at <= read_mark
9337            })
9338    }
9339
9340    /// Lexicographic minimum WAL read mark over all active/preparing transactions
9341    /// ([`WalPos::STAGED`] if none). A freshly-materialized object's version-store rows may be GC'd
9342    /// only once `materialized_at <= this`, i.e. every live reader can now physically reach it —
9343    /// otherwise a reader whose read mark predates the materialization would lose the rows.
9344    pub fn compute_min_reader_mark(&self) -> WalPos {
9345        self.txs
9346            .iter()
9347            .filter_map(|entry| {
9348                let tx = entry.value();
9349                match tx.state.load() {
9350                    TransactionState::Active | TransactionState::Preparing(_) => Some(tx.read_mark),
9351                    _ => None,
9352                }
9353            })
9354            .min()
9355            .unwrap_or(WalPos::STAGED)
9356    }
9357
9358    /// GC floor for a freshly *created* btree: while a reader's read mark predates the binding's
9359    /// `materialized_at` (the creation frame) it cannot reach the btree and relies on the version
9360    /// store. Incremental re-materialization of existing rows is handled precisely per-version via
9361    /// [`RowVersion::materialized_at`] in [`Self::gc_version_chain`], not here.
9362    fn rootpage_gc_protected(&self, table_id: &MVTableId, min_reader_mark: WalPos) -> bool {
9363        self.table_id_to_rootpage
9364            .get(table_id)
9365            .is_some_and(|e| e.value().materialized_at > min_reader_mark)
9366    }
9367
9368    pub fn tx_should_abort(&self, tx_id: u64) -> bool {
9369        if let Some(tx) = self.txs.get(&tx_id) {
9370            tx.value().abort_now.load(Ordering::Acquire)
9371        } else {
9372            false
9373        }
9374    }
9375}
9376
9377fn rollback_row_version(tx_id: u64, rv: &mut RowVersion) {
9378    if rv.begin() == Some(TxTimestampOrID::TxID(tx_id)) {
9379        // If the transaction has aborted,
9380        // it marks all its new versions as garbage and sets their Begin
9381        // and End timestamps to infinity to make them invisible
9382        // See section 2.4: https://www.cs.cmu.edu/~15721-f24/papers/Hekaton.pdf
9383        rv.set_begin(None);
9384        rv.set_end(None);
9385    } else if rv.end() == Some(TxTimestampOrID::TxID(tx_id)) {
9386        // undo deletions by this transaction
9387        rv.set_end(None);
9388    }
9389}
9390
9391impl RowidAllocator {
9392    /// Lock-free rowid allocation via atomic CAS.
9393    /// Returns None only when at i64::MAX (triggers random fallback).
9394    /// Returns Some((new_rowid, prev_rowid)) where prev_rowid is None if table was empty.
9395    pub fn get_next_rowid(&self) -> Option<(i64, Option<i64>)> {
9396        loop {
9397            let cur = self.max_rowid.load(Ordering::SeqCst);
9398            if cur == i64::MAX {
9399                tracing::trace!("get_next_rowid(max)");
9400                return None;
9401            }
9402            let next = cur + 1;
9403            if self
9404                .max_rowid
9405                .compare_exchange(cur, next, Ordering::SeqCst, Ordering::SeqCst)
9406                .is_ok()
9407            {
9408                let prev = if cur == 0 { None } else { Some(cur) };
9409                tracing::trace!("get_next_rowid({next})");
9410                return Some((next, prev));
9411            }
9412        }
9413    }
9414
9415    /// Bump the counter to at least `rowid`. Used for user-specified rowids
9416    /// (e.g. INSERT INTO t(rowid,...) VALUES(1000,...)).
9417    pub fn insert_row_id_maybe_update(&self, rowid: i64) {
9418        loop {
9419            let cur = self.max_rowid.load(Ordering::SeqCst);
9420            if rowid <= cur {
9421                return;
9422            }
9423            if self
9424                .max_rowid
9425                .compare_exchange(cur, rowid, Ordering::SeqCst, Ordering::SeqCst)
9426                .is_ok()
9427            {
9428                return;
9429            }
9430        }
9431    }
9432
9433    pub fn is_uninitialized(&self) -> bool {
9434        !self.initialized.load(Ordering::SeqCst)
9435    }
9436
9437    /// Initialize from btree max. Called once per table, under lock.
9438    pub fn initialize(&self, rowid: Option<i64>) {
9439        tracing::trace!("initialize({rowid:?})");
9440        let _ = self
9441            .max_rowid
9442            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |cur| {
9443                let next = match rowid {
9444                    // max_rowid starts at 0, but a B-tree whose largest rowid is
9445                    // negative still needs to seed automatic allocation from that value.
9446                    Some(rowid) if cur == 0 => rowid,
9447                    Some(rowid) => cur.max(rowid),
9448                    None => cur,
9449                };
9450                (next != cur).then_some(next)
9451            });
9452        self.initialized.store(true, Ordering::SeqCst);
9453    }
9454
9455    pub fn lock(&self) -> bool {
9456        self.lock.write()
9457    }
9458
9459    pub fn unlock(&self) {
9460        self.lock.unlock()
9461    }
9462}
9463
9464pub fn create_seek_range<K: Ord>(
9465    limit_boundary: Bound<K>,
9466    direction: IterationDirection,
9467) -> (Bound<K>, Bound<K>) {
9468    if direction == IterationDirection::Forwards {
9469        (limit_boundary, Bound::Unbounded)
9470    } else {
9471        (Bound::Unbounded, limit_boundary)
9472    }
9473}
9474
9475/// A write-write conflict happens when transaction T_current attempts to update a
9476/// row version that is:
9477/// a) currently being updated by an active transaction T_previous, or
9478/// b) was updated by an ended transaction T_previous that committed AFTER T_current started
9479/// but BEFORE T_previous commits.
9480///
9481/// "Suppose transaction T wants to update a version V. V is updatable
9482/// only if it is the latest version, that is, it has an end timestamp equal
9483/// to infinity or its End field contains the ID of a transaction TE and
9484/// TE’s state is Aborted"
9485/// Ref: https://www.cs.cmu.edu/~15721-f24/papers/Hekaton.pdf , page 301,
9486/// 2.6. Updating a Version.
9487fn is_write_write_conflict<A: ConcurrentAllocator>(
9488    txs: &SkipMap<TxID, Transaction<A>, BasicComparator, A>,
9489    finalized_tx_states: &SkipMap<TxID, TransactionState, BasicComparator, A>,
9490    tx: &Transaction<A>,
9491    rv: &RowVersion,
9492) -> bool {
9493    match rv.end() {
9494        Some(TxTimestampOrID::TxID(rv_end)) => {
9495            if rv_end == tx.tx_id {
9496                return false;
9497            }
9498            match lookup_tx_state(txs, finalized_tx_states, rv_end) {
9499                Some(TransactionState::Aborted) | Some(TransactionState::Terminated) => false,
9500                Some(TransactionState::Active)
9501                | Some(TransactionState::Preparing(_))
9502                | Some(TransactionState::Committed(_)) => true,
9503                None => {
9504                    tracing::debug!(
9505                        "is_write_write_conflict: missing tx {} for row version {:?}; treating as conflict",
9506                        rv_end,
9507                        rv
9508                    );
9509                    true
9510                }
9511            }
9512        }
9513        // A non-"infinity" end timestamp (here modeled by Some(ts)) functions as a write lock
9514        // on the row, so it can never be updated by another transaction.
9515        // Ref: https://www.cs.cmu.edu/~15721-f24/papers/Hekaton.pdf , page 301,
9516        // 2.6. Updating a Version.
9517        Some(TxTimestampOrID::Timestamp(_)) => true,
9518        None => false,
9519    }
9520}
9521
9522impl RowVersion {
9523    /// Construct a row version. `begin`/`end` are bit-packed internally.
9524    pub fn new(
9525        id: u64,
9526        begin: Option<TxTimestampOrID>,
9527        end: Option<TxTimestampOrID>,
9528        row: Row,
9529        btree_resident: bool,
9530    ) -> Self {
9531        Self {
9532            id,
9533            begin: crate::mvcc::database::PackedTs::pack(begin),
9534            end: crate::mvcc::database::PackedTs::pack(end),
9535            row,
9536            btree_resident,
9537            materialized_at: WalPos::ORIGIN,
9538        }
9539    }
9540
9541    /// WAL position at which this version's current state is materialized in the B-tree
9542    /// ([`WalPos::ORIGIN`] = not materialized). See the field doc.
9543    #[inline]
9544    pub(crate) fn materialized_at(&self) -> WalPos {
9545        self.materialized_at
9546    }
9547
9548    #[inline]
9549    pub(crate) fn set_materialized_at(&mut self, pos: WalPos) {
9550        self.materialized_at = pos;
9551    }
9552
9553    /// The begin timestamp/tx-id, or `None`.
9554    #[inline]
9555    pub fn begin(&self) -> Option<TxTimestampOrID> {
9556        self.begin.unpack()
9557    }
9558
9559    /// The end timestamp/tx-id, or `None`.
9560    #[inline]
9561    pub fn end(&self) -> Option<TxTimestampOrID> {
9562        self.end.unpack()
9563    }
9564
9565    #[inline]
9566    pub fn set_begin(&mut self, value: Option<TxTimestampOrID>) {
9567        self.begin = crate::mvcc::database::PackedTs::pack(value);
9568        // The version's state changed, so any prior B-tree materialization no longer reflects
9569        // it (over-resetting to ORIGIN is safe: it only delays GC, never reclaims early).
9570        self.materialized_at = WalPos::ORIGIN;
9571    }
9572
9573    #[inline]
9574    pub fn set_end(&mut self, value: Option<TxTimestampOrID>) {
9575        self.end = crate::mvcc::database::PackedTs::pack(value);
9576        // A delete (or any end change) means the B-tree no longer reflects this version's current
9577        // state until a checkpoint re-materializes it; the GC must not reclaim it before then.
9578        self.materialized_at = WalPos::ORIGIN;
9579    }
9580
9581    /// Replace `begin`/`end` references to `tx_id` with the committed
9582    /// timestamp `end_ts`. Shared by the chunked commit step
9583    /// (`step_rewrite_live_versions`) and the dropped-statement cleanup path
9584    /// (`rewrite_live_versions_for_committed_tx`) so the two cannot drift.
9585    #[inline]
9586    fn rewrite_txid_to_timestamp(&mut self, tx_id: TxID, end_ts: u64) {
9587        if let Some(TxTimestampOrID::TxID(rv_id)) = self.begin() {
9588            if rv_id == tx_id {
9589                self.set_begin(Some(TxTimestampOrID::Timestamp(end_ts)));
9590            }
9591        }
9592        if let Some(TxTimestampOrID::TxID(rv_id)) = self.end() {
9593            if rv_id == tx_id {
9594                self.set_end(Some(TxTimestampOrID::Timestamp(end_ts)));
9595            }
9596        }
9597    }
9598
9599    /// A row is visible to a transaction if:
9600    /// * Begin is visible to the transaction
9601    /// * End timestamp is not applicable yet, meaning deletion of row is not visible to this transaction
9602    fn is_visible_to<A: ConcurrentAllocator>(
9603        &self,
9604        tx: &Transaction<A>,
9605        txs: &SkipMap<TxID, Transaction<A>, BasicComparator, A>,
9606        finalized_tx_states: &SkipMap<TxID, TransactionState, BasicComparator, A>,
9607    ) -> bool {
9608        is_begin_visible(txs, finalized_tx_states, tx, self)
9609            && is_end_visible(txs, finalized_tx_states, tx, self)
9610    }
9611
9612    /// Check if this version indicates the B-tree row has been modified (updated or deleted).
9613    ///
9614    /// A version is "relevant" to a transaction if:
9615    /// 1. The version is fully visible (begin visible AND end visible), OR
9616    /// 2. The version has an end timestamp that indicates the row was deleted before/at the transaction's begin, OR
9617    /// 3. The current transaction itself has deleted/updated this row (end = current tx_id)
9618    ///
9619    /// This is used by dual-cursor to determine if a B-tree row should be shown or hidden.
9620    fn is_btree_invalidating_version<A: ConcurrentAllocator>(
9621        &self,
9622        tx: &Transaction<A>,
9623        txs: &SkipMap<TxID, Transaction<A>, BasicComparator, A>,
9624        finalized_tx_states: &SkipMap<TxID, TransactionState, BasicComparator, A>,
9625    ) -> bool {
9626        // If the version is fully visible, it invalidates the B-tree
9627        if self.is_visible_to(tx, txs, finalized_tx_states) {
9628            return true;
9629        }
9630
9631        // Check if this version represents a deletion/update that affects us
9632        match self.end() {
9633            Some(TxTimestampOrID::Timestamp(end_ts)) => {
9634                // Row was deleted at end_ts. If we started after end_ts, we shouldn't see it
9635                turso_assert!(
9636                    tx.begin_ts != end_ts,
9637                    "begin_ts and committed end_ts cannot be equal: txn timestamps are strictly monotonic"
9638                );
9639                tx.begin_ts > end_ts
9640            }
9641            Some(TxTimestampOrID::TxID(end_tx_id)) => {
9642                // Row is being deleted/updated by another transaction.
9643                // Consult the deleting tx's state so we don't race with the
9644                // post-commit rewrite that turns TxID(W) into Timestamp(W.end_ts).
9645                if end_tx_id == tx.tx_id {
9646                    return true;
9647                }
9648                match lookup_tx_state(txs, finalized_tx_states, end_tx_id) {
9649                    Some(TransactionState::Committed(committed_ts)) => {
9650                        // Same predicate as the Timestamp arm above.
9651                        tx.begin_ts > committed_ts
9652                    }
9653                    Some(TransactionState::Preparing(end_ts)) => {
9654                        // Hekaton speculative read: treat as if W will commit at
9655                        // its prepared end_ts. When we speculatively invalidate
9656                        // the B-tree row, register a commit dependency on W —
9657                        // for tombstones (begin=None) we are the only place that
9658                        // decides this, since `is_visible_to` short-circuits at
9659                        // `is_begin_visible` and never calls `is_end_visible`.
9660                        // If W aborts, we must cascade-abort to avoid letting
9661                        // the reader observe the row reappear.
9662                        let speculatively_invalidated = tx.begin_ts > end_ts;
9663                        if speculatively_invalidated {
9664                            register_commit_dependency(txs, tx, end_tx_id);
9665                        }
9666                        speculatively_invalidated
9667                    }
9668                    Some(TransactionState::Active) => false,
9669                    Some(TransactionState::Aborted) | Some(TransactionState::Terminated) => false,
9670                    None => false,
9671                }
9672            }
9673            None => false,
9674        }
9675    }
9676}
9677
9678/// Hekaton Section 2.7 — register-and-report protocol:
9679/// "To take a commit dependency on a transaction T2, T1 increments its
9680/// CommitDepCounter and adds its transaction ID to T2's CommitDepSet."
9681///
9682/// The lock on `commit_dep_set` serializes with the drain in commit/abort
9683/// resolution, preventing the race where we push an entry after the drain.
9684fn register_commit_dependency<A: ConcurrentAllocator>(
9685    txs: &SkipMap<TxID, Transaction<A>, BasicComparator, A>,
9686    dependent_tx: &Transaction<A>,
9687    depended_on_tx_id: TxID,
9688) {
9689    turso_assert!(
9690        dependent_tx.tx_id != depended_on_tx_id,
9691        "transaction cannot depend on itself"
9692    );
9693    let Some(depended_on) = txs.get(&depended_on_tx_id) else {
9694        // Transaction was already committed and removed from the map
9695        // (CommitEnd calls remove_tx after setting Committed and draining
9696        // CommitDepSet). Dependency is trivially resolved.
9697        return;
9698    };
9699    let depended_on = depended_on.value();
9700
9701    // Hold lock while checking state to serialize with the drain in
9702    // commit/abort postprocessing.
9703    let mut dep_set = depended_on.commit_dep_set.lock();
9704    match depended_on.state.load() {
9705        TransactionState::Preparing(_) => {
9706            // Increment counter BEFORE inserting into dep_set and BEFORE dropping
9707            // the lock. This prevents underflow: if we inserted first and
9708            // released the lock, the depended-on tx could drain the dep_set
9709            // and call fetch_sub before we increment, wrapping the counter
9710            // from 0 to u64::MAX. Only increment on first insertion (dedup).
9711            if dep_set.insert(dependent_tx.tx_id) {
9712                dependent_tx
9713                    .commit_dep_counter
9714                    .fetch_add(1, Ordering::AcqRel);
9715            }
9716            drop(dep_set);
9717            tracing::trace!(
9718                "register_commit_dependency: tx {} depends on tx {}",
9719                dependent_tx.tx_id,
9720                depended_on_tx_id
9721            );
9722        }
9723        TransactionState::Active => {
9724            turso_assert!(false, "a txn found dependent on active txn");
9725        }
9726        TransactionState::Committed(_) => {
9727            // Already committed — dependency trivially resolved.
9728        }
9729        TransactionState::Aborted | TransactionState::Terminated => {
9730            // Already aborted — cascade abort to dependent.
9731            drop(dep_set);
9732            dependent_tx.abort_now.store(true, Ordering::Release);
9733            tracing::trace!(
9734                "register_commit_dependency: tx {} must abort (dep tx {} aborted)",
9735                dependent_tx.tx_id,
9736                depended_on_tx_id
9737            );
9738        }
9739    }
9740}
9741
9742fn lookup_tx_state<A: ConcurrentAllocator>(
9743    txs: &SkipMap<TxID, Transaction<A>, BasicComparator, A>,
9744    finalized_tx_states: &SkipMap<TxID, TransactionState, BasicComparator, A>,
9745    tx_id: TxID,
9746) -> Option<TransactionState> {
9747    txs.get(&tx_id)
9748        .map(|entry| entry.value().state.load())
9749        .or_else(|| finalized_tx_states.get(&tx_id).map(|entry| *entry.value()))
9750}
9751
9752fn lookup_finalized_tx_state<A: ConcurrentAllocator>(
9753    finalized_tx_states: &SkipMap<TxID, TransactionState, BasicComparator, A>,
9754    tx_id: TxID,
9755) -> Option<TransactionState> {
9756    finalized_tx_states.get(&tx_id).map(|entry| {
9757        let state = *entry.value();
9758        turso_assert!(
9759            !matches!(
9760                state,
9761                TransactionState::Active | TransactionState::Preparing(_)
9762            ),
9763            "finalized_tx_states contains non-final state for tx {tx_id}: {state:?}"
9764        );
9765        state
9766    })
9767}
9768
9769fn is_begin_visible<A: ConcurrentAllocator>(
9770    txs: &SkipMap<TxID, Transaction<A>, BasicComparator, A>,
9771    finalized_tx_states: &SkipMap<TxID, TransactionState, BasicComparator, A>,
9772    tx: &Transaction<A>,
9773    rv: &RowVersion,
9774) -> bool {
9775    match rv.begin() {
9776        Some(TxTimestampOrID::Timestamp(rv_begin_ts)) => {
9777            turso_assert!(
9778                tx.begin_ts != rv_begin_ts,
9779                "begin_ts and committed rv_begin_ts cannot be equal: txn timestamps are strictly monotonic"
9780            );
9781            tx.begin_ts > rv_begin_ts
9782        }
9783        Some(TxTimestampOrID::TxID(rv_begin)) => {
9784            let visible = match txs.get(&rv_begin) {
9785                Some(tb_entry) => {
9786                    let tb = tb_entry.value();
9787                    let visible = match tb.state.load() {
9788                        TransactionState::Active => tx.tx_id == tb.tx_id && rv.end().is_none(),
9789                        TransactionState::Preparing(end_ts) => {
9790                            // Hekaton Table 1 / Section 2.5: speculative read of TB.
9791                            // If begin_ts > end_ts, the version would be visible once TB
9792                            // commits. Speculatively return true and register a dependency.
9793                            // Fixes partial commit visibility (Bug #8).
9794                            turso_assert!(
9795                                tx.tx_id != tb.tx_id,
9796                                "a txn cannot read its own row versions during prepare"
9797                            );
9798                            turso_assert!(
9799                                tx.begin_ts != end_ts,
9800                                "begin_ts and preparing end_ts cannot be equal: txn timestamps are strictly monotonic"
9801                            );
9802                            if tx.begin_ts > end_ts {
9803                                register_commit_dependency(txs, tx, rv_begin);
9804                                true
9805                            } else {
9806                                false
9807                            }
9808                        }
9809                        TransactionState::Committed(committed_ts) => {
9810                            turso_assert!(
9811                                tx.begin_ts != committed_ts,
9812                                "begin_ts and committed_ts cannot be equal: txn timestamps are strictly monotonic"
9813                            );
9814                            tx.begin_ts > committed_ts
9815                        }
9816                        TransactionState::Aborted => false,
9817                        TransactionState::Terminated => {
9818                            tracing::debug!(
9819                                "TODO: should reread rv's end field - it should have updated the timestamp in the row version by now"
9820                            );
9821                            false
9822                        }
9823                    };
9824                    tracing::trace!(
9825                        "is_begin_visible: tx={tx}, tb={tb} rv = {:?}-{:?} visible = {visible}",
9826                        rv.begin(),
9827                        rv.end()
9828                    );
9829                    visible
9830                }
9831                None => match lookup_finalized_tx_state(finalized_tx_states, rv_begin) {
9832                    Some(TransactionState::Committed(committed_ts)) => {
9833                        turso_assert!(
9834                            tx.begin_ts != committed_ts,
9835                            "begin_ts and committed_ts cannot be equal: txn timestamps are strictly monotonic"
9836                        );
9837                        tx.begin_ts > committed_ts
9838                    }
9839                    Some(TransactionState::Aborted) | Some(TransactionState::Terminated) => false,
9840                    Some(TransactionState::Active) | Some(TransactionState::Preparing(_)) => {
9841                        unreachable!(
9842                            "is_begin_visible: live tx {} missing from txs but present in finalized cache",
9843                            rv_begin
9844                        );
9845                    }
9846                    None => {
9847                        // Transaction was removed from the map after converting its TxID refs
9848                        // to Timestamps. The begin field should have been updated but we still
9849                        // see the stale TxID. Conservative fallback.
9850                        false
9851                    }
9852                },
9853            };
9854            visible
9855        }
9856        None => false,
9857    }
9858}
9859
9860fn is_end_visible<A: ConcurrentAllocator>(
9861    txs: &SkipMap<TxID, Transaction<A>, BasicComparator, A>,
9862    finalized_tx_states: &SkipMap<TxID, TransactionState, BasicComparator, A>,
9863    current_tx: &Transaction<A>,
9864    row_version: &RowVersion,
9865) -> bool {
9866    match row_version.end() {
9867        Some(TxTimestampOrID::Timestamp(rv_end_ts)) => current_tx.begin_ts < rv_end_ts,
9868        Some(TxTimestampOrID::TxID(rv_end)) => {
9869            let visible = match txs.get(&rv_end) {
9870                Some(other_tx_entry) => {
9871                    let other_tx = other_tx_entry.value();
9872                    let visible = match other_tx.state.load() {
9873                        // V's sharp mind discovered an issue with the hekaton paper which basically states that a
9874                        // transaction can see a row version if the end is a TXId only if it isn't the same transaction.
9875                        // Source: https://avi.im/blag/2023/hekaton-paper-typo/
9876                        TransactionState::Active => current_tx.tx_id != other_tx.tx_id,
9877                        // Hekaton Table 2: speculative ignore of TE. If end_ts < begin_ts,
9878                        // we speculatively ignore V (treat deletion as committed). Register a
9879                        // dependency in case TE aborts (then V should have been visible).
9880                        TransactionState::Preparing(end_ts) => {
9881                            turso_assert!(
9882                                current_tx.tx_id != other_tx.tx_id,
9883                                "a txn is reading itself while preparing"
9884                            );
9885                            let visible = current_tx.begin_ts < end_ts;
9886                            if !visible {
9887                                register_commit_dependency(txs, current_tx, rv_end);
9888                            }
9889                            visible
9890                        }
9891                        TransactionState::Committed(committed_ts) => {
9892                            current_tx.begin_ts < committed_ts
9893                        }
9894                        TransactionState::Aborted => true,
9895                        // Table 2 (Hekaton): Reread V's End field. In this codebase Terminated is only
9896                        // reachable from Aborted, and abort rollback resets end to None → visible.
9897                        TransactionState::Terminated => true,
9898                    };
9899                    tracing::trace!(
9900                        "is_end_visible: tx={current_tx}, te={other_tx} rv = {:?}-{:?}  visible = {visible}",
9901                        row_version.begin(),
9902                        row_version.end()
9903                    );
9904                    visible
9905                }
9906                None => match lookup_finalized_tx_state(finalized_tx_states, rv_end) {
9907                    Some(TransactionState::Committed(committed_ts)) => {
9908                        current_tx.begin_ts < committed_ts
9909                    }
9910                    Some(TransactionState::Aborted) | Some(TransactionState::Terminated) => true,
9911                    Some(TransactionState::Active) | Some(TransactionState::Preparing(_)) => {
9912                        unreachable!(
9913                            "is_end_visible: live tx {rv_end} missing from txs but present in finalized cache"
9914                        );
9915                    }
9916                    None => {
9917                        // Transaction was removed after converting its TxID refs to Timestamps.
9918                        // The end field should have been updated. Conservative fallback.
9919                        true
9920                    }
9921                },
9922            };
9923            visible
9924        }
9925        None => true,
9926    }
9927}
9928
9929impl<Clock: LogicalClock, A: ConcurrentAllocator> Debug for CommitState<Clock, A> {
9930    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9931        match self {
9932            Self::Initial => write!(f, "Initial"),
9933            Self::Commit { end_ts } => f.debug_struct("Commit").field("end_ts", end_ts).finish(),
9934            Self::WaitForDependencies { end_ts } => f
9935                .debug_struct("WaitForDependencies")
9936                .field("end_ts", end_ts)
9937                .finish(),
9938            Self::BuildLogRecord(ctx) => f.debug_tuple("BuildLogRecord").field(ctx).finish(),
9939            Self::BeginCommitLogicalLog { end_ts, log_record } => f
9940                .debug_struct("BeginCommitLogicalLog")
9941                .field("end_ts", end_ts)
9942                .field("log_record", log_record)
9943                .finish(),
9944            Self::UpgradeLogicalLogHeader { end_ts, log_record } => f
9945                .debug_struct("UpgradeLogicalLogHeader")
9946                .field("end_ts", end_ts)
9947                .field("log_record", log_record)
9948                .finish(),
9949            Self::WriteLogicalLog { end_ts, log_record } => f
9950                .debug_struct("WriteLogicalLog")
9951                .field("end_ts", end_ts)
9952                .field("log_record", log_record)
9953                .finish(),
9954            Self::FinishLogicalLogWrite { end_ts } => f
9955                .debug_struct("FinishLogicalLogWrite")
9956                .field("end_ts", end_ts)
9957                .finish(),
9958            Self::SyncLogicalLog { end_ts } => f
9959                .debug_struct("SyncLogicalLog")
9960                .field("end_ts", end_ts)
9961                .finish(),
9962            Self::EndCommitLogicalLog { end_ts } => f
9963                .debug_struct("EndCommitLogicalLog")
9964                .field("end_ts", end_ts)
9965                .finish(),
9966            Self::Checkpoint { state_machine: _ } => f.debug_struct("Checkpoint").finish(),
9967            Self::CommitEnd { end_ts } => {
9968                f.debug_struct("CommitEnd").field("end_ts", end_ts).finish()
9969            }
9970            Self::RewriteLiveVersions(ctx) => {
9971                f.debug_tuple("RewriteLiveVersions").field(ctx).finish()
9972            }
9973            Self::FinalizeCommit { end_ts } => f
9974                .debug_struct("FinalizeCommit")
9975                .field("end_ts", end_ts)
9976                .finish(),
9977        }
9978    }
9979}
9980
9981impl PartialOrd for RowID {
9982    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
9983        Some(self.cmp(other))
9984    }
9985}
9986
9987impl Ord for RowID {
9988    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
9989        // Make sure table id is first comparison so that we sort first by table_id and then by
9990        // rowid. Due to order of the struct, table_id is first which is fine but if we were to
9991        // change it we would bring chaos.
9992        match self.table_id.cmp(&other.table_id) {
9993            std::cmp::Ordering::Equal => self.row_id.cmp(&other.row_id),
9994            ord => ord,
9995        }
9996    }
9997}