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(¤t_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(¤t_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}