- docs/db-sqlite-reference.md (32KB): pager FSM (7 states, 15 transitions), WAL concurrency model, ~50 VDBE opcodes, B-Tree cell layout, 37 formal invariants, notable CVEs, formal verification precedents - docs/blog-07-prove-sqlite.md (1,164 words): framing blog post, all 6 sections
32 KiB
SQLite Pager, WAL, VDBE, and B-Tree Reference
Author: Marcus (agent-cos) cos@marcus.local Date: 2026-05-09 Purpose: Engineering source material for SpeckDL formal verification Target: SQLite version 3.53.1 (check-in c88b22011a54b, 2026-05-05)
1. Pager State Machine
The pager is SQLite's storage engine — manages page cache, transactions, and crash recovery. Located in src/pager.c (~12K lines). The pager owns the lock state, journal state, and page cache.
1.1 Pager State Enum (from pager.c header comment)
PAGER_UNLOCK (0) — No lock held. Initial state.
PAGER_SHARED (1) — Read lock (SQLITE_LOCK_SHARED). Read-only access.
PAGER_RESERVED (2) — Reserved lock (SQLITE_LOCK_RESERVED). Preparing to write.
PAGER_EXCLUSIVE (3) — Exclusive lock (SQLITE_LOCK_EXCLUSIVE). Actively writing.
PAGER_SYNCED (4) — All dirty pages written + synced. Journal can be deleted.
PAGER_ERROR (5) — Error occurred. No further operations allowed.
PAGER_OPEN (6) — Special state: connection open but no lock. Transitional.
1.2 State Transition Table
| From | To | Trigger / Guard | Side Effects |
|---|---|---|---|
| PAGER_OPEN | PAGER_SHARED | First pager_acquire() / pager_begin() |
Obtain SQLITE_LOCK_SHARED |
| PAGER_UNLOCK | PAGER_SHARED | sqlite3PagerSharedLock() | Obtain shared lock, recover hot journal if present |
| PAGER_SHARED | PAGER_UNLOCK | pager_unlock() / end of read tx |
Release shared lock |
| PAGER_SHARED | PAGER_RESERVED | pager_write() or pager_begin() |
Obtain SQLITE_LOCK_RESERVED. No other writer can proceed. |
| PAGER_RESERVED | PAGER_EXCLUSIVE | First page modification (in PAGER_RESERVED) |
- |
| PAGER_RESERVED | PAGER_EXCLUSIVE | Commit begins | Obtain PENDING then EXCLUSIVE lock |
| PAGER_RESERVED | PAGER_SHARED | ROLLBACK | Discard changes, release reserved lock |
| PAGER_EXCLUSIVE | PAGER_SYNCED | All dirty pages written + fsynced | Journal header updated to sync state |
| PAGER_SYNCED | PAGER_SHARED (journal-mode) or PAGER_UNLOCK (WAL) | Delete journal file (COMMIT point) | Transaction atomically committed |
| PAGER_EXCLUSIVE | PAGER_ERROR | I/O error, disk full, etc. | errCode set, hot journal preserved |
| PAGER_SYNCED | PAGER_ERROR | I/O error during sync | errCode set, journal preserved for recovery |
| any → | PAGER_ERROR | Any I/O error during pager operation | Further ops blocked |
| PAGER_ERROR | PAGER_OPEN | pager_unlock() forced close |
Resets connection but keeps db open |
1.3 Journal Mode Variants
Three rollback journal modes (non-WAL):
- DELETE (default): Journal file deleted on commit. If crash before delete → hot journal → recovery rolls back.
- TRUNCATE: Journal file truncated to zero length on commit. Faster on some filesystems than delete.
- PERSIST: Journal header zeroed on commit, file left on disk. Slightly faster, but leaves journal files around.
The pager tracks journalMode and adjusts the commit-delete step accordingly.
1.4 Pager Invariants (to formally specify)
- I1 (Lock State Consistency):
eStatemust be consistent with actual file lock held. IfeState==PAGER_SHARED, must holdSQLITE_LOCK_SHARED. IfeState==PAGER_EXCLUSIVE, must holdSQLITE_LOCK_EXCLUSIVE. - I2 (Dirty Page Tracking): For any page in the cache,
isDirtyiff the page has been modified since last checkpoint/journal write. - I3 (Journal Existence): The journal file exists iff
eState >= PAGER_RESERVEDandeState != PAGER_ERROR(except during recovery). - I4 (Hot Journal Detection): A journal is "hot" iff it exists but the database was not cleanly closed. On first shared lock, hot journals must be rolled back.
- I5 (Error Propagation): Once
eState == PAGER_ERROR, all subsequent pager operations must return the storederrCode. - I6 (Read-Only Pager): If
readOnlyflag is set,eStatemust never exceedPAGER_SHARED. - I7 (Sync Before Commit): No dirty page is persisted to the database file until after the journal is synced to disk AND the exclusive lock is held.
- I8 (Commit Atomicity): The commit point is the deletion of the journal file. Before this: crash → rollback. After this: crash → committed state visible.
1.5 Lock Escalation Sequence (from atomiccommit.html)
NO_LOCK → SHARED (read) → RESERVED (intent to write) → PENDING (block new readers)
→ EXCLUSIVE (writing) → no lock (after commit journal deletion)
Key subtlety: PENDING lock allows existing readers to finish but blocks new readers. This prevents writer starvation in the face of constant new read connections.
2. WAL Mode State Machine
WAL mode inverts the rollback journal: original data stays in the database file, changes are appended to the WAL file. Commit = appending a commit record to WAL.
2.1 WAL Files
| File | Purpose | Lifecycle |
|---|---|---|
X-wal |
Write-ahead log. Sequential append of frames. | Exists while connections are open. Deleted on clean close. |
X-shm |
Shared memory (wal-index). Hash table for frame location. | mmap'd. Not fsynced. Omitted under EXCLUSIVE locking mode. |
X |
Main database file. Contains original + checkpointed pages. | Persistent. |
2.2 WAL-Index Header Fields (from walformat.html)
| Field | Bytes | Description |
|---|---|---|
| iVersion | 0-3 | WAL-index format version (3007000) |
| iChange | 8-11 | Transaction counter, incremented per tx |
| isInit | 12 | 1 when shm file initialized |
| bigEndCksum | 13 | Checksum endianness flag |
| szPage | 14-15 | Page size (or 1 for 65536) |
| mxFrame | 16-19 | Number of valid commit frames in WAL |
| nPage | 20-23 | Database size in pages |
| aFrameCksum | 24-31 | Checksum of last frame |
| aSalt | 32-39 | Two salt values from WAL header |
| nBackfill | 96-99 | Frames already backfilled by checkpoints |
| read-mark[0..4] | 100-119 | Five reader end marks |
| nBackfillAttempted | 128-132 | Frames attempted but not confirmed backfilled |
2.3 WAL Locks
| Lock | Byte | Purpose |
|---|---|---|
| WAL_WRITE_LOCK | 120 | Exclusive: appending frames to WAL |
| WAL_CKPT_LOCK | 121 | Exclusive: running checkpoint |
| WAL_RECOVER_LOCK | 122 | Exclusive: running recovery |
| WAL_READ_LOCK(0) | 123 | Reader end-marks (0-4) |
| WAL_READ_LOCK(1) | 124 | |
| WAL_READ_LOCK(2) | 125 | |
| WAL_READ_LOCK(3) | 126 | |
| WAL_READ_LOCK(4) | 127 |
2.4 WAL Frame Lifecycle
Frame format (in WAL file): [page-number] [db-size-after-commit] [salt-1] [salt-2] [checksum-1] [checksum-2]
followed by page data.
Frame lifecycle states:
1. WRITER appends frames to end of WAL file (holding WAL_WRITE_LOCK exclusive)
2. Last frame of a transaction marks "commit" via non-zero db-size field
3. READERS snapshot mxFrame at transaction start → their "end mark"
4. CHECKPOINTER copies frames back to database (holding WAL_CKPT_LOCK), increments nBackfill
5. RESET: When nBackfill == mxFrame and no reader holds WAL_READ_LOCK(N) for N>0,
writer rewinds WAL to beginning (resets mxFrame, nBackfill to 0)
2.5 Reader Snapshot (End Mark) Protocol
- When a read transaction begins, the reader notes current
mxFramevalue → this is its "end mark" - The reader acquires one of WAL_READ_LOCK(0..4) to declare its end mark
- To read page P, the reader calls
FindFrame(P, end_mark): find the latest frame for page P in WAL with frame number ≤ end_mark - If found: read from WAL frame. If not found: read from database file.
- Readers hold WAL_READ_LOCK(N) for the duration of their read transaction
- This prevents checkpointer from overwriting pages the reader still needs
2.6 Checkpoint States
| Checkpoint Mode | Behavior |
|---|---|
| PASSIVE | Non-blocking. Works around readers. May not complete. Default. |
| FULL | Blocks until checkpoint completes or a writer appears. More aggressive. |
| RESTART | Full checkpoint then blocks until no readers. Ensures WAL reset. |
| TRUNCATE | Like RESTART, also truncates WAL file to zero bytes. |
Checkpoint progress constraint: MUST STOP at the minimum end_mark across all active readers.
nBackfill ≤ min(read_mark[0..4])nBackfillAttempted ≥ nBackfill(may be ahead if reader blocked a backfill)
2.7 WAL Reset Condition
The WAL is reset (wound back to start) when:
nBackfill == mxFrame(all frames backfilled)- No reader holds
WAL_READ_LOCK(N)for any N > 0 (no readers using WAL) - Writer holds WAL_WRITE_LOCK exclusively
- On reset:
mxFrame = 0,nBackfill = 0, file position rewound
2.8 WAL Invariants (to formally specify)
- W1 (mxFrame Monotonic):
mxFrameis non-decreasing except during WAL reset. - W2 (nBackfill Bound):
nBackfill ≤ mxFramealways. - W3 (Backfill ≤ Readers):
nBackfillAttempted ≤ min(read_mark[0..4])for any checkpoint that respects readers. - W4 (Frame Validity): All frames 1..mxFrame have valid checksums. Frames > mxFrame are undefined.
- W5 (Commit Detection): Frame k is a commit frame iff its db-size-after-commit field ≠ 0.
- W6 (Read Consistency): A reader with end-mark M sees exactly the database state as of the last commit frame ≤ M.
- W7 (WAL Write Serialization): Only one connection may append to WAL at a time (WAL_WRITE_LOCK exclusive).
- W8 (Checkpoint Isolation): Checkpoint must not overwrite pages needed by any active reader (nBackfill ≤ min(read_marks)).
- W9 (Salt Match): WAL file header salt values must match
aSaltin the WAL-index header. Mismatch → WAL is stale → must recover. - W10 (WAL Reset Safety): WAL may only be reset when
nBackfill == mxFrameAND no reader locks exist.
3. VDBE Instruction Set (Key Opcodes)
The VDBE is SQLite's bytecode virtual machine. Each prepared statement is a bytecode program. Source: vdbe.c (~8K lines). 192 opcodes in version 3.53.1.
3.1 Instruction Format
Each instruction: [opcode] P1 P2 P3 P4 P5
P1, P2, P3: 32-bit signed integers (registers, cursor numbers, jump targets)
P4: various (int, string, function pointer, collation)
P5: 16-bit unsigned flags
3.2 Transaction & Locking Opcodes
| Opcode | P1 | P2 | P4 | Effect |
|---|---|---|---|---|
| Transaction | iDb | write_flag | Begin a transaction on database P1. P2=1 for write, 0 for read. Opens pager transaction. | |
| AutoCommit | autocommit_flag | rollback_flag | Set auto-commit on/off. If P2 set, rollback first. Causes VM halt. | |
| Checkpoint | iDb | mode | Run checkpoint on database P1. P2 = PASSIVE(0)/FULL(1)/RESTART(2)/TRUNCATE(3). | |
| Savepoint | start_flag | savepoint_name | Begin/Release/Rollback a savepoint. | |
| TableLock | iDb | root_page | table_name | Lock a table before modifying. write_flag in P3. |
| SchemaLock | iDb | schema_name | Lock schema objects. |
3.3 Cursor Opcodes
| Opcode | P1 | P2 | P3 | P4 | Effect |
|---|---|---|---|---|---|
| OpenRead | cursor | root_page | iDb | Open cursor P1 for read on root page P2 of db P3 | |
| OpenWrite | cursor | root_page | iDb | table_name | Open cursor P1 for write on root page P2 of db P3 |
| OpenAutoindex | cursor | key_cols | nCol | Open ephemeral auto-index cursor | |
| OpenEphemeral | cursor | nCol | Open temporary table, auto-clears on close | ||
| Close | cursor | Close cursor P1 | |||
| SeekGT | cursor | jump_target | key_reg | Seek cursor P1 to first entry > key in register | |
| SeekGE | cursor | jump_target | key_reg | Seek cursor P1 to first entry >= key in register | |
| SeekLT | cursor | jump_target | key_reg | Seek cursor P1 to first entry < key in register | |
| SeekLE | cursor | jump_target | key_reg | Seek cursor P1 to first entry <= key in register | |
| SeekRowid | cursor | Seek cursor P1 to entry with given rowid | |||
| NotFound | cursor | jump_target | Jump if key not found in index | ||
| Found | cursor | jump_target | Jump if key found in index | ||
| Next | cursor | jump_target | Advance cursor P1 to next entry. Jump to P2 if no more rows. | ||
| Prev | cursor | jump_target | Move cursor P1 to previous entry. Jump to P2 if at beginning. | ||
| Last | cursor | Move cursor P1 to last entry. | |||
| Rewind | cursor | jump_target | Move to first entry. Jump to P2 if empty. | ||
| Column | cursor | column_idx | dest_reg | Read column P2 from cursor P1 into register P3 | |
| Rowid | cursor | dest_reg | Write rowid of cursor P1 into register P2 | ||
| IdxInsert | cursor | key | Insert entry into index cursor | ||
| IdxDelete | cursor | key | Delete entry from index cursor | ||
| Insert | cursor | Insert current record into table cursor | |||
| Delete | cursor | Delete current record from table cursor | |||
| SorterSort | cursor | Sort sorter cursor P1 | |||
| SorterData | cursor | dest_reg | Read current sorter row |
3.4 Value & Register Opcodes
| Opcode | P1 | P2 | P3 | Effect |
|---|---|---|---|---|
| Integer | value | dest | Store integer value in register P2 (P1 is the value) | |
| Real | value (P4) | dest | Store real value in register P2 | |
| String8 | dest | string | Store UTF-8 string in register P2. Self-modifying → becomes String opcode. | |
| Null | start | count | Set P2 registers starting at P1 to NULL | |
| Blob | size | dest | data | Store P1-byte blob in register P2 |
| Copy | src | dest | count | Copy P3 registers from P1 to P2 |
| Move | src | dest | count | Move P3 registers from P1 to P2 (clear source) |
| SCopy | src | dest | Soft copy (shallow) register P1 to P2 | |
| ResultRow | reg_start | nReg | Pause VM: emit result row from registers P1..P1+P2-1. sqlite3_step() returns SQLITE_ROW. | |
| Yield | pc_store | Coroutine yield. Swap PC with value in register P1. | ||
| Halt | p1 | p2 | p3 | Halt execution. If P1==SQLITE_OK and P2==OE_Abort, auto-rollback. |
3.5 Arithmetic & Comparison Opcodes
| Opcode | P1 | P2 | P3 | P4 | Effect |
|---|---|---|---|---|---|
| Add | r1 | r2 | result | r[P3] = r[P1] + r[P2] | |
| Subtract | r1 | r2 | result | r[P3] = r[P1] - r[P2] | |
| Multiply | r1 | r2 | result | r[P3] = r[P1] * r[P2] | |
| Divide | r1 | r2 | result | r[P3] = r[P1] / r[P2] | |
| Remainder | r1 | r2 | result | r[P3] = r[P1] % r[P2] | |
| Eq | r1 | r2 | jump | coll | Compare r[P1] and r[P2]. Jump to P3 if equal. |
| Ne | r1 | r2 | jump | coll | Jump if not equal |
| Lt | r1 | r2 | jump | coll | Jump if r[P1] < r[P2] |
| Le | r1 | r2 | jump | coll | Jump if r[P1] ≤ r[P2] |
| Gt | r1 | r2 | jump | coll | Jump if r[P1] > r[P2] |
| Ge | r1 | r2 | jump | coll | Jump if r[P1] ≥ r[P2] |
| BitAnd | r1 | r2 | result | r[P3] = r[P1] & r[P2] | |
| BitOr | r1 | r2 | result | r[P3] = r[P1] | r[P2] | |
| BitNot | src | dest | r[P2] = ~r[P1] | ||
| Concat | r1 | r2 | result | r[P3] = r[P1] || r[P2] |
3.6 Control Flow Opcodes
| Opcode | P1 | P2 | Effect |
|---|---|---|---|
| Goto | target | Unconditional jump to P2 | |
| Gosub | return_reg | target | Push PC to register P1, jump to P2 |
| Return | return_reg | Jump to address stored in P1 + 1 | |
| If | jump_target | dest | Conditional jump |
| IfNot | jump_target | dest | Conditional jump (negated) |
| IfNull | reg | jump_target | Jump if register contains NULL |
| NotNull | reg | jump_target | Jump if register is not NULL |
| IfPos | reg | jump_target | Jump if register > 0 |
| IfNeg | reg | jump_target | Jump if register < 0 |
| Once | flag | NOP if flag was set. Execute body exactly once. | |
| Init | flags | max_addr | First opcode. Sets trace flags. Self-modifying: increments P1 each invocation. |
| Program | sub_program_addr | Invoke a subprogram (trigger). | |
| Param | param_idx | dest_reg | Read parameter from caller into destination register. |
3.7 Aggregate Function Opcodes
| Opcode | P1 | P2 | P3 | Effect |
|---|---|---|---|---|
| AggStep | arg_reg | accum | Execute xStep for aggregate function | |
| AggStep1 | step/inverse | arg_reg | accum | Execute xStep (P1=0) or xInverse (P1!=0) |
| AggInverse | arg_reg | accum | Execute xInverse (window functions) | |
| AggFinal | accum | Finalize aggregate, store result | ||
| AggValue | result | Store current aggregate value |
3.8 VDBE Invariants (to formally specify)
- V1 (PC Bounds): Program counter must remain within [0, nOp] during execution.
- V2 (Register Type Safety): Each register holds exactly one type at a time (NULL, int, real, text, blob, RowSet, Frame, Undefined).
- V3 (Cursor Validity): An opcode must not reference a cursor unless it was previously opened (OpenRead/OpenWrite/OpenEphemeral) and not yet closed.
- V4 (Transaction Guard): Before any write opcode executes, a transaction must be open. Read opcodes may run outside a transaction but must obtain shared lock.
- V5 (ResultRow Protocol): ResultRow must be preceded by opcodes that fill the output registers. sqlite3_step() returns SQLITE_ROW; subsequent call resumes after ResultRow.
- V6 (Halt Termination): Execution terminates only via Halt opcode, PC exceeding last opcode address, or error.
- V7 (Error Cleanup): On error or HALT with error, all open cursors are closed, memory freed, and active transactions rolled back.
- V8 (Subprogram Isolation): Each Program invocation gets its own register set. Params are the only communication channel to the caller.
- V9 (Coroutine Symmetry): Yield stores PC to register; subsequent Yield reads register to PC. Coroutine execution must return to same point.
4. B-Tree Invariants
SQLite uses B+tree for tables, B-tree for indices. Source: btree.c.
4.1 Page Types
| Type | Value | Description |
|---|---|---|
| Interior index | 0x02 | Index b-tree interior page |
| Interior table | 0x05 | Table b-tree interior page |
| Leaf index | 0x0a | Index b-tree leaf page |
| Leaf table | 0x0d | Table b-tree leaf page |
4.2 Page Layout
Page header:
[page_type:1] [first_freeblock:2] [cell_count:2] [cell_content_start:2] [fragmented_free_bytes:1]
Followed by:
[right_child_pointer:4] (interior pages only)
[cell_pointer_array: cell_count * 2 bytes] — offsets to cells, in key order
[unallocated space in middle]
[cells: packed from end of page backwards]
4.3 B-Tree Cell Format
Table leaf cell:
[payload_size: varint] [rowid: varint] [payload: header + columns]
Table interior cell:
[left_child_page:4] [rowid: varint] [payload: partial row data]
Index leaf cell:
[payload_size: varint] [payload: header + index columns + rowid]
Index interior cell:
[left_child_page:4] [payload_size: varint] [payload: header + index columns + rowid]
4.4 Key Ordering Properties
- Table B-Tree: Ordered by rowid (integer primary key). Interior cells store the rowid of the first entry in each child page.
- Index B-Tree: Ordered by indexed column(s) + rowid. Interior cells store the key of the first entry in each child page.
- All keys within a page are in ascending order.
- For any interior page: all keys in child subtree i are ≤ the key at position i in the interior page, and ≤ all keys in child subtree i+1. (For indices, strict < for key columns, ≤ for rowid as tiebreaker.)
4.5 Page Split Condition
A page splits when inserting a cell would exceed the usable page space.
- A new page is allocated.
- Cells are distributed (roughly half each) between old and new page.
- Parent interior page gets a new cell pointing to the new page.
4.6 Page Merge Condition
When deleting, if a page's content falls below the minimum fill threshold (roughly 25-50% depending on implementation), the page may be merged with a sibling:
- Cells are moved to sibling page.
- Parent pointer to the empty page is removed.
- Empty page is added to freelist.
4.7 B-Tree Cursor Operations
Cursor state: { Btree*, root page, current page, cell index, cache of page stack }
Operations:
sqlite3BtreeCursor(): Create cursor on B-tree identified by root page + flags
sqlite3BtreeMoveto(): Position cursor to entry that matches key (or nearest)
sqlite3BtreeNext(): Advance cursor. Page crossing handled automatically.
sqlite3BtreePrevious(): Move cursor backwards. Page crossing handled.
sqlite3BtreeInsert(): Insert cell into current position
sqlite3BtreeDelete(): Delete entry at cursor position
4.8 B-Tree Invariants (to formally specify)
- B1 (Page Type Consistency): A page's type field must match its role. Interior pages reference child pages; leaf pages do not.
- B2 (Key Order Monotonic): For any interior page, keys in child page i are less than or equal to keys in child page i+1. Cells within a page are sorted ascending.
- B3 (Pointer Integrity): Every child page reference must point to a valid allocated page of the correct type (or overflow page, or freelist page).
- B4 (Cell Count Consistency):
cell_countin the page header must equal the number of entries in the cell pointer array. - B5 (Free Space Accounting):
fragmented_free_bytes+ unallocated space between cell pointer array and cells must sum correctly to the page's available free space. - B6 (Page Size Boundary): Usable page size = page_size - reserved_bytes. All cells must fit within the usable page.
- B7 (Root Page Integrity): The b-tree root page number must be a valid, allocated b-tree page. The schema table's root page mappings must be consistent.
- B8 (Balance Preserved): After every insert/delete operation, the b-tree must satisfy all internal structural invariants (fill ratio, parent-child pointer correctness, key ordering).
- B9 (Overflow Chain Integrity): If a cell's payload spills to overflow pages, the chain of overflow pages must: (a) form a proper linked list, (b) be bounded in length by the payload size, (c) contain no cycles.
- B10 (Freelist Consistency): Freelist pages must not be reachable from any b-tree root. Freelist trunk pages must point to valid leaf pages or 0.
5. Known Bugs and Formal Verification Precedents
5.1 Notable SQLite CVEs
| CVE | Year | Type | Module | Fixed In | Severity |
|---|---|---|---|---|---|
| CVE-2022-35737 | 2022 | Integer overflow → buffer overrun → RCE/DoS | printf/str_vappendf | 3.39.2 | High (7.5) |
| CVE-2025-3277 | 2025 | Write past end of malloc'd array via integer overflow | concat_ws() function | 3.49.1 | Moderate |
| CVE-2025-6965 | 2025 | Integer overflow → array bounds read | SQL injection path | 3.50.2 | Moderate |
| CVE-2024-0232 | 2024 | Use-after-free in JSON parser | JSON | 3.43.2 | Moderate |
| CVE-2023-7104 | 2023 | Corruption in session extension changeset processing | Session (ext) | 3.43.1 | Low |
| CVE-2025-29088 | 2025 | Crash via bad args to sqlite3_db_config | Config API | 3.49.1 | Low |
Key observation from SQLite's CVE page:
Almost all CVEs written against SQLite require the ability to inject and run arbitrary SQL. The core storage layer (pager, WAL, B-tree) has remarkably few logic bugs. Most CVEs are in SQL-level functions, extensions, or edge cases in the printf/logging subsystem.
5.2 22-Year-Old Integer Overflow (CVE-2022-35737)
Introduced October 2000 (version 1.0.12), discovered 2022 by Trail of Bits. Exploitable on 64-bit systems when large strings (>2GB) passed to printf functions with ! unicode flag. Arbitrary code execution possible without stack canaries. Not a pager/journal bug — but illustrates how old, untested edge cases survive.
5.3 Formal Verification Precedents
5.3.1 FoundationDB (Apple)
- What: Distributed transactional key-value store, formally verified in Flow (their deterministic simulation language)
- Method: Deterministic simulation testing — run all DB processes as fibers on a single thread with simulated network/disk, inject faults, check invariants. Run millions of random seeds nightly.
- Result: Found ~70 bugs before release. Flow-based simulation verified correctness of their distributed transaction, replication, and recovery protocols.
- Relevance to SQLite: The determinism approach parallels what could be done for SQLite's pager. SQLite is single-process by design, making deterministic replay easier.
5.3.2 TigerBeetle
- What: Distributed financial accounting database. VOPR (Viewstamped Operation Replicator) deterministic simulator.
- Method: Entire cluster runs on single thread. 1000x speed. Injects network, storage, and process faults. Swarm testing + fuzzer. Continuous verification.
- Relevance to SQLite: The VOPR approach of running the real implementation under controlled simulation is directly applicable to testing SQLite's recovery paths in the pager.
5.3.3 Amazon (DynamoDB, S3)
- What: TLA+ specifications for AWS distributed services
- Method: Model checking (TLC) + manual proofs (TLAPS). Found bugs in replication protocols, quorum membership, leader election.
- Paper: "Use of Formal Methods at Amazon Web Services" (Newcombe et al., 2014)
- Relevance to SQLite: TLA+ is ideal for specifying state machines like the pager's locking protocol. The pager state machine is small enough for tractable TLC model checking.
5.3.4 MongoDB Transactions (VLDB 2025)
- What: "Design and Modular Verification of Distributed Transactions in MongoDB"
- Method: TLA+ specification + model checking + test-case generation.
- Relevance to SQLite: Demonstrates that even complex transactional protocols can be verified with TLA+.
5.3.5 Formal Land's Rocq Translation of SQLite B-Tree (2025-2026)
- What: AI-assisted translation of SQLite C code to Rocq (Coq variant). Translation only (not full proof of equivalence).
- Result: "Still a big project to run with current technologies." Covered substantial part of B-tree but deemed too unstable for full verification at current AI capability levels.
- Relevance: Direct precedent that SQLite formal verification is hard. Suggests model-level verification (state machine specs) is more tractable than code-level proofs.
5.3.6 Richard Hipp's Own Attempt (SQLite Lead)
- Dr. Hipp reports spending "years" trying to write a formal design specification for SQLite but found it never added value and abandoned it. This is telling — both about the difficulty and about the value proposition.
5.4 SQLite's Existing Testing (Non-Formal)
| Method | Scale | Notes |
|---|---|---|
| TH3 (Test Harness #3) | Proprietary. Billions of test cases. | 100% branch coverage. Mutation testing (inject bugs, verify they're caught). |
| OSS-Fuzz | Continuous. | Google's fuzzing infrastructure. Catches memory safety bugs. |
| Release testing | ~100M tests per release | Run on multiple platforms |
| Anomaly testing | Random SQL/DB operations with crash injection | Verify recovery |
6. Target Invariants for SpeckDL Specification
Ranked by impact (what would be most impressive/valuable to prove):
Tier 1: Impact/Feasibility Sweet Spot
-
Pager Commit Atomicity (I8)
- Prove: For any sequence of pager operations and any crash point, the database file is always in either the pre-commit state (all changes rolled back) or the post-commit state (all changes applied). No partial-commit states.
- Value: This is the fundamental SQLite guarantee. Proving it formally would be the headline achievement.
- Feasibility: Medium. The pager state machine has only 5-6 states. The tricky part is modeling disk I/O reordering and crash-at-any-point semantics.
-
WAL Read Isolation (W6)
- Prove: A reader with end-mark M sees exactly the database state as of the last commit ≤ M. Writes after M are invisible.
- Value: WAL mode's key property. With formal proof, SQLite's WAL concurrency model gets a rigorous foundation.
- Feasibility: Medium. Need to model the WAL-index hash tables and the FindFrame algorithm.
-
Journal Recovery Correctness
- Prove: After a crash with a hot journal, recovery restores the database to its state before the incomplete transaction.
- Value: Crash recovery is the hardest case to test exhaustively. Formal proof would eliminate a whole class of concerns.
- Feasibility: High. Well-scoped problem with clear success criteria.
Tier 2: High Value, Higher Difficulty
-
B-Tree Balance Preservation (B8)
- Prove: After any sequence of insertions and deletions, the b-tree satisfies all structural invariants (page fill ratios, key ordering, pointer integrity, freelist consistency).
- Value: B-tree corruption is one of the most feared failure modes. Formal proof is the gold standard.
- Feasibility: Low-Medium. The B-tree is complex (~200KB of C code). A full proof is likely too big, but the core balancing algorithm could be specified and verified in isolation.
-
Lock Protocol Deadlock Freedom
- Prove: The lock escalation protocol (shared → reserved → pending → exclusive) is deadlock-free for any number of concurrent readers and writers.
- Value: Prevents a potential operational nightmare.
- Feasibility: High. Small state space. Ideal for TLC model checking.
-
WAL Checkpoint Safety (W8)
- Prove: Checkpoint never overwrites pages needed by active readers, even when readers and checkpointer operate concurrently.
- Value: WAL's concurrent reader+writer+checkpointer model is nontrivial. Getting it right formally is impressive.
- Feasibility: Medium. Requires modeling concurrent processes and the WAL-index header fields.
Tier 3: Ambitious / Future Work
-
Full Pager FSM Correctness (I1-I8)
- Prove all eight pager invariants from Section 1.4 simultaneously.
- Feasibility: Low (large specification, many interacting components). Best done incrementally.
-
VDBE Safety: No undefined register reads (V2)
- Prove that the code generator never produces bytecode that reads an Undefined register.
- Feasibility: Low. Requires modeling the SQL compiler's code generation, which is a much larger problem.
Recommendation: Start with Tier 1 (Items 1-3)
The pager commit atomicity + WAL read isolation + journal recovery form a coherent package:
- All three involve the pager module
- All three address crash-safety guarantees
- Together, they prove that "SQLite transactions are atomic even under arbitrary crash timing" — the single claim that matters most
- The state spaces are tractable for SpeckDL + model checking
7. Source References
| Source | Location |
|---|---|
| SQLite WAL documentation | https://www.sqlite.org/wal.html |
| SQLite Atomic Commit | https://www.sqlite.org/atomiccommit.html |
| SQLite Bytecode Engine | https://www.sqlite.org/opcode.html (192 opcodes, v3.53.1) |
| Database File Format | https://www.sqlite.org/fileformat2.html |
| WAL File Format | https://www.sqlite.org/walformat.html |
| Pager source | src/pager.c (~12K lines). States: PAGER_UNLOCK, SHARED, RESERVED, EXCLUSIVE, SYNCED, ERROR, OPEN |
| VDBE source | src/vdbe.c (~8K lines) + helper files |
| B-Tree source | src/btree.c |
| SQLite CVEs | https://sqlite.org/cves.html |
| Formal Land B-Tree translation | https://github.com/formal-land/rocq-of-db |
| FoundationDB verification | "FoundationDB: A Distributed, Unbundled, Transactional Key Value Store" (SIGMOD 2021) |
| Amazon formal methods | "Use of Formal Methods at Amazon Web Services" (2014) |
| MongoDB TLA+ | "Design and Modular Verification of Distributed Transactions in MongoDB" (VLDB 2025) |
| TigerBeetle VOPR | https://tigerbeetle.com/blog/2023-07-11-we-put-a-distributed-database-in-the-browser/ |
| CVE-2022-35737 (Trail of Bits) | https://blog.trailofbits.com/2022/10/25/sqlite-vulnerability-july-2022-library-api/ |
| Fly.io B-Tree internals | https://fly.io/blog/sqlite-internals-btree/ |
End of reference document. Ready for SpeckDL specification writing.