• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

joaoh82 / rust_sqlite / 25630593094

10 May 2026 01:57PM UTC coverage: 67.119% (+0.5%) from 66.605%
25630593094

push

github

web-flow
feat(engine): Phase 11.3 MvStore skeleton + PRAGMA journal_mode (SQLR-22) (#124)

* feat(engine): Phase 11.3 MvStore skeleton + PRAGMA journal_mode (SQLR-22)

Third slice of Phase 11 (concurrent writes via MVCC + BEGIN
CONCURRENT). Adds the in-memory version index (`MvStore`) and the
per-database `journal_mode` toggle so 11.4 can wire the executor's
read/write paths through a typed switch rather than a lazy-init
dance.

New `sqlrite::mvcc::store`:
- `RowID = (table, rowid)`, `VersionPayload = Present(cols) |
  Tombstone`, `RowVersion { begin, end, payload }`,
  `RowVersionChain = Vec<RowVersion>` behind `Arc<RwLock<…>>`.
- `MvStore` itself: `Mutex<HashMap<RowID, Arc<RwLock<…>>>>` plus a
  shared `MvccClock` and `ActiveTxRegistry`. The outer mutex
  guards the map's shape; the per-chain RwLock means readers
  walking different chains don't fight.
- `MvStore::read(row, begin_ts)` implements the textbook
  snapshot-isolation rule (`begin <= T < end`). Pure helper
  `visible_at(version, begin_ts)` exposed for tests + 11.6's GC.
- `push_committed` validates monotonic begin + caps the previous
  latest's `end` (validate-then-mutate; equal-begin retries
  surface as NonMonotonicBegin instead of leaving a half-capped
  chain). `push_in_flight` for tests + 11.4's BEGIN CONCURRENT
  writes; in-flight versions invisible to other readers.
- `MvStoreError` enum: `NotCommitted`, `PreviousAlreadyCapped`,
  `PreviousCappedByInFlight`, `NonMonotonicBegin`.

New `JournalMode { Wal, Mvcc }` enum (default Wal). `Database`
gains `journal_mode`, `mvcc_clock: Arc<MvccClock>`, `mv_store:
MvStore` fields — all allocated on `Database::new` even in Wal
mode so the toggle is cheap. `set_journal_mode` rejects
`Mvcc → Wal` while the store carries committed versions (would
silently strand them; v0 keeps this strict).

`PRAGMA journal_mode;` (read) renders the current mode; `PRAGMA
journal_mode = wal | mvcc;` (set) toggles. Case-insensitive on
both pragma name and value; numeric val... (continued)

281 of 306 new or added lines in 6 files covered. (91.83%)

9994 of 14890 relevant lines covered (67.12%)

1.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.76
/src/sql/db/database.rs
1
use crate::error::{Result, SQLRiteError};
2
use crate::mvcc::{JournalMode, MvStore, MvccClock};
3
use crate::sql::db::table::Table;
4
use crate::sql::pager::pager::{AccessMode, Pager};
5
use std::collections::HashMap;
6
use std::path::PathBuf;
7
use std::sync::Arc;
8

9
/// Snapshot of the mutable in-memory state taken at `BEGIN` time so
10
/// `ROLLBACK` can restore it. See `begin_transaction`, `rollback_transaction`.
11
/// `tables` is deep-cloned (the `Table::deep_clone` helper reallocates
12
/// the `Arc<Mutex<_>>` row storage so snapshot and live state don't
13
/// share a map).
14
#[derive(Debug)]
15
pub struct TxnSnapshot {
16
    pub(crate) tables: HashMap<String, Table>,
17
}
18

19
/// Default fraction of free pages that triggers an auto-VACUUM after
20
/// a page-releasing DDL (DROP TABLE / DROP INDEX / ALTER TABLE DROP
21
/// COLUMN). Matches SQLite's classic 25% heuristic. Override per
22
/// connection with [`Database::set_auto_vacuum_threshold`] (or
23
/// `Connection::set_auto_vacuum_threshold`); pass `None` to disable.
24
pub const DEFAULT_AUTO_VACUUM_THRESHOLD: f32 = 0.25;
25

26
/// The database is represented by this structure.assert_eq!
27
#[derive(Debug)]
28
pub struct Database {
29
    /// Name of this database. (schema name, not filename)
30
    pub db_name: String,
31
    /// HashMap of tables in this database
32
    pub tables: HashMap<String, Table>,
33
    /// If `Some`, every committing SQL statement auto-flushes the DB to
34
    /// this path. `None` → transient in-memory mode (the default; the
35
    /// REPL only enters persistent mode after `.open FILE`).
36
    pub source_path: Option<PathBuf>,
37
    /// Long-lived pager attached when the database is file-backed. Keeps
38
    /// an in-memory snapshot of every page so auto-saves can diff
39
    /// against the last-committed state and skip rewriting unchanged
40
    /// pages. `None` means "in-memory only" or "not yet opened".
41
    pub pager: Option<Pager>,
42
    /// Active transaction state (Phase 4f). `Some` between `BEGIN` and
43
    /// the matching `COMMIT` / `ROLLBACK`. While set:
44
    /// - auto-save is suppressed (mutations stay in-memory)
45
    /// - nested `BEGIN` is rejected
46
    /// - `ROLLBACK` restores `tables` from the snapshot
47
    pub txn: Option<TxnSnapshot>,
48
    /// Auto-VACUUM trigger (SQLR-10). After a page-releasing DDL
49
    /// (DROP TABLE / DROP INDEX / ALTER TABLE DROP COLUMN) commits and
50
    /// flushes, if the freelist exceeds this fraction of `page_count`
51
    /// the engine quietly compacts the file. `None` disables the
52
    /// trigger; defaults to `Some(DEFAULT_AUTO_VACUUM_THRESHOLD)`
53
    /// (SQLite parity at 25%). Per-connection runtime state — not
54
    /// persisted across reopens.
55
    pub auto_vacuum_threshold: Option<f32>,
56
    /// Phase 11.3 — current journal mode for the database. Default
57
    /// is [`JournalMode::Wal`] (every pre-Phase-11 caller). Toggled
58
    /// by `PRAGMA journal_mode = mvcc | wal`. The setting is
59
    /// per-database (every `Connection` to this `Database` observes
60
    /// the same value) — see the open question in
61
    /// [`docs/concurrent-writes-plan.md`](../../../docs/concurrent-writes-plan.md)
62
    /// §8 for the per-connection vs. per-database trade-off; v0
63
    /// picked per-database for simplicity.
64
    pub journal_mode: JournalMode,
65
    /// Phase 11.3 — process-wide MVCC clock. Shared between every
66
    /// `Connection` to this `Database` (and 11.4's `MvStore`).
67
    /// Seeded from the WAL header's `clock_high_water` at open
68
    /// time so timestamps don't repeat across reopens. Allocated
69
    /// here even in `JournalMode::Wal` so `PRAGMA journal_mode =
70
    /// mvcc` doesn't require lazy-creating the clock.
71
    pub mvcc_clock: Arc<MvccClock>,
72
    /// Phase 11.3 — in-memory version index. Allocated on every
73
    /// `Database::new` so the toggle to MVCC mode doesn't require
74
    /// a re-init step. Empty until 11.4 wires the commit path to
75
    /// publish row versions; reads still go through the legacy
76
    /// path until then.
77
    pub mv_store: MvStore,
78
}
79

80
impl Database {
81
    /// Creates an empty in-memory `Database`.
82
    ///
83
    /// # Examples
84
    ///
85
    /// ```
86
    /// use sqlrite::Database;
87
    /// let mut db = Database::new("my_db".to_string());
88
    /// ```
89
    pub fn new(db_name: String) -> Self {
2✔
90
        let mvcc_clock = Arc::new(MvccClock::new(0));
4✔
91
        let mv_store = MvStore::new(Arc::clone(&mvcc_clock));
4✔
92
        Database {
93
            db_name,
94
            tables: HashMap::new(),
2✔
95
            source_path: None,
96
            pager: None,
97
            txn: None,
98
            auto_vacuum_threshold: Some(DEFAULT_AUTO_VACUUM_THRESHOLD),
×
99
            journal_mode: JournalMode::default(),
2✔
100
            mvcc_clock,
101
            mv_store,
102
        }
103
    }
104

105
    /// Phase 11.3 — current journal mode. Toggled by `PRAGMA
106
    /// journal_mode = mvcc | wal`. `Wal` (the default) keeps every
107
    /// pre-Phase-11 caller's behaviour; `Mvcc` opts the database
108
    /// into MVCC + `BEGIN CONCURRENT` (Phase 11.4 wires this end-to-
109
    /// end; today the toggle is observable but the read/write
110
    /// paths don't change).
111
    pub fn journal_mode(&self) -> JournalMode {
1✔
112
        self.journal_mode
1✔
113
    }
114

115
    /// Phase 11.3 — switch the database's journal mode. `Wal → Mvcc`
116
    /// is unconditional in v0 (no in-flight transactions to drain
117
    /// because nothing publishes versions yet). `Mvcc → Wal` is
118
    /// rejected if `mv_store` carries any committed versions —
119
    /// switching back would silently strand them. v0 keeps this
120
    /// strict; the loosening (and the discard-versions path) lands
121
    /// when 11.4 starts populating the store.
122
    pub fn set_journal_mode(&mut self, mode: JournalMode) -> Result<()> {
1✔
123
        if self.journal_mode == mode {
1✔
NEW
124
            return Ok(());
×
125
        }
126
        if mode == JournalMode::Wal && self.mv_store.total_versions() > 0 {
2✔
NEW
127
            return Err(SQLRiteError::General(
×
NEW
128
                "PRAGMA journal_mode: cannot switch back to 'wal' while \
×
NEW
129
                 the MVCC store holds committed versions"
×
NEW
130
                    .to_string(),
×
131
            ));
132
        }
133
        self.journal_mode = mode;
1✔
134
        Ok(())
1✔
135
    }
136

137
    /// Phase 11.3 — the shared MVCC logical clock. Returned by
138
    /// reference (not cloned) because callers typically just read
139
    /// `now()` / `tick()` against the same `Arc` `Database` already
140
    /// holds.
NEW
141
    pub fn mvcc_clock(&self) -> &Arc<MvccClock> {
×
NEW
142
        &self.mvcc_clock
×
143
    }
144

145
    /// Phase 11.3 — the in-memory version index. Read-only access
146
    /// is enough for 11.3's tests; 11.4 grows the commit-path
147
    /// helpers into typed methods on `Database` rather than mutating
148
    /// this directly.
NEW
149
    pub fn mv_store(&self) -> &MvStore {
×
NEW
150
        &self.mv_store
×
151
    }
152

153
    /// Returns the current auto-VACUUM threshold, or `None` if disabled.
154
    /// See [`Database::set_auto_vacuum_threshold`] for semantics.
155
    pub fn auto_vacuum_threshold(&self) -> Option<f32> {
1✔
156
        self.auto_vacuum_threshold
1✔
157
    }
158

159
    /// Sets the auto-VACUUM threshold (SQLR-10). `Some(t)` with `t` in
160
    /// `0.0..=1.0` arms the trigger: after a page-releasing DDL
161
    /// commits, if the freelist exceeds `t * page_count` the engine
162
    /// runs a full-file compact. `None` disables the trigger. Values
163
    /// outside `0.0..=1.0` (or NaN / infinite) return a typed error
164
    /// rather than silently saturating.
165
    pub fn set_auto_vacuum_threshold(&mut self, threshold: Option<f32>) -> Result<()> {
1✔
166
        if let Some(t) = threshold {
1✔
167
            if !t.is_finite() || !(0.0..=1.0).contains(&t) {
2✔
168
                return Err(SQLRiteError::General(format!(
1✔
169
                    "auto_vacuum_threshold must be in 0.0..=1.0, got {t}"
×
170
                )));
171
            }
172
        }
173
        self.auto_vacuum_threshold = threshold;
1✔
174
        Ok(())
1✔
175
    }
176

177
    /// Returns true if the database contains a table with the specified key as a table name.
178
    ///
179
    pub fn contains_table(&self, table_name: String) -> bool {
2✔
180
        self.tables.contains_key(&table_name)
2✔
181
    }
182

183
    /// Returns an immutable reference of `sql::db::table::Table` if the database contains a
184
    /// table with the specified key as a table name.
185
    ///
186
    pub fn get_table(&self, table_name: String) -> Result<&Table> {
2✔
187
        if let Some(table) = self.tables.get(&table_name) {
7✔
188
            Ok(table)
2✔
189
        } else {
190
            Err(SQLRiteError::General(String::from("Table not found.")))
2✔
191
        }
192
    }
193

194
    /// Returns an mutable reference of `sql::db::table::Table` if the database contains a
195
    /// table with the specified key as a table name.
196
    ///
197
    pub fn get_table_mut(&mut self, table_name: String) -> Result<&mut Table> {
2✔
198
        if let Some(table) = self.tables.get_mut(&table_name) {
6✔
199
            Ok(table)
2✔
200
        } else {
201
            Err(SQLRiteError::General(String::from("Table not found.")))
×
202
        }
203
    }
204

205
    /// Returns `true` if this database is attached to a file and that
206
    /// file was opened in [`AccessMode::ReadOnly`]. In-memory databases
207
    /// (no pager) and read-write file-backed databases both return
208
    /// `false`. Callers use this to reject mutating SQL at the
209
    /// dispatcher level so the in-memory tables don't drift away from
210
    /// disk on a would-be INSERT / UPDATE / DELETE.
211
    pub fn is_read_only(&self) -> bool {
2✔
212
        self.pager
2✔
213
            .as_ref()
214
            .is_some_and(|p| p.access_mode() == AccessMode::ReadOnly)
6✔
215
    }
216

217
    /// Returns `true` while a `BEGIN … COMMIT`/`ROLLBACK` block is open.
218
    pub fn in_transaction(&self) -> bool {
2✔
219
        self.txn.is_some()
2✔
220
    }
221

222
    /// Starts a transaction: snapshots every table deep-cloned so that
223
    /// a later `rollback_transaction` can restore the pre-BEGIN state.
224
    /// Nested transactions are rejected — explicit savepoints are not
225
    /// on this phase's roadmap. Errors on a read-only database.
226
    pub fn begin_transaction(&mut self) -> Result<()> {
1✔
227
        if self.in_transaction() {
1✔
228
            return Err(SQLRiteError::General(
1✔
229
                "cannot BEGIN: a transaction is already open".to_string(),
1✔
230
            ));
231
        }
232
        if self.is_read_only() {
1✔
233
            return Err(SQLRiteError::General(
1✔
234
                "cannot BEGIN: database is opened read-only".to_string(),
1✔
235
            ));
236
        }
237
        let snapshot = TxnSnapshot {
238
            tables: self
1✔
239
                .tables
240
                .iter()
241
                .map(|(k, v)| (k.clone(), v.deep_clone()))
242
                .collect(),
243
        };
244
        self.txn = Some(snapshot);
2✔
245
        Ok(())
1✔
246
    }
247

248
    /// Drops the transaction snapshot and returns it for the caller to
249
    /// discard. The in-memory `tables` state is the new committed state;
250
    /// the caller is responsible for flushing to disk via the pager.
251
    /// Errors if no transaction is open.
252
    pub fn commit_transaction(&mut self) -> Result<()> {
1✔
253
        if self.txn.is_none() {
1✔
254
            return Err(SQLRiteError::General(
×
255
                "cannot COMMIT: no transaction is open".to_string(),
×
256
            ));
257
        }
258
        self.txn = None;
2✔
259
        Ok(())
1✔
260
    }
261

262
    /// Restores `tables` from the transaction snapshot and clears it.
263
    /// Errors if no transaction is open.
264
    pub fn rollback_transaction(&mut self) -> Result<()> {
1✔
265
        let Some(snapshot) = self.txn.take() else {
1✔
266
            return Err(SQLRiteError::General(
1✔
267
                "cannot ROLLBACK: no transaction is open".to_string(),
1✔
268
            ));
269
        };
270
        self.tables = snapshot.tables;
2✔
271
        Ok(())
1✔
272
    }
273
}
274

275
#[cfg(test)]
276
mod tests {
277
    use super::*;
278
    use crate::sql::dialect::SqlriteDialect;
279
    use crate::sql::parser::create::CreateQuery;
280
    use sqlparser::parser::Parser;
281

282
    #[test]
283
    fn new_database_create_test() {
4✔
284
        let db_name = String::from("my_db");
1✔
285
        let db = Database::new(db_name.to_string());
2✔
286
        assert_eq!(db.db_name, db_name);
2✔
287
    }
288

289
    #[test]
290
    fn contains_table_test() {
3✔
291
        let db_name = String::from("my_db");
1✔
292
        let mut db = Database::new(db_name.to_string());
2✔
293

294
        let query_statement = "CREATE TABLE contacts (
1✔
295
            id INTEGER PRIMARY KEY,
296
            first_name TEXT NOT NULL,
297
            last_name TEXT NOT NULl,
298
            email TEXT NOT NULL UNIQUE
299
        );";
300
        let dialect = SqlriteDialect::new();
1✔
301
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
1✔
302
        if ast.len() > 1 {
2✔
303
            panic!("Expected a single query statement, but there are more then 1.")
×
304
        }
305
        let query = ast.pop().unwrap();
2✔
306

307
        let create_query = CreateQuery::new(&query).unwrap();
2✔
308
        let table_name = &create_query.table_name;
1✔
309
        db.tables
2✔
310
            .insert(table_name.to_string(), Table::new(create_query));
3✔
311

312
        assert!(db.contains_table("contacts".to_string()));
1✔
313
    }
314

315
    #[test]
316
    fn get_table_test() {
3✔
317
        let db_name = String::from("my_db");
1✔
318
        let mut db = Database::new(db_name.to_string());
2✔
319

320
        let query_statement = "CREATE TABLE contacts (
1✔
321
            id INTEGER PRIMARY KEY,
322
            first_name TEXT NOT NULL,
323
            last_name TEXT NOT NULl,
324
            email TEXT NOT NULL UNIQUE
325
        );";
326
        let dialect = SqlriteDialect::new();
1✔
327
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
1✔
328
        if ast.len() > 1 {
2✔
329
            panic!("Expected a single query statement, but there are more then 1.")
×
330
        }
331
        let query = ast.pop().unwrap();
2✔
332

333
        let create_query = CreateQuery::new(&query).unwrap();
2✔
334
        let table_name = &create_query.table_name;
1✔
335
        db.tables
2✔
336
            .insert(table_name.to_string(), Table::new(create_query));
3✔
337

338
        let table = db.get_table(String::from("contacts")).unwrap();
1✔
339
        assert_eq!(table.columns.len(), 4);
1✔
340

341
        let table = db.get_table_mut(String::from("contacts")).unwrap();
1✔
342
        table.last_rowid += 1;
1✔
343
        assert_eq!(table.columns.len(), 4);
2✔
344
        assert_eq!(table.last_rowid, 1);
1✔
345
    }
346
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc