Normative notes for the storage layer's page manager (RecursiveAI.OneLite.Storage.Pager). Sources: sqlite.org fileformat2, atomiccommit, lockingv3, tempfiles, pragma (synchronous, journal_mode, cache_size), rescode. The sync points that can suspend are listed in the suspend-point notes; the commit and recovery sequences actually observed on the reference shell are in durability.md.
What it owns
- One database file, its rollback journal and (for
ATTACH) a super-journal. Page numbers start at 1; page n is at byte(n-1) * pageSize. The lock-byte page (the page holding file offset0x40000000) is never read, written or allocated (fileformat2 §1.3). - The 100-byte database header (
DatabaseHeader): validation of every field before use, the change counter, the in-header database size and its validity rule (version-valid-forequals the change counter, otherwise the file length decides), reserved bytes per page, the freelist head and count. - The page cache (
PageFrames, clock eviction, pin counts, dirty list, soft size limit), the rollback journal (all five modes), hot-journal detection and playback, statement journals and savepoints,synchronousandcache_size. - The freelist (trunk and leaf pages) and the pointer-map pages of auto-vacuum databases. The B-tree layer decides what to free and move; the pager keeps the lists and the map consistent.
The step contract
Every operation that can touch storage returns PagerStatus:
Ok: finished; out-parameters are valid.Pending: the pager needs one I/O;PendingIodescribes it (Kind,Phase,File/Path,Offset,Data,Run). Run it (ExecuteSync(), orawait ExecuteAsync(ct)), which feeds the result back withComplete, and call the same operation again with the same arguments. Nothing observable has happened yet; no local state outlives aPending.Error:Errorholds the typedOneLiteException(result code, extended code, message,Detail). The pager is still consistent: callRollback.
Only one request is outstanding at a time. Calling any other operation while one is pending throws SQLITE_MISUSE; Abandon() drops the outstanding request. PagerOptions.YieldEvery = n (always compiled, option-driven, not only in Checked builds) turns every n-th cache hit into Pending(Yield), so every suspend point can be resumed in tests; Yield needs no I/O, call ExecuteSync() (a no-op) and re-enter.
The engine's temporary files (the sorter's runs, see engine.md) use the same slot: Pager.ExternalIo(phase, request, out completion) is the re-entry helper for them. It issues the request and returns false, or consumes the completion of the same phase and returns true. YieldEvery also injects a Yield before every n-th such request.
...Sync wrappers run the loop inline and throw Error; ...Async(ct) twins await the VFS. Wrappers exist for BeginRead, EndRead, BeginWrite, Commit, Rollback, Get, MakeWritable, Allocate, Free and RollbackSavepoint.
Differences from the suspend-point spec (§3 of the design notes)
IoRequestcarries the file, path,Memory<byte>and run list directly instead of buffer-slot numbers;Closeis a plainDispose, not a request.- Statement journals and savepoints live in memory (no temp-file spill);
journal_mode=MEMORYkeeps the journal copies in memory too. Get(pgno > PageCount)isSQLITE_CORRUPT, as the spec says; a new, empty database gets page 1 lazily at the firstGet(1)of a write transaction.- Lock waits are
Sleeprequests driven byPagerOptions.Busy(retries)(returns milliseconds to sleep, or negative to give up; the default gives up at once). A hot-journal recovery that cannot reach EXCLUSIVE drops SHARED, sleeps and starts over, so two recoverers cannot deadlock. - Commit is
PrepareCommit(journal flushed/synced, EXCLUSIVE),WriteDatabase(dirty pages, truncate, sync),FinishCommit(retire the journal, the commit point);Commit()runs the three. The phases of spec §3.4 map onto them as 1-6, 7-9, 10-11. - Corruption:
OneLiteException.Messageis the reference text (database disk image is malformed,file is not a database); the page and what was wrong are inDetail. - A spill under a reader in rollback mode fails the statement with
SQLITE_BUSY(observed on the reference shell); it does not grow the cache. DELETEjournal mode also syncs the directory after the unlink atsynchronous=FULL(the reference does it only at EXTRA); seedurability.md.
Calls (what the B-tree layer uses)
| Call | Meaning |
|---|---|
Pager.Open(vfs, path, options) / OpenAsync | opens the database file (never creates a journal). PagerOptions: PageSize, ReservedBytes, Encoding, AutoVacuum, IncrementalVacuum, CacheSize (pages if positive, KiB if negative; default -2000), JournalMode, Synchronous, FullFsync, ReadOnly, Busy, YieldEvery. |
BeginRead() / EndRead() | takes SHARED, recovers a hot journal if there is one, validates page 1, flushes the cache if another process changed the file. EndRead is only legal in a read transaction (not inside a write transaction: commit or roll back instead). |
BeginWrite() | RESERVED lock; requires a read transaction (SQLITE_MISUSE otherwise). The journal is created on the first page that needs it. |
Get(pgno, GetOptions, out frame) | pins a page (PageFrame; frame.Data is a byte[] of one page). GetOptions.NoContent skips the read for a page about to be wholly overwritten. pgno outside 1..PageCount (or the lock-byte page) is SQLITE_CORRUPT. A hit allocates nothing. |
Release(frame) | unpins. |
MakeWritable(frame) | call before every modification batch (cheap when nothing is left to do). Journals the original, saves it for open savepoints, marks dirty. |
Allocate(out frame) | a fresh zeroed, writable, pinned page: from the freelist, else by growing the file (skipping the lock-byte page and pointer-map pages). |
Free(pgno) | puts the page on the freelist. The caller must have released its own pin first. |
ReservePages(allocations, frees), ReservePtrmap(pgno) | the prefetch half of reserve-then-effect: see "Reserve, then effect" below. |
IsPtrmapPage, PtrmapPageOf, PtrmapGet, PtrmapPut | pointer-map entries (auto-vacuum). |
LargestRootPage, SetLargestRootPage(page) | header offset 52. |
SetRelocator(relocator) | auto-vacuum relocation at commit: see "Auto-vacuum at commit" below. |
OpenSavepoint(), ReleaseSavepoint(level), RollbackSavepoint(level) | savepoints and statement journals; level 1 is the outermost. |
Commit() / Rollback() | end the write transaction (Rollback also ends a transaction after Error). HasChanges tells whether a write transaction modified anything. |
PrepareCommit(superJournal), WriteDatabase(), FinishCommit() | the phases of a commit; MultiCommit.CommitSync(pagers) drives several pagers with a super-journal (atomiccommit §5). |
SetSynchronous, SetJournalMode, SetCacheSize, TrySetPageSize(size, reserved) | pragma settings. The first two are refused inside a write transaction; the page size can only change while the database has no pages. |
PageSize, UsableSize, ReservedBytes, PageCount, AutoVacuum, DatabaseEncoding, JournalMode, Synchronous, LockBytePage, LockLevel, InTransaction, InWriteTransaction, Error | state. |
PageFrame.Data is the page; the first 100 bytes of page 1 are the database header, edited through DatabaseHeader accessors after MakeWritable. Offsets 24, 28 and 92 (change counter, size in pages, version-valid-for) are stamped by the pager at commit.
A new database (zero-length file) gets its page 1 written by the pager at the first write: the header (library version number 3053004), plus an empty table-leaf page header at offset 100, so the B-tree layer starts from a valid empty schema table.
Reserve, then effect
The B-tree changes pages in an effect phase that must not suspend, so every suspension happens first, in a reserve phase. The pager's part of that contract:
- After
ReservePages(allocations, frees)returnsOk, the nextallocationscalls toAllocateandfreescalls toFreereturnOkand neverPending; so doesSetLargestRootPage. AfterReservePtrmap(pgno)returnsOk,PtrmapGet(pgno)andPtrmapPut(pgno, ...)never returnPending. A reserve call that returnsPendingis repeated with the same arguments, like any other pager call. - How it holds: page 1, the freelist trunks the calls can touch (those that cover the allocations, and one more for the frees that follow) and, in an auto-vacuum database, the pointer-map pages of every page they can hand out are loaded and stay cached, because while reserving the cache grows past its soft limit instead of evicting. Free frames are set aside for the pages that will be allocated, so the effect phase evicts nothing, reads nothing and cannot spill. The rollback journal is opened if this transaction will journal pages, and its buffer is made roomy enough for every original page the calls can save, so the effect phase never writes.
- Calls made before the first effect add up (two
ReservePagescalls reserve for both). An effect completing (Allocate,FreeorPtrmapPutreturningOk) ends the phase; the next reserve call counts afresh. An effect that allocates, frees and writes no pointer-map entry (an insert that fits its leaf) ends the phase through the internalEndReservePhase, which the B-tree calls (viaIBtPageSource.EndEffect) when an insert or delete has finished; otherwise the promises of every such operation would add up for the whole transaction, and so would the spare frames. - What the caller must do: a page it will pass to
Freemust be in the cache, so it has to be pinned across the reserve call (it just read or wrote it, and holds the pin until it releases it forFree) unless it is already saved in the journal. A pointer-map entry needs its ownReservePtrmap. Pages beyond the end of the file need none (their pointer-map page is created in memory). - Costs: the reserve may write journal records or grow the journal buffer, and the cache may stay larger than
cache_sizeuntil pages are evicted later. The reserve covers the records the allocator itself saves; pages the B-tree will edit are made writable by the B-tree in its own reserve phase (MakeWritableof a cached page that is already journaled never suspends).
Auto-vacuum at commit
In a full auto-vacuum database (auto-vacuum on, incremental flag off) the file has no free pages after a commit (fileformat2 §1.7). The pager drives this as the first phase of a commit, before the header is stamped, if a relocator is installed with SetRelocator; without one the free pages stay and the file is valid but larger.
- Read the whole freelist (trunks and leaves, validated against the header's count and for duplicates).
- The final size is the pages in use plus the pointer-map pages and the lock-byte page that lie among them, never ending on one of those.
- From the last page down to the final size: a free leaf or the lock-byte page is dropped; a pointer-map page or a free trunk is saved in the journal and dropped; an in-use page is looked up in the pointer map and moved into the lowest free page at or below the final size. A root page above the final size is
SQLITE_CORRUPT(roots are never moved). - A move is: save the old page in the journal, copy its bytes verbatim to the new page (journaled if it was a trunk), write the new page's pointer-map entry (same type and parent), then call
IPageRelocator.Relocate(pager, oldPage, newPage, type, parent), which repairs every reference to the old page and the pointer-map entries of the moved page's children (it usesGet,MakeWritableandPtrmapPut; its documentation lists what each type needs). It may returnPendingand is then called again with the same arguments. - The header's freelist trunk and count become 0, the database size becomes the final size, and the commit truncates the file as usual.
A free leaf below the final size that nothing moved into, or a page the pointer map calls free that is not on the freelist, is SQLITE_CORRUPT. Crash safety is the ordinary commit's: everything cut off is in the journal, and a rolled-back free leaf's content is not restored (the format leaves it open). Incremental vacuum (PRAGMA incremental_vacuum) will reuse the same machinery; it is not wired yet.
The WAL seam
IPageSource (TryLocate(pgno, out file, out offset)) is consulted by Get before the database file. A WAL layer installs one with SetPageSource at BeginRead; with none installed the pager reads the database file. WAL mode is implemented in the pager itself (wal.md); SetJournalMode(Wal) throws SQLITE_MISUSE, and ChangeJournalMode(Wal) is the way in (and out).
Not here
B-tree cells, records, overflow content, schema. Cursor logic. WAL and -shm.