Repository navigation
Add internal/pagestore: page allocation, a free list, and crash-safe checkpoints - #10
Merged
Merged
Conversation
…checkpoints After 0x09 a node could be written as bytes, but nothing recorded where the tree starts, so after a restart a file of valid pages was useless. pagestore owns the file: it hands pages out and takes them back, grows the file in 16-page chunks, records the root in a meta page at page 0, and guarantees that a kill -9, a failed fsync or a power cut at any moment leaves a file that reopens to the last committed checkpoint. The meta page holds a NUL-terminated magic, a format version, the page size, the root, the free-list head and count, the next page to hand out, the pages the file holds, and a checkpoint generation. The magic carries no version, and the reader checks identity, then version, then everything else, so a newer file is reported as newer rather than as foreign or corrupt. Page 0 doubles as "no page", which is safe because page 0 can never be a root or a free page. The first draft threaded the free list through the free pages, as most tutorials do. A crash test showed it destroying the last committed tree: freeing the old root after checkpointing a new one, then crashing, left the durable meta pointing at a page Free had already overwritten. The rule that fixes it is that nothing the last durable checkpoint references may be touched until a new one is durable. Free now only marks a page pending, and the free list is written at each checkpoint into dedicated pages taken from pages already free in the durable state or by growing the file, never from pending pages or the current list pages. A checkpoint fsyncs data, writes the list, fsyncs, writes the meta page, and fsyncs again. Any failed fsync poisons the store, keeping the cause, because after fsyncgate a retry can report success having written nothing. Errors raised before anything durable is touched, such as running out of page ids, return plainly. Three bugs came from trusting the OS page cache as if it were the disk, and each has a fix. Open rewrites and fsyncs the meta it read before handing out any page, since that meta may exist only in the cache. Growth writes zeros for exactly the new pages from the store's own count, never from the file size the OS reports. Open and Create fsync the directory that really holds the file, since a file's name is cache state too. Resolving the path as a string disagreed with the kernel twice, through symlinks and past PATH_MAX, so fileio.SyncParent splits off the last component without cleaning, lets the kernel resolve the prefix, and follows only a final-component symlink itself. An all-zero file of at most one chunk is treated as an interrupted create. Open no longer creates a missing file. That is what turned every lost directory entry into silent data loss, an empty store returned with no error; now only Create makes files, and a missing database is an error. A directory fsync counts as done only when the filesystem says it cannot do one at all, as SQLite does. Tests use memFile, a fake disk that models a power cut keeping any subset of dirty writes, a failed fsync with fsyncgate semantics, a process restart that keeps the page cache, and eviction of pages marked clean but never written. FuzzCrashAnywhereKeepsTheLastCheckpoint checks after every step that every page the last checkpoint referenced holds its original bytes and every page is in exactly one state; it ran 2,436,886 executions. The demo kills a real child process twenty times and every run recovers. Independent review, with each finding proven by a failing test and reproduced by a separate skeptic, found eleven more bugs across four rounds, two of them in an earlier round's own fix; the fuzzer found one more in code a fix had touched. With the first draft's, that is thirteen, six of them data loss. One reviewer wrote an independent crash fuzzer from scratch and it found nothing. A mutation sweep then broke the code on purpose 563 different ways and found the suite missed 174 of them, a score of 68.9%. That drove a round of new tests: 81 hostile rewrites of a store's bytes on disk plus a fuzzer over arbitrary files; a fake file that fails or tears the Nth read, write, sync or size call at 251 points inside every operation; an independent oracle that decodes the simulated disk itself; each layer of Open's validation tested on its own; and a real kill -9 test, in which the test binary kills a copy of itself, so it runs in CI. Those tests found two more bugs, both failures that left the store not exactly as it was: Allocate did not roll back FilePages after growing the file, and Write marked the store dirty before writing. They also exposed weak tests that passed for the wrong reason. The score is now 93.1%, and each of the 39 remaining survivors was checked by hand: 17 change nothing observable, 15 are caught by a second layer of checks that is tested on its own, and 7 need an OS failure that cannot be produced on demand. Coverage of the storage packages rose from 79.7% to 93.5%. 238 tests, 14 fuzz targets, race clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Index storage manager
Why this is needed
After
0x09a B+Tree node can be written to disk as bytes. What a file stillcould not answer is the first question any reader asks after a restart: where
does the tree start? Nothing is in memory. A file holding millions of perfectly
formatted keys is useless if nothing says which page is the root.
This adds
internal/pagestore, which owns the file: it hands pages out, takesthem back, grows the file, records the root, and guarantees that a crash at any
moment — a
kill -9, a failedfsync, or a power cut — leaves a file thatreopens to the last committed state.
What is in the change
internal/pagestore/pagestore.gointernal/pagestore/describe.gointernal/pagestore/*_test.gokill -9internal/page/page.goGrow(from, to),KindFreeList,PageCountclamped at 2³²internal/fileio/fileio.goSyncDir,SyncParentandOpenExisting, so a file's name is durable and a missing file is an errorinternal/fileio/fileio_test.gofileio: the directory-fsync error policy, symlink resolution, the hop limitinternal/page/grow_test.goGrow,PageCount, andReadagainst a fake file that records every writecmd/scutedb-demo/pagestore.gomake demo-pagestore, including twenty realkill -9spagestoreimportscore,fileioandpage. Nothing imports it yet: theB+Tree still lives in memory, and wiring it to this store is the next step.
The meta page
Page 0 is the only place a reader can look without being told where to look:
Page 0 also means "no page": a root of 0 is "no root yet", and a free-list chain
ends at 0. That is safe only because page 0 can never be a root or a free page.
How the code is organised
Opening.
Create(path)Open(path)openFile(f)loadisBlank(f)create(pf)load(pf)Allocating and freeing.
Allocate(kind)Free(id)SetRoot(id)/Root()Read(id)/Write(p)Committing.
Checkpoint()Sync()Close()Discard()Checking.
Verify()Durable()/Meta()FreePages()/PendingPages()/ListPages()The flow of a checkpoint
Nothing that the previous checkpoint references is overwritten before step 6
completes. If any fsync fails, the store is poisoned and refuses every further
call until the file is reopened.
The flow of Open
Why a freed page has to wait
The first version wrote a free-list link into each freed page, so the free list
was a chain through the free pages. Two crash tests broke it. Freeing the old root
after checkpointing a new one, then crashing, destroyed the last committed tree:
the meta page still pointed at the old root, but
Freehad already overwrittenit. And reusing a free page broke the chain the durable free list depended on.
The rule that fixes both: until a new checkpoint is durable, nothing the previous
one references may be touched. So
Freeonly records the page as pending, andthe free list lives in dedicated pages taken from somewhere the durable state does
not look.
The page cache is not the disk
Most of what review found came from one confusion: a read returns what the
operating system holds, not necessarily what the drive holds.
Openreadloadrewrites and fsyncs it before handing out any pageGrow(from, to)writes zeros for exactly the new pages, from the store's own countOpenandCreatefsync the directory that really holds the file, following symlinksThe first is the subtle one. If a checkpoint's final fsync fails, the new meta can
sit in the cache but not on disk. Without recovery, the next
Openwould reusepages the on-disk checkpoint still needs, and a later power cut would revert to
that checkpoint — now overwritten.
The directory row took three attempts. Fsyncing the directory named in the path's
text misses the real one behind a symlink. Resolving the path with
filepath.EvalSymlinksfound the right place but built one long string that failspast
PATH_MAX, and afilepath.Dir"lexical parent" tidied away..beforefollowing symlinks. The version that holds splits off the last component without
cleaning, opens the prefix so the kernel resolves it, and follows only a
final-component symlink itself.
fileio.SyncParentis that function.A missing database is an error, not an empty database.
Openused to create amissing file, which is what turned any lost directory entry into silent data loss:
the next
Openquietly returned an empty store. Now onlyCreatemakes files.A consequence worth knowing: after a failed checkpoint the outcome is unknown.
If only the final fsync failed, the next
Openmay make the new state durable.Reopen and read the root to find out.
Verifying it
A real
kill -9, twenty times. The demo's child process loops forever —allocate a root, stamp it with the checkpoint number it will become, free the old
root, checkpoint — and is killed at an arbitrary moment. All twenty reopen to a
consistent checkpoint whose root holds the matching stamp.
A fake disk for everything a demo cannot stage.
memFilekeeps three copies ofthe truth — what reads see, what is dirty, and what is on the drive — and can
simulate a power cut that keeps any subset of dirty writes, a failed fsync that
behaves like Linux after 2018, a process restart that keeps the page cache, and
the cache later evicting pages that were never written.
A crash fuzzer.
FuzzCrashAnywhereKeepsTheLastCheckpointruns randomsequences of allocate, free, set-root, checkpoint, restart, crash, power cut,
failed checkpoint and failed sync. After every step it checks that every page the
last checkpoint referenced holds its original bytes and that every page is in
exactly one state. It ran 2,436,886 executions.
Independent review, round by round. Reviewers worked in private copies of the
repository and had to prove every finding with a failing test that a separate
skeptic then reproduced. Round two included a reviewer who wrote an independent
crash fuzzer from scratch; it found nothing. Round four found that round three's
own path fix had introduced two ways to make a valid file unopenable; round four's
generalist fuzzed allocation, checkpointing and recovery with new fault types for
eleven minutes and found nothing there.
Freeoverwrote pages the last checkpoint referencedOpentrusted a meta page that existed only in the cacheSyncdid not poison, so the next checkpoint committed lost pagesCreateleft a file that could never be openedVerifynever compared live state with the durable free listOpencreated the filePATH_MAX..before following symlinksAllocatethat had grown the file left the store reporting the bigger sizeWritestill marked the store as needing a checkpointHow strong the tests are. Coverage only says a line ran. Mutation testing is
stronger: a script breaks the code on purpose, one change at a time — 563 changes
in all — and checks that some test fails each time.
Breaking each fix by hand caught 28 of 28, but those were the spots I had just
fixed. The full sweep was less kind, and drove a round of new tests:
The new tests are: 81 hostile rewrites of a real store's bytes plus a fuzzer that
fed
Openrandom bytes 20.7 million times; a fake file that fails or tears theNth read, write, sync or size call at 251 points inside every operation; an
independent crash fuzzer that decodes the simulated disk with its own decoder;
each of
Open's two layers of checks tested on its own; and a realkill -9test that runs in CI.
The sweep also exposed weak tests of my own: six symlink tests passed if
Openfailed for any reason, and a
Discardtest was rescued by its ownClose. Bothnow check the exact cause. The 39 remaining survivors were each checked by hand:
17 make no observable difference, 15 are caught by a second layer of checks that
is tested separately, and 7 need an OS failure that cannot be produced on demand.
Known limits
alternating meta pages with checksums remove that; checksums arrive in
0x13.if that target joined to the link's directory passes
PATH_MAX(1024 on macOS);avoiding it needs
openat, which Go's standard library lacks on macOS.needs a page nothing durable references.
(
EINVAL,ENOTSUP), as SQLite does. Every other error fails.0x0C.recycled at the next checkpoint.
Verification
238 tests, 14 fuzz targets. Run the walkthrough with
make demo-pagestore.