Skip to content

Read the write-ahead log - #1

Open
fmguerreiro wants to merge 5 commits into
browserutils:masterfrom
fmguerreiro:wal-read
Open

Read the write-ahead log#1
fmguerreiro wants to merge 5 commits into
browserutils:masterfrom
fmguerreiro:wal-read

Conversation

@fmguerreiro

@fmguerreiro fmguerreiro commented Jul 28, 2026

Copy link
Copy Markdown

Problem

This package never opens the -wal file, so a database in WAL mode reads as of its last checkpoint. Anything written since is invisible.

I hit it as a missing GitHub cookie: gh image said "no session token found" for a Firefox that was logged in, because the cookie sat in a 754KB cookies.sqlite-wal. Chrome, Firefox and Epiphany all go through OpenFrom, so one fix covers all three.

What it does

Open, and OpenFrom when the reader has a Name() string, read committed pages out of the log beside the database.

Which frames count follows the spec: the salt matches the header, the checksum verifies, nothing past the last commit frame. Two more rejects: a page number of zero, which wal.c also rejects, and a page count too big for an int32.

Decisions worth your attention:

  • OpenFrom finds the log by asserting Name() string, not by taking a path. It only gets an io.ReadSeeker, so there is no path otherwise, and the assertion is what lets kooky pick this up unchanged, since it opens the real file in place. An explicit OpenFromPath(io.ReadSeeker, string) would avoid folding a live log into a deliberate snapshot copy, but nothing copies these files today, so I took the smaller change. Say the word if you want the explicit form.
  • A log that won't open is treated as absent. These files get read while their owner is running, so a permission denial says nothing about the main database. Same for a log SQLite would ignore: bad checksum, wrong format version, page-size mismatch, no commit frame. Once the log is open, read errors propagate instead.
  • The log's page 1 replaces the header, so it runs the same magic check the main file's page 1 did and may not change the page size. A zeroed page 1 otherwise arrives as page size zero and panics.
  • The header page count no longer sizes the page cache. A 1KB crafted -wal claiming MaxInt32 pages got Open OOM-killed.
  • Reads re-check the frame they land on. A checkpoint can restart the log mid-read, leaving an offset pointing at the wrong generation or past the end of the file. Both error out rather than return a wrong page. A restart that draws the same salt is not caught.

Behaviour change

Open, and OpenFrom with an *os.File, now return a different database image, with no opt-out. That's the point of the change, but it may want a release note.

Tests

testdata/wal.sqlite is a real database left mid-WAL: one row in the main file, three once the log applies. Opening it with the sqlite3 CLI checkpoints the log away and the fixture stops testing anything. TestOpenWithoutWAL catches that if it happens. Every other log is built in memory.


Both pre-existing on master, left alone here. Happy to file separately:

  • 64KB-page databases panic, WAL or not. The page size is stored as the sentinel 1 and PageSize() returns it as-is, so Open reads 1-byte pages.
  • A header recording a page size of zero divides by zero in OpenFrom.

A database in WAL mode keeps recent pages in a separate "-wal" file until a
checkpoint folds them in, so reading the main file alone returns the database
as of the last checkpoint. For a file belonging to a running application that
is arbitrarily stale: a row written seconds ago can be missing, and so can a
newer page 1, which leaves the header (user_version among it) out of date too.

Open, and OpenFrom when the reader exposes Name(), now index the log beside the
database and serve those pages from it. Frames are accepted while their salts
match the header and their checksums verify, and only frames up to the last
commit frame are applied, so a torn tail or an in-flight transaction is dropped
rather than read. A log SQLite would ignore (bad header checksum, mismatched
page size, no commit frame) is ignored here too, leaving the main file readable
on its own.

testdata/wal.sqlite was checkpointed after its first row: one row and
user_version 7 on its own, three rows and user_version 16 with its log applied.
The pager held the index and the file it points into as two fields set
together, cleared together, and with one guarded on the other to be
dereferenced. Nothing enforced that pairing. The file now lives on
walIndex, which drops a return value from openWAL and a branch from
pager.Delete.

Cleanup on a failed attachWAL moves to the call site, matching the init
path directly below it, and DbFile.Close now returns the error from
closing the log instead of discarding it.

In the tests, copyDB owns the temporary directory it copies into and
hands back a cleanup func, and assertRows replaces the open-and-compare
block that five of them repeated verbatim.
Open panics on a log whose page 1 is zeroed. The magic check in OpenFrom
runs against the main file's page 1, never against the log's replacement
for it, so a page size of zero reaches the pager and page.Kind() indexes
an empty buffer. Re-check the magic and the page size after the decode.

Three frame fields went unvalidated:

- a page number of zero, which wal.c rejects outright
- a format version other than 3007000, which would parse a future format
  with today's frame rules
- a commit page count above MaxInt32, which became a negative NumPage

A checkpoint can also restart the log between the scan and a later page
read, leaving an indexed offset pointing into another generation's frame.
Re-read the frame header on each page and check that the page number and
salt still match, so that errors instead of returning a wrong page.

Structural, no behaviour change: build the index before the pager so the
page count derives once, put the offsets and the file handle behind
walIndex methods, and pass the checksum word order as a binary.ByteOrder.

Each rejection has a test through readWALIndex. The new wal-grow fixture
covers a log that grows the database past the end of the main file, which
is the ordinary state of a database with a live writer.
Reading the page count from the log before building the pager, as the
previous commit does, also made it attacker-controlled at the point of
allocation: newPager hints its cache map with npages, so a 1KB crafted
-wal committing at MaxInt32 pages reaches make(map[int]page, 2147483647).
Open thrashes for 19s and is killed. Drop the hint. The cache only ever
holds the pages actually read, and the main file's header could already
reach the same call.

Tests: page 1 with the wrong magic, and page 1 changing the page size,
were each masked by whichever check fired first, and the log's
fall-through to the main file was never exercised at all, since every
fixture happens to log every page it commits. The three copies of the
checksum chain collapse to one helper taking the word order, which keeps
the property that a bug in walChecksum cannot cancel itself out.
A checkpoint restarts the log either by overwriting it under a new salt or,
for wal_checkpoint(TRUNCATE) and journal_size_limit, by emptying it. Only
the first was recognised. The second leaves every indexed offset past the
end of the file, so the read came back as a bare io.EOF and read like a
disk problem rather than the restart it is. A short read at an offset the
scan already reached can only mean the log shrank, so map it onto the same
error as a salt mismatch.

Also stop the pager cache comment implying a bound it does not have: lru is
appended to and never read, so nothing evicts.
@fmguerreiro
fmguerreiro marked this pull request as ready for review July 29, 2026 00:23
@fmguerreiro fmguerreiro changed the title Support WAL mode Read the write-ahead log Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant