1 How the write-ahead log works
Every transaction appends one frame per changed page to chat.db-wal. A frame is a 24-byte
header plus a complete copy of the page. The last frame of a transaction is marked as a commit frame. The
main file is not touched.
To read page P, SQLite looks for the newest valid frame that holds page P (the -shm file is an
index that makes this fast) and uses the main file only if there is none. A checkpoint copies the newest
version of each page back into the main file. After that the WAL is either truncated, or restarted: new
frames are written from the beginning again, over the old ones. Frames that are not overwritten stay in the file
and are recognised as old by their salts.
2 The WAL header: salts and checkpoints
The WAL header holds the page size and two salts. At every restart salt-1 is incremented and salt-2 gets a new random value. Every frame copies both salts into its own header, so a frame from an earlier generation of the log can be told apart. The checkpoint sequence counts the restarts: in the sample it is 2.
The two checksums are a running sum over 32-bit words. The header's checksum seeds a chain that continues
through every frame header (first 8 bytes) and page image, so each frame's checksum also vouches for all frames
before it. The magic number says which byte order the words are read in: 0x377F0682 means
little-endian, which is what ARM and x86 phones and PCs write.
3 Frames and which ones count
SQLite accepts a frame only if three things hold:
The table below applies these rules to the sample WAL. Click a frame number to jump to its hex view.
Frame 1 is a commit frame for page 2 (the messages table): its commit size says the database has 4
pages after this transaction.
Frame 2 is another version of page 2, written by a later transaction. It is the newest valid frame for page 2, so this is the page a normal query sees.
Frame 3 holds page 3 (contacts), but its salt-1 is one lower than the header's: it was written
before the last restart and survived because the new generation has only two frames so far. SQLite ignores it.
Its content still matters to you: a stale frame can hold an older version of a page than anything else in the
file. Here it happens to match the main file, because that version was checkpointed.
4 Recovering the deleted message
Put the three versions of page 2 side by side:
| Where | Rowids in the cells | Message 9 ("Meet me at the north entrance …") |
|---|---|---|
| Main file, page 2 (last checkpoint) | 1, 2, 4, 5, 7 | absent; never checkpointed |
| WAL frame 1 (older version) | 1, 2, 4, 5, 7, 8, 9 | live row, with sender and timestamp |
| WAL frame 2 (current version) | 1, 2, 4, 5, 7, 8 | deleted, text left in the unallocated space |
Sam sent two messages a minute apart. The second was deleted before any checkpoint, so the main file never saw it, and the current page only has its remains. Frame 1 still holds the complete row: contact, direction, timestamp and text. A normal query returns only message 8:
SELECT on the main file only vs. with the WAL (builder output)
WAL frame list (builder output)
Try this: in frame 1's page image, find cell 7 and read its sent_at value. Then find the same
text in frame 2: which field of the page header tells you that those bytes are no longer part of any cell? And
why does the deleted cell sit in the unallocated space rather than in a freeblock?
5 Pitfalls
- Checkpoints erase history. After a checkpoint the newest pages are in the main file and the WAL may be truncated or restarted. Opening the original database in a normal tool can trigger exactly that.
- Restarts overwrite from the front. The oldest surviving frames are at the end of the file, with old salts. Tools that stop at the first invalid frame never show them.
- Same page, many versions. A busy chat page may appear in dozens of frames. Compare versions by frame number, not by file offset, and say which one a value came from.
- The
-shmfile is a cache. SQLite can rebuild it from the WAL, but acquire and hash it anyway: it records which frames were in use at the moment of acquisition.
6 Try it yourself
Answer from the hex views and the frame table above. Numbers can be typed in decimal or hex (0x…).
At which byte offset of the WAL file does the header of frame 3 start?
Take the page size from the WAL header (offset 8, 4 bytes, big-endian) and use the formula in section 1.
WAL header bytes 8–11: 00 00 04 00 = 1,024. Each frame is 24 + 1,024 = 1,048
bytes.
32 + (3 − 1) × 1,048 = 2,128 = 0x850. Frame 3's hex view starts there.
Which 4 bytes does frame 3 store as its salt-1? Type them in file order.
Salt-1 is at offset 8 of the 24-byte frame header, after page number and commit size.
Frame 3 header at 0x850: 00 00 00 03 (page 3) |
00 00 00 04 (commit size) | 5A 17 00 01 (salt-1) | 0F 1E 2D 3C
(salt-2) | two checksums.
The WAL header has salt-1 5A 17 00 02: one higher, because the log was restarted once after
frame 3 was written.
In frame 2's page image, at which page offset does the cell content area begin?
The b-tree page header of a leaf page: type (1 byte), first freeblock (2), cell count (2), cell content start (2), fragmented bytes (1). The page image begins 24 bytes after the frame header.
Frame 2's page image starts 0D 02 F1 00 06 02 73 00. Cell content start =
02 73 = 627 (0x273).
In frame 1 the same field is 02 12 = 530: the cell of message 9 lay at page offsets 530–626.
In frame 2 those bytes are below the content start, i.e. unallocated, which is why the text is still there but
belongs to no cell.
Why does SQLite ignore frame 3?
Compare frame 3's commit size and salts with the WAL header, and check the "Frame status" row of its hex view.
Frame 3's commit size is 00 00 00 04, so it is a commit frame, and the
database has 4 pages. But its salts are 5A 17 00 01 / 0F 1E 2D 3C, while the header
has 5A 17 00 02 / 4B 5A 69 78. The frame belongs to an earlier generation of the
log, so SQLite stops before it.
These are the 24 bytes of frame 2's header (file offset 0x438). Edit them and watch the decoded
header below:
- Change the last byte of salt-1 (at
0x443) from02to01. The frame now looks stale. If this were the real file, which version of page 2 would SQLite read, and would message 9 be visible? - Reset, then set the commit size (last byte at
0x43F) from04to00. The frame is no longer a commit frame, and both checksums fail. - The salt edit left the checksums alone, the commit size edit broke them. Why? Which bytes of a frame does the checksum cover?