From e5895227733514ce3f2cdf2079a82ae02825b3be Mon Sep 17 00:00:00 2001 From: Syed Suhail Ahmed <114918598+suhailopensource@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:47:20 +0530 Subject: [PATCH] Add internal/pagestore: page allocation, a free list, and crash-safe 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. --- Makefile | 3 + README.md | 374 ++++++- cmd/scutedb-demo/main.go | 6 +- cmd/scutedb-demo/pagestore.go | 326 ++++++ internal/fileio/fileio.go | 71 +- internal/fileio/fileio_test.go | 230 +++++ internal/page/grow_test.go | 267 +++++ internal/page/page.go | 34 +- internal/pagestore/boundaries_test.go | 338 ++++++ internal/pagestore/crash_test.go | 86 ++ internal/pagestore/describe.go | 124 +++ internal/pagestore/describe_test.go | 93 ++ internal/pagestore/faults_test.go | 1333 ++++++++++++++++++++++++ internal/pagestore/fuzz_test.go | 279 +++++ internal/pagestore/hostile_test.go | 737 +++++++++++++ internal/pagestore/killnine_test.go | 127 +++ internal/pagestore/layers_test.go | 156 +++ internal/pagestore/lifecycle_test.go | 235 +++++ internal/pagestore/memfile_test.go | 146 +++ internal/pagestore/oracle_test.go | 1380 +++++++++++++++++++++++++ internal/pagestore/pagestore.go | 870 ++++++++++++++++ internal/pagestore/pagestore_test.go | 757 ++++++++++++++ internal/pagestore/recovery_test.go | 844 +++++++++++++++ 23 files changed, 8809 insertions(+), 7 deletions(-) create mode 100644 cmd/scutedb-demo/pagestore.go create mode 100644 internal/fileio/fileio_test.go create mode 100644 internal/page/grow_test.go create mode 100644 internal/pagestore/boundaries_test.go create mode 100644 internal/pagestore/crash_test.go create mode 100644 internal/pagestore/describe.go create mode 100644 internal/pagestore/describe_test.go create mode 100644 internal/pagestore/faults_test.go create mode 100644 internal/pagestore/fuzz_test.go create mode 100644 internal/pagestore/hostile_test.go create mode 100644 internal/pagestore/killnine_test.go create mode 100644 internal/pagestore/layers_test.go create mode 100644 internal/pagestore/lifecycle_test.go create mode 100644 internal/pagestore/memfile_test.go create mode 100644 internal/pagestore/oracle_test.go create mode 100644 internal/pagestore/pagestore.go create mode 100644 internal/pagestore/pagestore_test.go create mode 100644 internal/pagestore/recovery_test.go diff --git a/Makefile b/Makefile index 6a95e3b..5cd7e36 100644 --- a/Makefile +++ b/Makefile @@ -73,3 +73,6 @@ demo-delete: demo-nodepage: go run ./cmd/scutedb-demo nodepage + +demo-pagestore: + go build -o /tmp/scutedb-demo ./cmd/scutedb-demo && /tmp/scutedb-demo pagestore diff --git a/README.md b/README.md index a386a7a..77948f5 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ A *scute* is one of the bony plates that make up a turtle's shell. A shell is made of plates; a database file is made of pages. -**Status:** Phases 0 and A complete. Phase B has started: a B+Tree node now has -a byte-for-byte on-disk format, and a tree can be walked straight out of a file -using nothing but page numbers. +**Status:** Phases 0 and A complete. Phase B is under way: a B+Tree node has a +byte-for-byte on-disk format, and a storage manager allocates pages, recycles +them, and finds the root again after a restart — including after a `kill -9`. --- @@ -21,6 +21,7 @@ make demo-scan # watch a file-based database degrade make hexdump # write real pages and look at the bytes make demo-delete # borrow, merge and root collapse, traced live make demo-nodepage # decode a real B+Tree page out of a real file +make demo-pagestore # free lists, checkpoints, and twenty kill -9 restarts ``` --- @@ -656,7 +657,7 @@ Deliberate, each one is a later step: - ~~`Page.Append` writes items with no separator~~ → fixed in `0x03` - The B+Tree is pointers in memory and nothing persists → `0x09` (nodes become pages) - No way to find item *n* without walking items 1..*n-1* → `0x0F` (slot directory) -- `File.Allocate` never reuses a freed page → `0x0A` (free list) +- ~~`File.Allocate` never reuses a freed page~~ → fixed in `0x0A` (free list) - The reserved header bytes hold no checksum → `0x13` - Nothing is thread-safe → `0x0C` @@ -800,6 +801,370 @@ arbitrary keys for 2.38M executions. Run it: `make demo-nodepage`. +### `0x0A` The index storage manager + +`0x09` made a tree node into bytes. It left one question open, and it is the +first thing any reader of the file asks: **where does the tree start?** After a +restart nothing is in memory. A file holding 14 million perfectly formatted keys +is useless if nothing says which page is the root. + +`internal/pagestore` answers it, and takes on everything that comes with owning +a file: handing pages out, taking them back, growing the file, and making sure a +crash at any moment leaves something that still makes sense. + +**The meta page is the one fixed address.** Page 0 is the only place a reader can +look without being told where to look, so it holds everything needed to find +everything else: + +``` +page 0, the meta page + +0000 00 00 00 00 01 00 00 00 00 38 10 00 00 00 00 00 |.........8......| +0010 53 43 55 54 45 44 42 00 00 00 00 01 00 00 10 00 |SCUTEDB.........| +0020 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 02 |................| +0030 00 00 00 10 00 00 00 02 00 00 00 00 00 00 00 00 |................| + +offset bytes meaning +16..23 53 43 55 54 45 44 42 00 magic = "SCUTEDB", NUL-terminated +24..27 00 00 00 01 format version = 1 +28..31 00 00 10 00 page size = 4096 +32..35 00 00 00 01 root = page 1 +36..39 00 00 00 00 free list = none (nothing is free) +40..43 00 00 00 00 free page count = 0 +44..47 00 00 00 02 next page to hand out = 2 +48..51 00 00 00 10 pages the file holds = 16 (65536 bytes) +52..55 00 00 00 02 checkpoint generation = 2 +``` + +Page 0 doubles as the "no page" value — `root = 0` means no root yet, and a +free-list chain ends at 0. That is the trick `0x04` warned against, and here it is +safe for exactly the reason it was unsafe there: a sentinel is only dangerous when +the value it steals could be real. `-1` can be a real integer. Page 0 can never be +a root or a free page, because it is always this page. + +**Magic numbers and versions exist to refuse.** + +``` +open a text file -> rejected, not a scutedb file +open a version-2 file -> rejected, unknown format version +open 8192-byte pages -> rejected, wrong page size +``` + +None of those would crash if accepted. Reading 8 KB pages as 4 KB pages would +quietly return wrong rows, which is worse. Two rules make the refusal *useful*: + +- **The magic never carries the version.** An earlier draft ended the magic with + a `01` byte, so a version-2 file would have been called foreign rather than + newer. The magic answers "is this ours?"; the version field answers "which + one?". Keeping them apart is what lets a reader say *this is a ScuteDB file, + newer than me* instead of *this is not a ScuteDB file*. +- **Check identity, then version, then everything else.** Everything after the + version is allowed to change between versions — including the page header — so + a newer file must be recognised before any of it is read. + +SQLite's first sixteen bytes are `SQLite format 3` followed by a NUL, for the same +reason. + +**The file grows in chunks.** Pages handed out and pages the file holds are two +different numbers, so the meta page stores both: + +``` +handed out file size grown +0 64.0 KB 1 times +15 64.0 KB 1 times +16 128.0 KB 2 times +31 128.0 KB 2 times +32 192.0 KB 3 times +40 192.0 KB 3 times +``` + +Forty pages, three growths, one write each. + +**Free lists, and why a freed page has to wait.** The first draft of this step did +what most tutorials do: freeing a page wrote a "free" marker and a next-pointer +into the page itself, so the free list was a chain threaded through the free +pages. Two crash tests broke it: + +- **Data loss.** Checkpoint a root, make a new root, free the old one — exactly + what a B+Tree root collapse does — then crash before the next checkpoint. On + restart the meta page correctly points at the old root, but `Free` had already + overwritten it. The last committed tree was destroyed by work that was never + committed. +- **A corrupt free list.** Reusing a free page overwrote its link, so after a + crash the durable free list walked into a leaf. + +One cause behind both: **overwriting a page the last durable state still points +at.** Until a new checkpoint is durable, nothing the previous one references may +be touched. The fix is the design LMDB and BoltDB use: + +``` +Free(page) goes on a PENDING list, bytes untouched + not reusable until the checkpoint that stops referencing it + +checkpoint pending pages join the free list + the free list is written into its OWN pages, taken only from + pages already free in the durable state — never from pending + pages, never from the current free-list pages +``` + +``` +free pages 3, 4 and 5 + reusable now none + pending, after checkpoint 3 4 5 + +allocate -> page 7. a NEW page, not one just freed. + +checkpoint + reusable now 3 4 5 + holding the free list 8 + +allocate -> page 5. reused, and the file did not grow. +``` + +The old free-list pages are listed as free in the new list, so they are recycled +rather than leaked — two hundred allocate-free-checkpoint rounds keep the file at +one chunk. + +**Data durability is not structural durability.** Data durability means a page's +bytes reached the disk. Structural durability means the file *as a whole* +describes a state that makes sense. A checkpoint turns the first into the second, +and the order is the whole algorithm: + +``` +1. fsync every page written since the last checkpoint +2. write the new free list into its own pages +3. fsync so the list is on disk +4. write page 0 the new root, list, and page counts +5. fsync the commit point +``` + +The meta page is written last. A crash anywhere before step 5 leaves page 0 +describing the previous checkpoint, with nothing it points at disturbed. That +relies on a 56-byte write being atomic, which holds because it sits inside one +disk sector; Postgres makes the same bet with `pg_control`, which it keeps under +512 bytes for exactly this reason. On macOS a plain `fsync` does not flush the +drive's own write cache, but Go's `File.Sync` already issues `F_FULLFSYNC` there +(falling back to `fsync` where a filesystem cannot do it), so these really do +reach the storage. + +**The page cache is not the disk.** Most of what went wrong in this step came from +one confusion. A read returns what the *operating system* holds, which is not +necessarily what the *disk* holds. Three separate bugs trusted the first as if it +were the second: + +| trusted as durable | what could really be on disk | fix | +|---|---|---| +| the meta page `Open` just read | an older meta page | **recovery-on-open**: rewrite the meta and fsync it before handing out a single page | +| the file size | a shorter file | growth writes zeros for exactly the new pages, from the store's own count, never from the file's reported size | +| the file's name in its directory | no directory entry at all | `Open` and `Create` fsync the directory that *really* holds the file, following symlinks, every time | + +The first is the subtle one. If a checkpoint's final fsync fails, or the process +dies during it, the new meta page sits in the cache but not on disk. The next +`Open` would believe it and start reusing pages the *on-disk* checkpoint still +needs; a later power cut then reverts to that older checkpoint, now overwritten. +Rewriting the meta on open makes what the store believes and what the disk holds +agree before anything else happens. SQLite does the equivalent when it rolls back +a hot journal on open. + +The directory row took three tries, and the way it went wrong twice is the lesson. +The first fix fsynced `filepath.Dir(path)` — the directory named in the path's +*text*. Through a symlink the kernel puts the file's entry in the *target's* +directory, so the fsync hit the wrong one. The second fix resolved the path with +`filepath.EvalSymlinks` and fsynced that. It was right about where the file is, +but it built the whole resolved path as one string, which fails once it passes +`PATH_MAX` even though the kernel reaches the same file one step at a time. And it +also fsynced a "lexical parent" computed with `filepath.Dir`, which tidies away +`..` before following symlinks and can name a directory that does not exist. Both +made valid files unopenable. + +The third fix stops resolving paths in strings at all. It splits off the last +component without cleaning anything, opens that prefix as a directory, and lets +the kernel resolve every symlink and `..` along the way. The only thing it follows +itself is a symlink in the *final* component, using `Lstat` and `Readlink` and +joining the target onto the raw prefix. + +**A missing database is an error, not an empty database.** `Open` used to create +the file if it did not exist. That is what turned every lost directory entry into +silent data loss: after a power cut dropped the file's name, the next `Open` +quietly handed back a fresh, empty store. Now only `Create` makes files, and +`Open` of a missing path fails with `fs.ErrNotExist`. `Open` still initialises a +file that exists but is blank, because that is what an interrupted `Create` leaves +behind. + +**When fsync fails, stop.** Any failed fsync poisons the store; every later call +returns `ErrPoisoned`, carrying the original error, until the file is reopened. +In 2018 Postgres found that Linux could drop dirty pages on a failed fsync, mark +them clean, and report success on the retry — so a retry can "succeed" having +written nothing. Postgres now panics instead. Errors that happen before anything +durable is touched, such as running out of page ids, return plainly and leave the +store usable. + +One consequence worth stating plainly: **after a failed checkpoint, the outcome +is unknown.** If only the final fsync failed, the new meta may already be in the +cache, and the next `Open` will make it durable. The caller has to reopen and read +the root to find out — the same position as a Postgres client whose `COMMIT` timed +out. + +**Restart as a test.** The demo runs a child process that loops — new root, stamp +it with the checkpoint number it is about to produce, free the old root, +checkpoint — and `kill -9`s it at an arbitrary moment, twenty times: + +``` +run child said file says root stamp verify +1 4 5 5 ok +2 6 6 6 ok +3 6 7 7 ok +... +19 26 27 27 ok +20 27 27 27 ok + +20 of 20 recovered to a consistent checkpoint. +``` + +"File says" is sometimes one ahead: the child finished a checkpoint and died +before it could print. What must never happen is the file being *behind*. + +The same thing runs as an ordinary test, `TestARealKillNineAtAnyMomentAlwaysRecovers`. +The test binary starts a copy of itself as the child, kills it with a real +`kill -9` twelve times at different moments, and checks each file. So it runs in +CI on Linux with every pull request, not only when someone runs the demo. + +A `kill -9` only kills the process; the OS still flushes what it was handed. The +harder cases cannot be staged from a demo, so the tests use a simulated disk that +models an operating system in front of a drive: + +- a **power cut** keeps the last synced image plus any chosen subset of the writes + since; +- a **failed fsync** behaves like Linux after 2018 — some writes land, the rest are + marked clean and never reach the disk, though reads still see them; +- a **process restart** keeps the page cache, which is exactly where the + cache-versus-disk bugs live; +- **eviction** lets those clean-but-unwritten pages silently revert to the older + bytes on disk. + +`FuzzCrashAnywhereKeepsTheLastCheckpoint` runs random sequences of allocate, free, +set-root, checkpoint, clean restart, process crash, power cut, failed checkpoint +and failed sync, and after every single step checks that every page the last +checkpoint referenced still holds its original bytes, and that every page ever +handed out is in exactly one state. It ran 2,436,886 executions of up to 400 +operations each. + +**What independent review found.** Four reviewers, each working in a private copy +of the repository, attacked the first version from different angles — crash +consistency, page accounting, the file format, and misuse of the API. Every +finding had to come with a failing test, and a separate skeptic had to reproduce +it before it counted. A second round attacked the fixes; one of its reviewers +wrote an independent crash fuzzer from scratch, with a different definition of +"correct", and it found nothing. A third round attacked what the second round +changed, and a fourth attacked what the third changed — finding that the third +round's own fix had introduced two ways to make a valid file unopenable. + +| bug | severity | found by | +|---|---|---| +| `Free` overwrote pages the last checkpoint still referenced | data loss | crash tests, first draft | +| `Open` trusted a meta page that existed only in the cache | data loss | review, round 1 | +| a failed `Sync` did not poison, so the next checkpoint committed lost pages | data loss | review, round 1 | +| page ids could wrap past 2³² into the meta page | data loss | review, round 1 | +| a root that was also a free-list page was accepted, and later handed out | corruption | review, round 1 | +| a crash during `Create` left a file that could never be opened | unrecoverable | review, round 1 | +| `Verify` never compared live state with the durable free list | blind spot | review, round 1 | +| growth trusted the file size the cache reported | corruption | **the fuzzer, while testing another fix** | +| the directory was fsynced only when `Open` created the file | data loss | review, round 2 | +| running out of page ids reported "an fsync failed" and hid the cause | wrong error | review, round 2 | +| the directory fsync followed the path's text, not its symlink | data loss | review, round 3 | +| that fix built the resolved path as one string, which fails past `PATH_MAX` | unopenable | review, round 4 | +| that fix also tidied away `..` before following symlinks | unopenable | review, round 4 | +| a failed `Allocate` that had grown the file left the store reporting the bigger size | wrong state | fault-injection tests | +| a failed `Write` still marked the store as needing a checkpoint | wrong state | fault-injection tests | + +**How strong are the tests?** A test suite can pass and still miss bugs. Two +measures say how much it would really catch. + +*Coverage* asks which lines run during the tests. It is the weaker measure: a +line can run without any test checking its result. + +*Mutation testing* is the stronger one. A script breaks the code on purpose — +flips a `<` to `<=`, switches off an `if`, makes an error return `nil`, deletes +a line that updates state — one change at a time, 563 changes in all. For each +broken copy it runs the whole suite. If no test fails, that break "survived", +which means the tests would not have noticed that bug. + +Every fix above was also broken on purpose by hand, and all 28 were caught. That +number turned out to be flattering: those were the exact spots I had just fixed. +The full sweep told a different story: + +``` + before hardening after hardening +coverage, storage code 79.7% 93.5% +mutation score 68.9% 93.1% +breaks nobody caught 174 39 +tests 181 238 +``` + +The 174 surviving breaks pointed straight at what was missing, and that drove a +round of new tests: + +- **Hostile files.** 81 ways of damaging a real store's bytes on disk, each of + which `Open` must reject with the right error *and without changing the file*, + plus a fuzzer that fed `Open` random bytes 20.7 million times. +- **Fault injection.** A fake file that fails the Nth write, read, sync or size + call, or tears a write halfway. It does this at 251 separate points inside + every operation, and after each one checks that the store either stayed + exactly as it was or poisoned itself, and that the file still reopens. +- **An independent oracle.** A second crash fuzzer that reads the bytes on the + simulated disk with its *own* decoder, instead of trusting the store's. +- **Each safety check on its own.** `Open` checks a file twice: once with + `checkMeta` and `checkFreeSet`, once with `verifyLive`. Break either and the + other still catches it, so the sweep could never test them separately. Now + each one is also tested directly. +- **A real `kill -9`** as an ordinary test, described above. + +The sweep also caught **weak tests I had written**. Six symlink tests passed if +`Open` failed for *any* reason, not the right one. A `Discard` test passed only +because the test itself called `Close` afterwards. Both now check the exact +cause. + +The 39 breaks still surviving were each checked by hand. None is an untested +gap. They fall into three groups: + +``` +17 no observable difference e.g. capping at a limit with > or >= gives the same value +15 a second check catches it two layers guard the same thing; each is tested alone + 7 needs a faked OS failure e.g. closing a directory failing, which the OS + will not do on demand +``` + +In short: coverage says the code ran; mutation testing says the tests would +notice if it were wrong. The second number went from 69% to 93%. + +**How the real ones do it.** SQLite's header is page 1, with a free list of trunk +pages that each list leaf pages. BoltDB keeps two alternating meta pages chosen by +transaction id and checksum, writes its free list to a fresh page on every commit, +and holds freed pages as pending until no reader can still see them. LMDB tracks +free pages in a B-Tree of its own. Postgres keeps its checkpoint location in +`pg_control`. + +**Known limits**, each deliberate: + +- A single meta page relies on a sector-sized write being atomic. Two alternating + meta pages with checksums remove that assumption; checksums arrive in `0x13`. +- A database path whose *final* component is a symlink with a relative target + cannot be opened if that target, joined to the link's directory, is longer than + `PATH_MAX` (1024 bytes on macOS). Resolving it without building that string + needs `openat`, which Go's standard library does not expose on macOS. +- A full disk with an empty free list cannot checkpoint, because the new free list + needs a page nothing durable references. +- A directory fsync is treated as done when the filesystem says it cannot do one + at all (`EINVAL`, `ENOTSUP`), as SQLite does; on those filesystems a new file's + name is only as durable as the filesystem makes it. Every other error fails. +- There is no file lock, so two processes can open the same store; that belongs + with concurrency in `0x0C`. Windows is not supported. +- A checkpoint can occasionally write one empty trailing free-list page; it is + valid, and recycled at the next checkpoint. +- Nothing is thread-safe yet (`0x0C`), and the B+Tree does not use this store yet. + +Run it: `make demo-pagestore`. + --- ## Layout @@ -818,6 +1183,7 @@ internal/ slots/ fixed-size, aligned record slots inside a page btree/ in-memory B+Tree: search, insert, split, range scans, delete nodepage/ the on-disk byte layout of a B+Tree node + pagestore/ page allocation, free list, meta page, checkpoints ``` ## Conventions diff --git a/cmd/scutedb-demo/main.go b/cmd/scutedb-demo/main.go index 26af582..8e08a68 100644 --- a/cmd/scutedb-demo/main.go +++ b/cmd/scutedb-demo/main.go @@ -27,7 +27,7 @@ import ( func main() { if len(os.Args) < 2 { - fmt.Println("usage: scutedb-demo scan|update|race|crash|pages|header|encode|nulls|align|btree|range|delete|nodepage") + fmt.Println("usage: scutedb-demo scan|update|race|crash|pages|header|encode|nulls|align|btree|range|delete|nodepage|pagestore") os.Exit(2) } switch os.Args[1] { @@ -57,6 +57,10 @@ func main() { expDelete() case "nodepage": expNodePage() + case "pagestore": + expPageStore() + case "store-child": + storeChild(os.Args[2]) case "crash-child": crashChild(os.Args[2]) default: diff --git a/cmd/scutedb-demo/pagestore.go b/cmd/scutedb-demo/pagestore.go new file mode 100644 index 0000000..ffdc41d --- /dev/null +++ b/cmd/scutedb-demo/pagestore.go @@ -0,0 +1,326 @@ +package main + +import ( + "bufio" + "encoding/binary" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/page" + "github.com/scute-db/scutedb/internal/pagestore" +) + +func expPageStore() { + fmt.Print("STEP 0x0A The index storage manager\n\n") + storeMagic() + storeMeta() + storeChunks() + storeFreeList() + storeDurability() + storeRestarts() +} + +func storeMagic() { + fmt.Print("1. WHICH FILE IS THIS?\n\n") + fmt.Print(" the first thing a reader needs is proof the file is ours,\n") + fmt.Print(" in a format this build understands.\n\n") + + path := tmp("magic.db") + s, err := pagestore.Create(path) + check(err) + check(s.Close()) + + raw, err := os.ReadFile(path) + check(err) + fmt.Printf(" bytes 16..23 of a new file: % 02X %q\n\n", raw[16:24], string(raw[16:23])) + + junk := tmp("poem.txt") + check(os.WriteFile(junk, []byte(strings.Repeat("roses are red, pages are 4096\n", 300)), 0o644)) + _, err = pagestore.Open(junk) + fmt.Printf(" open a text file -> %s\n", verdict(err)) + + future := tmp("future.db") + bumped := append([]byte(nil), raw...) + binary.BigEndian.PutUint32(bumped[24:], pagestore.Version+1) + check(os.WriteFile(future, bumped, 0o644)) + _, err = pagestore.Open(future) + fmt.Printf(" open a version-%d file -> %s\n", pagestore.Version+1, verdict(err)) + + big := tmp("bigpages.db") + resized := append([]byte(nil), raw...) + binary.BigEndian.PutUint32(resized[28:], 8192) + check(os.WriteFile(big, resized, 0o644)) + _, err = pagestore.Open(big) + fmt.Printf(" open 8192-byte pages -> %s\n\n", verdict(err)) + + fmt.Print(" refusing is the point. reading an 8 KB-page file as 4 KB pages\n") + fmt.Print(" would not crash. it would quietly return wrong rows.\n\n") +} + +func verdict(err error) string { + switch { + case err == nil: + return "ACCEPTED" + case errors.Is(err, pagestore.ErrNotScuteDB): + return "rejected, not a scutedb file" + case errors.Is(err, pagestore.ErrBadVersion): + return "rejected, unknown format version" + case errors.Is(err, pagestore.ErrBadPageSize): + return "rejected, wrong page size" + } + return "rejected: " + err.Error() +} + +func storeMeta() { + fmt.Print("2. THE META PAGE: THE ONE FIXED ADDRESS\n\n") + fmt.Print(" after a restart nothing is in memory. page 0 is the only place\n") + fmt.Print(" a reader can look without being told where to look.\n\n") + + path := tmp("meta.db") + s, err := pagestore.Create(path) + check(err) + root, err := s.Allocate(page.KindBTreeLeaf) + check(err) + check(s.SetRoot(root.ID())) + check(s.Checkpoint()) + out, err := s.DescribeMeta() + check(err) + fmt.Print(indent(out, " ")) + check(s.Close()) +} + +func storeChunks() { + fmt.Print("3. GROWING THE FILE IN CHUNKS\n\n") + path := tmp("chunks.db") + s, err := pagestore.Create(path) + check(err) + + fmt.Printf(" %-12s %-16s %s\n", "handed out", "file size", "grown") + fmt.Println(" " + strings.Repeat("-", 40)) + report := func() { + check(s.Sync()) + info, err := os.Stat(path) + check(err) + fmt.Printf(" %-12d %-16s %d times\n", s.Meta().NextPage-1, humanBytes(info.Size()), s.Stats().Grown) + } + report() + for _, target := range []int{15, 16, 31, 32, 40} { + for int(s.Meta().NextPage)-1 < target { + _, err := s.Allocate(page.KindHeap) + check(err) + } + report() + } + check(s.Close()) + fmt.Printf("\n 40 pages, but the file grew 3 times, not 40. each growth adds\n") + fmt.Printf(" %d pages (%s) in one write, so the filesystem can lay them out\n", pagestore.ChunkPages, humanBytes(pagestore.ChunkPages*page.Size)) + fmt.Print(" contiguously and the metadata update is paid once per chunk.\n") + fmt.Print(" 'handed out' and 'file size' are different numbers, so both live\n") + fmt.Print(" in the meta page. after a restart the next page is 41, not 48.\n\n") +} + +func storeFreeList() { + fmt.Print("4. FREE LISTS, AND WHY A FREED PAGE HAS TO WAIT\n\n") + path := tmp("free.db") + s, err := pagestore.Create(path) + check(err) + var ids []core.PageID + for i := 0; i < 6; i++ { + p, err := s.Allocate(page.KindHeap) + check(err) + ids = append(ids, p.ID()) + } + check(s.SetRoot(ids[0])) + check(s.Checkpoint()) + + for _, id := range ids[2:5] { + check(s.Free(id)) + } + fmt.Printf(" free pages %d, %d and %d\n", ids[2], ids[3], ids[4]) + fmt.Print(indent(s.DescribeFreeList(), " ")) + + p, err := s.Allocate(page.KindHeap) + check(err) + fmt.Printf(" allocate -> page %d. a NEW page, not one just freed.\n\n", p.ID()) + fmt.Print(" the last checkpoint on disk still says those three pages are in\n") + fmt.Print(" use. if one were overwritten now and the process died, restart\n") + fmt.Print(" would find the committed tree pointing at someone else's bytes.\n\n") + + check(s.Checkpoint()) + fmt.Print(" checkpoint\n") + fmt.Print(indent(s.DescribeFreeList(), " ")) + q, err := s.Allocate(page.KindHeap) + check(err) + fmt.Printf(" allocate -> page %d. reused, and the file did not grow.\n\n", q.ID()) + fmt.Print(" the free list lives in its own page, not threaded through the\n") + fmt.Print(" free pages, so reusing a free page cannot damage the list that\n") + fmt.Print(" describes the last checkpoint.\n\n") + check(s.Close()) +} + +func storeDurability() { + fmt.Print("5. DATA DURABILITY IS NOT STRUCTURAL DURABILITY\n\n") + fmt.Print(" data durability: the bytes of a page reached the disk.\n") + fmt.Print(" structural durability: the file, as a whole, describes a state\n") + fmt.Print(" that makes sense.\n\n") + fmt.Print(" a checkpoint turns the first into the second, in this order:\n\n") + fmt.Print(" 1. fsync every page written since the last one\n") + fmt.Print(" 2. write the new free list into its own pages\n") + fmt.Print(" 3. fsync so the list is on disk\n") + fmt.Print(" 4. write page 0 the new root, list, and page counts\n") + fmt.Print(" 5. fsync the commit point\n\n") + fmt.Print(" the meta page is written LAST. if the machine dies at any step\n") + fmt.Print(" before 5, page 0 still describes the previous checkpoint, and\n") + fmt.Print(" nothing that checkpoint points at has been touched.\n\n") + + path := tmp("durable.db") + s, err := pagestore.Create(path) + check(err) + first, err := s.Allocate(page.KindHeap) + check(err) + copy(first[page.HeaderSize:], "committed") + check(s.Write(first)) + check(s.SetRoot(first.ID())) + check(s.Checkpoint()) + + second, err := s.Allocate(page.KindHeap) + check(err) + copy(second[page.HeaderSize:], "uncommitted") + check(s.Write(second)) + check(s.SetRoot(second.ID())) + check(s.Free(first.ID())) + check(s.Sync()) + fmt.Printf(" root is page %d, checkpointed. then: new root page %d, old root\n", first.ID(), second.ID()) + fmt.Print(" freed, and every page fsynced. data durable. NOT checkpointed.\n") + check(s.Discard()) + + back, err := pagestore.Open(path) + check(err) + got, err := back.Read(back.Root()) + check(err) + fmt.Printf(" the process dies. reopen: root is page %d, holding %q\n\n", + back.Root(), strings.TrimRight(string(got[page.HeaderSize:page.HeaderSize+11]), "\x00")) + fmt.Print(" the second page's bytes are on disk. it does not matter: page 0\n") + fmt.Print(" never learned about it, so the file still describes the old\n") + fmt.Print(" tree, and freeing the old root did not touch its bytes.\n\n") + check(back.Close()) +} + +func storeRestarts() { + fmt.Print("6. RESTART AS A TEST: KILL -9, TWENTY TIMES\n\n") + fmt.Print(" a child process loops: new root page, stamp it with the next\n") + fmt.Print(" checkpoint number, free the old root, checkpoint, report. the\n") + fmt.Print(" parent SIGKILLs it at an arbitrary moment and reopens the file.\n\n") + + self, err := os.Executable() + check(err) + fmt.Printf(" %-5s %-12s %-12s %-10s %s\n", "run", "child said", "file says", "root stamp", "verify") + fmt.Println(" " + strings.Repeat("-", 58)) + + clean := 0 + for run := 1; run <= 20; run++ { + path := tmp("restart.db") + cmd := exec.Command(self, "store-child", path) + stdout, err := cmd.StdoutPipe() + check(err) + check(cmd.Start()) + + last := uint32(0) + done := make(chan struct{}) + go func() { + sc := bufio.NewScanner(stdout) + for sc.Scan() { + if v, err := strconv.ParseUint(sc.Text(), 10, 32); err == nil { + last = uint32(v) + } + } + close(done) + }() + time.Sleep(time.Duration(40+run*13) * time.Millisecond) + check(cmd.Process.Kill()) + cmd.Wait() + <-done + + s, err := pagestore.Open(path) + if errors.Is(err, fs.ErrNotExist) { + fmt.Printf(" %-5d %-12d killed before it created the file; nothing to recover\n", run, last) + clean++ + continue + } + if err != nil { + fmt.Printf(" %-5d %-12d OPEN FAILED: %v\n", run, last, err) + continue + } + gen := s.Durable().Checkpoint + stamp := uint32(0) + if s.Root() != pagestore.NoPage { + p, err := s.Read(s.Root()) + check(err) + stamp = binary.BigEndian.Uint32(p[page.HeaderSize:]) + } + v := s.Verify() + ok := v == nil && gen >= last && gen <= last+1 && (s.Root() == pagestore.NoPage || stamp == gen) + status := "ok" + if v != nil { + status = v.Error() + } else if !ok { + status = "MISMATCH" + } else { + clean++ + } + fmt.Printf(" %-5d %-12d %-12d %-10d %s\n", run, last, gen, stamp, status) + check(s.Close()) + } + + fmt.Printf("\n %d of 20 recovered to a consistent checkpoint.\n\n", clean) + fmt.Print(" 'file says' is sometimes one ahead of 'child said': the child\n") + fmt.Print(" finished a checkpoint and died before it could print. that is\n") + fmt.Print(" correct. the one thing that must never happen is the file being\n") + fmt.Print(" BEHIND what the child was told was durable, or the root holding\n") + fmt.Print(" a stamp from a different checkpoint than the one that set it.\n\n") + fmt.Print(" a SIGKILL only kills the process; the OS still flushes what it\n") + fmt.Print(" was given. a power cut is harsher, and cannot be staged from a\n") + fmt.Print(" demo. the test suite covers it with a simulated disk that drops\n") + fmt.Print(" any combination of unsynced writes.\n\n") +} + +func storeChild(path string) { + s, err := pagestore.Create(path) + if err != nil { + os.Exit(1) + } + out := bufio.NewWriter(os.Stdout) + fmt.Fprintln(out, s.Durable().Checkpoint) + out.Flush() + for { + p, err := s.Allocate(page.KindBTreeLeaf) + if err != nil { + os.Exit(1) + } + binary.BigEndian.PutUint32(p[page.HeaderSize:], s.Durable().Checkpoint+1) + if err := s.Write(p); err != nil { + os.Exit(1) + } + old := s.Root() + if err := s.SetRoot(p.ID()); err != nil { + os.Exit(1) + } + if old != pagestore.NoPage { + if err := s.Free(old); err != nil { + os.Exit(1) + } + } + if err := s.Checkpoint(); err != nil { + os.Exit(1) + } + fmt.Fprintln(out, s.Durable().Checkpoint) + out.Flush() + } +} diff --git a/internal/fileio/fileio.go b/internal/fileio/fileio.go index debcfc2..a510743 100644 --- a/internal/fileio/fileio.go +++ b/internal/fileio/fileio.go @@ -1,6 +1,12 @@ package fileio -import "os" +import ( + "errors" + "os" + "path/filepath" + "strings" + "syscall" +) type File interface { ReadAt(p []byte, off int64) (int, error) @@ -21,6 +27,14 @@ func Open(path string) (*OSFile, error) { return &OSFile{f: f}, nil } +func OpenExisting(path string) (*OSFile, error) { + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return nil, err + } + return &OSFile{f: f}, nil +} + func (o *OSFile) ReadAt(p []byte, off int64) (int, error) { return o.f.ReadAt(p, off) } func (o *OSFile) WriteAt(p []byte, off int64) (int, error) { return o.f.WriteAt(p, off) } func (o *OSFile) Sync() error { return o.f.Sync() } @@ -34,4 +48,59 @@ func (o *OSFile) Size() (int64, error) { return st.Size(), nil } +var syncFile = func(f *os.File) error { return f.Sync() } + +func SyncDir(dir string) error { + d, err := os.Open(dir) + if err != nil { + return err + } + err = syncFile(d) + if cerr := d.Close(); err == nil { + err = cerr + } + if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTSUP) || errors.Is(err, syscall.EOPNOTSUPP) { + return nil + } + return err +} + +const maxLinks = 40 + +func SyncParent(path string) error { + for hops := 0; ; hops++ { + if hops > maxLinks { + return &os.PathError{Op: "syncparent", Path: path, Err: syscall.ELOOP} + } + dir := rawDir(path) + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink == 0 { + return SyncDir(dir) + } + target, err := os.Readlink(path) + if err != nil { + return err + } + if filepath.IsAbs(target) { + path = target + } else { + path = dir + string(os.PathSeparator) + target + } + } +} + +func rawDir(path string) string { + i := strings.LastIndexByte(path, os.PathSeparator) + switch { + case i < 0: + return "." + case i == 0: + return string(os.PathSeparator) + } + return path[:i] +} + var _ File = (*OSFile)(nil) diff --git a/internal/fileio/fileio_test.go b/internal/fileio/fileio_test.go new file mode 100644 index 0000000..6e91256 --- /dev/null +++ b/internal/fileio/fileio_test.go @@ -0,0 +1,230 @@ +package fileio + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "syscall" + "testing" +) + +func TestSyncDirToleratesOnlyFilesystemsThatCannotDoIt(t *testing.T) { + dir := t.TempDir() + orig := syncFile + defer func() { syncFile = orig }() + + cases := []struct { + errno syscall.Errno + ok bool + why string + }{ + {0, true, "success"}, + {syscall.EINVAL, true, "some NFS and FUSE filesystems cannot fsync a directory at all"}, + {syscall.ENOTSUP, true, "the filesystem does not support it"}, + {syscall.EOPNOTSUPP, true, "the filesystem does not support it"}, + {syscall.EIO, false, "a real I/O error: the directory entry may not be on disk"}, + {syscall.ENOSPC, false, "the disk is full"}, + {syscall.EACCES, false, "a permission problem is not the filesystem lacking the feature"}, + } + for _, c := range cases { + errno := c.errno + syncFile = func(f *os.File) error { + if errno == 0 { + return nil + } + return &os.PathError{Op: "sync", Path: f.Name(), Err: errno} + } + err := SyncDir(dir) + if (err == nil) != c.ok { + t.Errorf("fsync of a directory failing with %v (%s): SyncDir returned %v", errno, c.why, err) + } + if !c.ok && !errors.Is(err, errno) { + t.Errorf("SyncDir lost the cause: got %v, want %v in the chain", err, errno) + } + } +} + +func TestSyncParentFollowsASymlinkToTheRealDirectory(t *testing.T) { + linkDir, realDir := t.TempDir(), t.TempDir() + real := filepath.Join(realDir, "f") + if err := os.WriteFile(real, nil, 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(linkDir, "f") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + + orig := syncFile + defer func() { syncFile = orig }() + var synced []string + syncFile = func(f *os.File) error { + synced = append(synced, f.Name()) + return nil + } + if err := SyncParent(link); err != nil { + t.Fatal(err) + } + + resolved, err := filepath.EvalSymlinks(realDir) + if err != nil { + t.Fatal(err) + } + found := false + for _, d := range synced { + if r, err := filepath.EvalSymlinks(d); err == nil && r == resolved { + found = true + } + } + if !found { + t.Errorf("SyncParent(%s) fsynced %v, never the directory %s that holds the file's entry", link, synced, realDir) + } +} + +func TestRawDirSplitsWithoutCleaning(t *testing.T) { + cases := map[string]string{ + "a/b/c": "a/b", + "c": ".", + "/c": "/", + "a/link/../c/x": "a/link/../c", + "./x": ".", + "a//x": "a/", + "/tmp/a/../b.db": "/tmp/a/..", + } + for in, want := range cases { + if got := rawDir(in); got != want { + t.Errorf("rawDir(%q) = %q, want %q; cleaning here would change what the kernel resolves", in, got, want) + } + } +} + +func TestOpenExistingRefusesAMissingFileAndDoesNotCreateIt(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.db") + if _, err := OpenExisting(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("OpenExisting of a missing file gave %v, want os.ErrNotExist", err) + } + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("OpenExisting created the file: %v", err) + } + f, err := Open(path) + if err != nil { + t.Fatalf("Open, unlike OpenExisting, creates a missing file: %v", err) + } + f.Close() +} + +func TestSizeAfterCloseFails(t *testing.T) { + f, err := Open(filepath.Join(t.TempDir(), "f")) + if err != nil { + t.Fatal(err) + } + f.Close() + if _, err := f.Size(); err == nil { + t.Error("Size of a closed file returned no error") + } +} + +func TestSyncParentGivesUpOnASymlinkLoop(t *testing.T) { + dir := t.TempDir() + if err := os.Symlink("b", filepath.Join(dir, "a")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("a", filepath.Join(dir, "b")); err != nil { + t.Fatal(err) + } + if err := SyncParent(filepath.Join(dir, "a")); !errors.Is(err, syscall.ELOOP) { + t.Fatalf("SyncParent on a symlink loop gave %v, want ELOOP", err) + } +} + +func TestSyncParentOfAMissingPathFails(t *testing.T) { + if err := SyncParent(filepath.Join(t.TempDir(), "nope", "x.db")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("SyncParent of a missing path gave %v, want os.ErrNotExist", err) + } +} + +func TestSyncDirOfAMissingDirectoryFails(t *testing.T) { + if err := SyncDir(filepath.Join(t.TempDir(), "nope")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("SyncDir of a missing directory gave %v, want os.ErrNotExist", err) + } +} + +func TestSyncParentFsyncsTheDirectoryOfAPlainFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "plain.db") + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + orig := syncFile + defer func() { syncFile = orig }() + var synced []string + syncFile = func(f *os.File) error { + synced = append(synced, f.Name()) + return nil + } + if err := SyncParent(path); err != nil { + t.Fatal(err) + } + if len(synced) != 1 || synced[0] != dir { + t.Fatalf("SyncParent of a plain file fsynced %v, want exactly [%s]", synced, dir) + } +} + +func TestOpenInAMissingDirectoryFails(t *testing.T) { + if _, err := Open(filepath.Join(t.TempDir(), "missing", "f")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("Open in a missing directory gave %v, want os.ErrNotExist", err) + } +} + +func TestSyncParentResolvesARelativeTargetFromTheLinksDirectory(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "sub") + if err := os.Mkdir(sub, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "f"), nil, 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink("sub/f", link); err != nil { + t.Fatal(err) + } + orig := syncFile + defer func() { syncFile = orig }() + var synced []string + syncFile = func(f *os.File) error { + synced = append(synced, f.Name()) + return nil + } + if err := SyncParent(link); err != nil { + t.Fatalf("SyncParent through a relative symlink: %v", err) + } + if len(synced) != 1 || synced[0] != filepath.Join(dir, "sub") { + t.Fatalf("SyncParent fsynced %v, want exactly [%s]: a relative target resolves from the link's own directory", synced, sub) + } +} + +func TestSyncParentFollowsExactlyMaxLinksSymlinksAndNoMore(t *testing.T) { + for _, n := range []int{maxLinks, maxLinks + 1} { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "f"), nil, 0o644); err != nil { + t.Fatal(err) + } + target := "f" + for i := n; i >= 1; i-- { + name := "l" + strconv.Itoa(i) + if err := os.Symlink(target, filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } + target = name + } + err := SyncParent(filepath.Join(dir, "l1")) + if n <= maxLinks && err != nil { + t.Errorf("a chain of %d symlinks is within the limit of %d but gave %v", n, maxLinks, err) + } + if n > maxLinks && !errors.Is(err, syscall.ELOOP) { + t.Errorf("a chain of %d symlinks is past the limit of %d but gave %v, want ELOOP", n, maxLinks, err) + } + } +} diff --git a/internal/page/grow_test.go b/internal/page/grow_test.go new file mode 100644 index 0000000..6721b36 --- /dev/null +++ b/internal/page/grow_test.go @@ -0,0 +1,267 @@ +package page + +import ( + "bytes" + "errors" + "io" + "math" + "testing" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/fileio" +) + +type growWrite struct { + off int64 + n int +} + +type growFile struct { + data []byte + writes []growWrite + failAt int + size int64 + sizeErr error +} + +var _ fileio.File = (*growFile)(nil) + +var errGrowWrite = errors.New("growfile: injected write failure") + +func (g *growFile) ReadAt(p []byte, off int64) (int, error) { + if off >= int64(len(g.data)) { + return 0, io.EOF + } + n := copy(p, g.data[off:]) + if n < len(p) { + return n, io.EOF + } + return n, nil +} + +func (g *growFile) WriteAt(p []byte, off int64) (int, error) { + g.writes = append(g.writes, growWrite{off, len(p)}) + if g.failAt != 0 && len(g.writes) == g.failAt { + return 0, errGrowWrite + } + if end := off + int64(len(p)); end > int64(len(g.data)) { + g.data = append(g.data, make([]byte, end-int64(len(g.data)))...) + } + copy(g.data[off:], p) + return len(p), nil +} + +func (g *growFile) Sync() error { return nil } +func (g *growFile) Close() error { return nil } + +func (g *growFile) Size() (int64, error) { + if g.sizeErr != nil { + return 0, g.sizeErr + } + if g.size != 0 { + return g.size, nil + } + return int64(len(g.data)), nil +} + +func filledWith(b byte, pages int) *growFile { + return &growFile{data: bytes.Repeat([]byte{b}, pages*Size)} +} + +func TestGrowZeroesExactlyTheNewPagesAndNothingElse(t *testing.T) { + g := filledWith(0xAA, 700) + if err := NewFile(g).Grow(10, 610); err != nil { + t.Fatal(err) + } + for p := 0; p < 700; p++ { + want := byte(0xAA) + if p >= 10 && p < 610 { + want = 0 + } + page := g.data[p*Size : (p+1)*Size] + if !bytes.Equal(page, bytes.Repeat([]byte{want}, Size)) { + t.Fatalf("page %d holds %02X..., want every byte %02X", p, page[0], want) + } + } +} + +func TestGrowWritesInPiecesThatTileTheRange(t *testing.T) { + g := &growFile{} + if err := NewFile(g).Grow(10, 610); err != nil { + t.Fatal(err) + } + if want := (600 + growPiece - 1) / growPiece; len(g.writes) != want { + t.Fatalf("600 pages took %d writes, want %d pieces of at most %d pages", len(g.writes), want, growPiece) + } + next := Offset(10) + for i, w := range g.writes { + if w.off != next { + t.Fatalf("write %d starts at %d, want %d: the pieces must be contiguous", i, w.off, next) + } + if w.n > growPiece*Size || w.n%Size != 0 { + t.Fatalf("write %d is %d bytes, want whole pages and at most %d", i, w.n, growPiece*Size) + } + next += int64(w.n) + } + if next != Offset(610) { + t.Fatalf("the pieces end at %d, want %d", next, Offset(610)) + } +} + +func TestGrowOfOneChunkIsOneWrite(t *testing.T) { + g := &growFile{} + if err := NewFile(g).Grow(16, 32); err != nil { + t.Fatal(err) + } + if len(g.writes) != 1 || g.writes[0] != (growWrite{Offset(16), 16 * Size}) { + t.Fatalf("growing one 16-page chunk made writes %v, want a single 64 KB write at page 16", g.writes) + } +} + +func TestGrowDoesNotTrustTheFileSize(t *testing.T) { + g := filledWith(0xAA, 40) + if err := NewFile(g).Grow(16, 32); err != nil { + t.Fatal(err) + } + if len(g.writes) != 1 { + t.Fatalf("the file already reports 40 pages, but Grow(16, 32) must still write the zeros; writes: %v", g.writes) + } + if !bytes.Equal(g.data[16*Size:32*Size], make([]byte, 16*Size)) { + t.Fatal("pages 16..31 are not zero after Grow") + } +} + +func TestGrowOfAnEmptyRangeWritesNothing(t *testing.T) { + for _, r := range [][2]uint32{{5, 5}, {9, 3}, {0, 0}} { + g := &growFile{} + if err := NewFile(g).Grow(r[0], r[1]); err != nil { + t.Fatal(err) + } + if len(g.writes) != 0 { + t.Errorf("Grow(%d, %d) wrote %v, want nothing", r[0], r[1], g.writes) + } + } +} + +func TestGrowStopsAtTheFirstFailedWrite(t *testing.T) { + g := &growFile{failAt: 2} + err := NewFile(g).Grow(0, 3*growPiece) + if !errors.Is(err, errGrowWrite) { + t.Fatalf("Grow with a failing second write returned %v, want the write error", err) + } + if len(g.writes) != 2 { + t.Errorf("Grow kept writing after a failure: %d writes", len(g.writes)) + } +} + +func TestPageCountClampsInsteadOfWrapping(t *testing.T) { + g := &growFile{size: (int64(math.MaxUint32) + 17) * Size} + n, err := NewFile(g).PageCount() + if err != nil { + t.Fatal(err) + } + if n != math.MaxUint32 { + t.Fatalf("a file of 2^32+17 pages reports %d pages; it must clamp to %d, not wrap to 16", n, uint32(math.MaxUint32)) + } +} + +func TestKindNamesIncludeTheFreeList(t *testing.T) { + if KindFreeList.String() != "free-list" { + t.Errorf("KindFreeList prints as %q", KindFreeList.String()) + } + if Kind(99).String() != "kind(99)" { + t.Errorf("an unknown kind prints as %q", Kind(99).String()) + } +} + +func TestAppendAcceptsAnExactFitAndRefusesOneByteMore(t *testing.T) { + p := New(1, KindHeap) + if _, err := p.Append(make([]byte, p.FreeSpace()+1)); err == nil { + t.Fatal("Append of one byte more than the free space succeeded") + } + if _, err := p.Append(make([]byte, p.FreeSpace())); err != nil { + t.Fatalf("Append of exactly the free space failed: %v", err) + } + if p.FreeSpace() != 0 { + t.Fatalf("a page filled exactly has %d bytes free", p.FreeSpace()) + } +} + +func TestReadOfAPageThatIsNotAllThereIsShort(t *testing.T) { + g := &growFile{data: make([]byte, Size+Size/2)} + pf := NewFile(g) + if _, err := pf.Read(0); err != nil { + t.Fatalf("page 0 is all there: %v", err) + } + if _, err := pf.Read(1); !errors.Is(err, core.ErrShortPage) && err == nil { + t.Errorf("Read of a half-written page gave %v, want an error", err) + } + if _, err := pf.Read(9); err == nil { + t.Error("Read of a page past the end of the file succeeded") + } +} + +func TestWriteRejectsAPageOfTheWrongSize(t *testing.T) { + g := &growFile{} + if err := NewFile(g).Write(make(Page, Size-1)); err == nil { + t.Fatal("Write of a 4095-byte page succeeded") + } + if len(g.writes) != 0 { + t.Errorf("a rejected page was still written: %v", g.writes) + } +} + +func TestPageCountAndAllocateReportSizeErrors(t *testing.T) { + boom := errors.New("size failed") + pf := NewFile(&growFile{sizeErr: boom}) + if _, err := pf.PageCount(); !errors.Is(err, boom) { + t.Errorf("PageCount gave %v, want the size error", err) + } + if _, err := pf.Allocate(KindHeap); !errors.Is(err, boom) { + t.Errorf("Allocate gave %v, want the size error", err) + } +} + +func TestAllocateReportsAFailedWrite(t *testing.T) { + pf := NewFile(&growFile{failAt: 1}) + if _, err := pf.Allocate(KindHeap); !errors.Is(err, errGrowWrite) { + t.Errorf("Allocate with a failing write gave %v, want the write error", err) + } +} + +func TestOpenInAMissingDirectoryFails(t *testing.T) { + if _, err := Open(t.TempDir() + "/missing/dir/pages.db"); err == nil { + t.Error("Open in a directory that does not exist succeeded") + } +} + +type eofAtEnd struct{ *growFile } + +func (e eofAtEnd) ReadAt(p []byte, off int64) (int, error) { + n, err := e.growFile.ReadAt(p, off) + if err == nil && off+int64(n) == int64(len(e.data)) { + return n, io.EOF + } + return n, err +} + +type shortNoError struct{ *growFile } + +func (s shortNoError) ReadAt(p []byte, off int64) (int, error) { + n, _ := s.growFile.ReadAt(p[:len(p)/2], off) + return n, nil +} + +func TestReadAcceptsAFullPageThatAlsoReportsEndOfFile(t *testing.T) { + g := filledWith(0x5A, 2) + if _, err := NewFile(eofAtEnd{g}).Read(1); err != nil { + t.Errorf("io.ReaderAt may return a full read together with io.EOF; Read rejected it: %v", err) + } +} + +func TestReadRejectsAShortPageEvenWithoutAnError(t *testing.T) { + g := filledWith(0x5A, 2) + if _, err := NewFile(shortNoError{g}).Read(0); !errors.Is(err, core.ErrShortPage) { + t.Errorf("a half page returned with no error gave %v, want ErrShortPage", err) + } +} diff --git a/internal/page/page.go b/internal/page/page.go index afc7187..9599dd0 100644 --- a/internal/page/page.go +++ b/internal/page/page.go @@ -3,6 +3,7 @@ package page import ( "encoding/binary" "fmt" + "math" "github.com/scute-db/scutedb/internal/core" "github.com/scute-db/scutedb/internal/fileio" @@ -22,6 +23,7 @@ const ( KindHeap KindBTreeLeaf KindBTreeInternal + KindFreeList ) func (k Kind) String() string { @@ -36,6 +38,8 @@ func (k Kind) String() string { return "btree-leaf" case KindBTreeInternal: return "btree-internal" + case KindFreeList: + return "free-list" } return fmt.Sprintf("kind(%d)", uint8(k)) } @@ -107,7 +111,11 @@ func (pf *File) PageCount() (uint32, error) { if err != nil { return 0, err } - return uint32(size / Size), nil + n := size / Size + if n > math.MaxUint32 { + n = math.MaxUint32 + } + return uint32(n), nil } func (pf *File) Read(id core.PageID) (Page, error) { @@ -130,6 +138,30 @@ func (pf *File) Write(p Page) error { return err } +const growPiece = 256 + +func (pf *File) Grow(from, to uint32) error { + if to <= from { + return nil + } + n := to - from + if n > growPiece { + n = growPiece + } + zero := make([]byte, int(n)*Size) + for at := from; at < to; { + step := to - at + if step > growPiece { + step = growPiece + } + if _, err := pf.f.WriteAt(zero[:int(step)*Size], Offset(core.PageID(at))); err != nil { + return err + } + at += step + } + return nil +} + func (pf *File) Allocate(kind Kind) (Page, error) { n, err := pf.PageCount() if err != nil { diff --git a/internal/pagestore/boundaries_test.go b/internal/pagestore/boundaries_test.go new file mode 100644 index 0000000..0a593f5 --- /dev/null +++ b/internal/pagestore/boundaries_test.go @@ -0,0 +1,338 @@ +package pagestore + +import ( + "bytes" + "encoding/binary" + "errors" + "os" + "strings" + "testing" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/page" +) + +func TestEachKindOfChangeAloneMakesCloseCheckpoint(t *testing.T) { + type setup struct { + name string + change func(t *testing.T, s *Store, p page.Page) + check func(t *testing.T, back *Store, p page.Page) + } + cases := []setup{ + {"set root", func(t *testing.T, s *Store, p page.Page) { + if err := s.SetRoot(p.ID()); err != nil { + t.Fatal(err) + } + }, func(t *testing.T, back *Store, p page.Page) { + if back.Root() != p.ID() { + t.Errorf("Close dropped a root change: root is %d, want %d", back.Root(), p.ID()) + } + }}, + {"write", func(t *testing.T, s *Store, p page.Page) { + copy(p[page.HeaderSize:], "changed") + if err := s.Write(p); err != nil { + t.Fatal(err) + } + }, func(t *testing.T, back *Store, p page.Page) { + got, err := back.Read(p.ID()) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(got[page.HeaderSize:], []byte("changed")) { + t.Errorf("Close dropped a page write") + } + }}, + {"allocate", func(t *testing.T, s *Store, p page.Page) { + mustAlloc(t, s) + }, func(t *testing.T, back *Store, p page.Page) { + if back.Meta().NextPage != p.ID()+2 { + t.Errorf("Close dropped an allocation: next page is %d, want %d", back.Meta().NextPage, p.ID()+2) + } + }}, + {"free", func(t *testing.T, s *Store, p page.Page) { + if err := s.Free(p.ID()); err != nil { + t.Fatal(err) + } + }, func(t *testing.T, back *Store, p page.Page) { + found := false + for _, id := range back.FreePages() { + found = found || id == p.ID() + } + if !found { + t.Errorf("Close dropped a free: page %d is not free after reopening", p.ID()) + } + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + p := mustAlloc(t, s) + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + if s.Dirty() { + t.Fatal("dirty right after a checkpoint") + } + c.change(t, s, p) + if !s.Dirty() { + t.Fatalf("%s did not mark the store dirty, so Close would skip the checkpoint", c.name) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + back := mustOpen(t, path) + defer back.Close() + c.check(t, back, p) + }) + } +} + +func TestStatsCountWhatActuallyHappened(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + for i := 0; i < ChunkPages-1; i++ { + mustAlloc(t, s) + } + if err := s.Free(3); err != nil { + t.Fatal(err) + } + if err := s.Free(4); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + mustAlloc(t, s) + got := s.Stats() + want := Stats{Grown: 2, Allocated: ChunkPages - 1, Reused: 1, Freed: 2, Checkpoints: 2} + if got != want { + t.Fatalf("stats are %+v, want %+v", got, want) + } +} + +func TestACheckpointThatGrowsTheFileForItsFreeListRecordsTheNewSize(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + for int(s.Meta().NextPage) < ChunkPages { + mustAlloc(t, s) + } + if err := s.Free(5); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + if got := s.ListPages(); len(got) != 1 || got[0] != core.PageID(ChunkPages) { + t.Fatalf("the free list should sit on page %d, the first page of a new chunk; it is on %v", ChunkPages, got) + } + if s.Meta().FilePages != 2*ChunkPages || s.Durable().FilePages != 2*ChunkPages { + t.Fatalf("the checkpoint grew the file but records %d live and %d durable pages, want %d", + s.Meta().FilePages, s.Durable().FilePages, 2*ChunkPages) + } + if err := s.Verify(); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + back := mustOpen(t, path) + defer back.Close() + if err := back.Verify(); err != nil { + t.Fatal(err) + } +} + +func TestThePageJustPastTheEndIsNotHandedOut(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + last := mustAlloc(t, s) + past := s.Meta().NextPage + if past != last.ID()+1 { + t.Fatalf("next page is %d, want %d", past, last.ID()+1) + } + if _, err := s.Read(last.ID()); err != nil { + t.Errorf("the last page handed out cannot be read: %v", err) + } + if _, err := s.Read(past); !errors.Is(err, ErrBadPageID) { + t.Errorf("Read of page %d, one past the end, gave %v", past, err) + } + if err := s.Free(past); !errors.Is(err, ErrBadPageID) { + t.Errorf("Free of page %d, one past the end, gave %v", past, err) + } + if err := s.SetRoot(past); !errors.Is(err, ErrBadPageID) { + t.Errorf("SetRoot of page %d, one past the end, gave %v", past, err) + } + if err := s.Write(page.New(past, page.KindHeap)); !errors.Is(err, ErrBadPageID) { + t.Errorf("Write of page %d, one past the end, gave %v", past, err) + } +} + +func TestEveryMethodRefusesAClosedStore(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + p := mustAlloc(t, s) + if err := s.Close(); err != nil { + t.Fatal(err) + } + calls := map[string]error{} + calls["SetRoot"] = s.SetRoot(p.ID()) + _, calls["Read"] = s.Read(p.ID()) + calls["Write"] = s.Write(p) + calls["Sync"] = s.Sync() + calls["Verify"] = s.Verify() + _, calls["Allocate"] = s.Allocate(page.KindHeap) + calls["Free"] = s.Free(p.ID()) + calls["Checkpoint"] = s.Checkpoint() + _, calls["DescribeMeta"] = s.DescribeMeta() + for name, err := range calls { + if !errors.Is(err, ErrClosed) { + t.Errorf("%s on a closed store gave %v, want ErrClosed", name, err) + } + } +} + +func TestDiscardClosesWithoutCheckpointingAndCanBeRepeated(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + p := mustAlloc(t, s) + if err := s.Discard(); err != nil { + t.Fatal(err) + } + if _, err := s.Read(p.ID()); !errors.Is(err, ErrClosed) { + t.Fatalf("Read straight after Discard gave %v, want ErrClosed", err) + } + if err := s.Discard(); err != nil { + t.Errorf("a second Discard gave %v, want nil", err) + } + if err := s.Close(); err != nil { + t.Errorf("Close after Discard gave %v, want nil", err) + } + back := mustOpen(t, path) + defer back.Close() + if back.Meta().NextPage != 1 { + t.Errorf("Discard checkpointed: next page is %d after reopening, want 1", back.Meta().NextPage) + } +} + +func boundariesStore(t *testing.T) *Store { + t.Helper() + s := mustCreate(t, tmpPath(t)) + for i := 0; i < 10; i++ { + mustAlloc(t, s) + } + if err := s.SetRoot(1); err != nil { + t.Fatal(err) + } + for _, id := range []core.PageID{2, 3, 4} { + if err := s.Free(id); err != nil { + t.Fatal(err) + } + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + if err := s.Free(6); err != nil { + t.Fatal(err) + } + if err := s.Verify(); err != nil { + t.Fatalf("clean store: %v", err) + } + return s +} + +func TestVerifyCatchesEachProblemThatOnlyOneOfItsChecksCanSee(t *testing.T) { + cases := []struct { + name string + corrupt func(*testing.T, *Store) + }{ + {"the meta page on disk is valid but is not the last checkpoint", func(t *testing.T, s *Store) { + newer := s.durable + newer.Checkpoint++ + if err := s.pf.Write(encodeMeta(newer)); err != nil { + t.Fatal(err) + } + }}, + {"the live next page is behind the last checkpoint", func(t *testing.T, s *Store) { + ahead := s.durable + ahead.NextPage = s.nextPage + 1 + if err := s.pf.Write(encodeMeta(ahead)); err != nil { + t.Fatal(err) + } + s.durable = ahead + }}, + {"a free page has no status at all, so it could be used after being freed", func(t *testing.T, s *Store) { + delete(s.status, s.free[0]) + }}, + {"a page past the end sits on the pending list", func(t *testing.T, s *Store) { + s.pending = append(s.pending, 9999) + s.status[9999] = stPending + }}, + {"a free page is marked pending", func(t *testing.T, s *Store) { + s.status[s.free[0]] = stPending + }}, + {"a free-list page is marked free", func(t *testing.T, s *Store) { + s.status[s.listPage[0]] = stFree + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s := boundariesStore(t) + defer s.Discard() + c.corrupt(t, s) + if err := s.Verify(); !errors.Is(err, ErrCorrupt) { + t.Errorf("Verify gave %v where %s, want ErrCorrupt", err, c.name) + } + }) + } +} + +func TestVerifyCatchesFreeListPagesInTheWrongOrder(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Discard() + n := FreeListCapacity + 50 + for i := 0; i < n; i++ { + mustAlloc(t, s) + } + for id := core.PageID(1); id <= core.PageID(n); id++ { + if err := s.Free(id); err != nil { + t.Fatal(err) + } + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + if len(s.listPage) != 2 { + t.Fatalf("expected two free-list pages, got %v", s.listPage) + } + if err := s.Verify(); err != nil { + t.Fatal(err) + } + s.listPage[0], s.listPage[1] = s.listPage[1], s.listPage[0] + if err := s.Verify(); !errors.Is(err, ErrCorrupt) { + t.Errorf("Verify accepted live free-list pages in a different order from the chain on disk: %v", err) + } +} + +func TestCreateTellsAStoreFromForeignDataOfAnySize(t *testing.T) { + for _, size := range []int{5, 23, 24, 100, 4096} { + path := tmpPath(t) + junk := bytes.Repeat([]byte("x"), size) + if err := os.WriteFile(path, junk, 0o644); err != nil { + t.Fatal(err) + } + _, err := Create(path) + if err == nil || !strings.Contains(err.Error(), "not a scutedb store") { + t.Errorf("Create over %d bytes of foreign data gave %v; it must not suggest Open", size, err) + } + } + path := tmpPath(t) + raw := make([]byte, 100) + copy(raw[offMagic:], Magic[:]) + binary.BigEndian.PutUint32(raw[offVersion:], Version) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Create(path); err == nil || !strings.Contains(err.Error(), "use Open") { + t.Errorf("Create over a file that carries the magic gave %v; it should point at Open", err) + } +} diff --git a/internal/pagestore/crash_test.go b/internal/pagestore/crash_test.go new file mode 100644 index 0000000..d7a9676 --- /dev/null +++ b/internal/pagestore/crash_test.go @@ -0,0 +1,86 @@ +package pagestore + +import ( + "bytes" + "testing" + + "github.com/scute-db/scutedb/internal/page" +) + +func TestFreeAfterACheckpointMustNotCorruptTheDurableTree(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + + old := mustAlloc(t, s) + copy(old[page.HeaderSize:], []byte("committed")) + if err := s.Write(old); err != nil { + t.Fatal(err) + } + if err := s.SetRoot(old.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + + replacement := mustAlloc(t, s) + if err := s.SetRoot(replacement.ID()); err != nil { + t.Fatal(err) + } + if err := s.Free(old.ID()); err != nil { + t.Fatal(err) + } + if err := s.Discard(); err != nil { + t.Fatal(err) + } + + back := mustOpen(t, path) + defer back.Close() + if back.Root() != old.ID() { + t.Fatalf("after the crash the root is %d, want the checkpointed root %d", back.Root(), old.ID()) + } + got, err := back.Read(back.Root()) + if err != nil { + t.Fatalf("reading the checkpointed root after a crash: %v", err) + } + if !bytes.HasPrefix(got[page.HeaderSize:], []byte("committed")) { + t.Fatalf("the checkpointed root was overwritten before the checkpoint that freed it: kind %s, bytes % 02X", + got.Kind(), got[page.HeaderSize:page.HeaderSize+9]) + } +} + +func TestReuseAfterACheckpointMustNotCorruptTheDurableFreeList(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + + a := mustAlloc(t, s) + b := mustAlloc(t, s) + mustAlloc(t, s) + if err := s.Free(a.ID()); err != nil { + t.Fatal(err) + } + if err := s.Free(b.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + + reused := mustAlloc(t, s) + copy(reused[page.HeaderSize:], []byte("new data")) + if err := s.Write(reused); err != nil { + t.Fatal(err) + } + if err := s.Discard(); err != nil { + t.Fatal(err) + } + + back := mustOpen(t, path) + defer back.Close() + if err := back.Verify(); err != nil { + t.Fatalf("a crash after reusing a free page left the durable free list corrupt: %v", err) + } + if back.Meta().FreeCount != 2 { + t.Errorf("after the crash the free count is %d, want the checkpointed 2", back.Meta().FreeCount) + } +} diff --git a/internal/pagestore/describe.go b/internal/pagestore/describe.go new file mode 100644 index 0000000..33a9eea --- /dev/null +++ b/internal/pagestore/describe.go @@ -0,0 +1,124 @@ +package pagestore + +import ( + "fmt" + "strings" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/page" +) + +func (s *Store) DescribeMeta() (string, error) { + if err := s.usable(); err != nil { + return "", err + } + p, err := s.pf.Read(MetaPage) + if err != nil { + return "", err + } + return describeMetaPage(p), nil +} + +func hexRows(p []byte, from, to int) string { + var b strings.Builder + for off := from; off < to; off += 16 { + fmt.Fprintf(&b, "%04X ", off) + for i := 0; i < 16; i++ { + if i == 8 { + b.WriteByte(' ') + } + fmt.Fprintf(&b, "%02X ", p[off+i]) + } + b.WriteString(" |") + for i := 0; i < 16; i++ { + c := p[off+i] + if c >= 0x20 && c < 0x7F { + b.WriteByte(c) + } else { + b.WriteByte('.') + } + } + b.WriteString("|\n") + } + return b.String() +} + +func describeMetaPage(p page.Page) string { + var b strings.Builder + fmt.Fprint(&b, "page 0, the meta page\n\n") + b.WriteString(hexRows(p, 0, 64)) + + fmt.Fprintf(&b, "\n%-9s %-26s %s\n", "offset", "bytes", "meaning") + fmt.Fprintln(&b, strings.Repeat("-", 76)) + row := func(off, size int, meaning string) { + fmt.Fprintf(&b, "%-9s %-26s %s\n", + fmt.Sprintf("%d..%d", off, off+size-1), + fmt.Sprintf("% 02X", p[off:off+size]), meaning) + } + row(0, 4, fmt.Sprintf("page id = %d", p.ID())) + row(4, 1, fmt.Sprintf("kind = %d (%s)", p[4], p.Kind())) + row(offMagic, 8, fmt.Sprintf("magic = %q, NUL-terminated", string(p[offMagic:offMagic+7]))) + + m, err := decodeMeta(p) + if err != nil { + fmt.Fprintf(&b, "\nnot a readable meta page: %v\n", err) + return b.String() + } + row(offVersion, 4, fmt.Sprintf("format version = %d", m.Version)) + row(offPageSize, 4, fmt.Sprintf("page size = %d", m.PageSize)) + row(offRoot, 4, rootLabel(m.Root)) + row(offFreeList, 4, listLabel(m.FreeListPage)) + row(offFreeCount, 4, fmt.Sprintf("free page count = %d", m.FreeCount)) + row(offNextPage, 4, fmt.Sprintf("next page to hand out = %d", m.NextPage)) + row(offFilePages, 4, fmt.Sprintf("pages the file holds = %d (%d bytes)", m.FilePages, int(m.FilePages)*page.Size)) + row(offCheckpoint, 4, fmt.Sprintf("checkpoint generation = %d", m.Checkpoint)) + return b.String() +} + +func rootLabel(id core.PageID) string { + if id == NoPage { + return "root = none yet (0 is safe: page 0 is always this page)" + } + return fmt.Sprintf("root = page %d", id) +} + +func listLabel(id core.PageID) string { + if id == NoPage { + return "free list = none (nothing is free)" + } + return fmt.Sprintf("free list starts on page %d", id) +} + +func joinIDs(ids []core.PageID) string { + if len(ids) == 0 { + return "none" + } + parts := make([]string, len(ids)) + for i, id := range ids { + parts[i] = fmt.Sprintf("%d", id) + } + return strings.Join(parts, " ") +} + +func (s *Store) DescribeFreeList() string { + var b strings.Builder + fmt.Fprintf(&b, "%-26s %s\n", "reusable now", joinIDs(s.free)) + fmt.Fprintf(&b, "%-26s %s\n", "pending, after checkpoint", joinIDs(s.pending)) + fmt.Fprintf(&b, "%-26s %s\n", "holding the free list", joinIDs(s.listPage)) + return b.String() +} + +func (s *Store) Describe() string { + var b strings.Builder + used := int(s.nextPage) - 1 - len(s.free) - len(s.pending) - len(s.listPage) + fmt.Fprintf(&b, "%-28s %d pages, %d bytes\n", "file holds", s.filePages, int(s.filePages)*page.Size) + fmt.Fprintf(&b, "%-28s %d\n", "handed out so far", int(s.nextPage)-1) + fmt.Fprintf(&b, "%-28s %d\n", " in use", used) + fmt.Fprintf(&b, "%-28s %d\n", " free, reusable now", len(s.free)) + fmt.Fprintf(&b, "%-28s %d\n", " freed, waiting on a checkpoint", len(s.pending)) + fmt.Fprintf(&b, "%-28s %d\n", " holding the free list", len(s.listPage)) + fmt.Fprintf(&b, "%-28s %d\n", "never handed out", s.filePages-uint32(s.nextPage)) + fmt.Fprintf(&b, "%-28s %s\n", "root", rootLabel(s.root)) + fmt.Fprintf(&b, "%-28s %d\n", "checkpoint generation", s.durable.Checkpoint) + return b.String() +} diff --git a/internal/pagestore/describe_test.go b/internal/pagestore/describe_test.go new file mode 100644 index 0000000..d238689 --- /dev/null +++ b/internal/pagestore/describe_test.go @@ -0,0 +1,93 @@ +package pagestore + +import ( + "errors" + "strconv" + "strings" + "testing" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/page" +) + +func describeStore(t *testing.T) *Store { + t.Helper() + s := mustCreate(t, tmpPath(t)) + for i := 0; i < 8; i++ { + mustAlloc(t, s) + } + if err := s.SetRoot(1); err != nil { + t.Fatal(err) + } + for _, id := range []core.PageID{3, 4, 5} { + if err := s.Free(id); err != nil { + t.Fatal(err) + } + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + if err := s.Free(7); err != nil { + t.Fatal(err) + } + return s +} + +func TestDescribeCountsEveryPageInExactlyOneState(t *testing.T) { + s := describeStore(t) + defer s.Close() + out := s.Describe() + m := s.Meta() + inUse := int(m.NextPage) - 1 - len(s.FreePages()) - len(s.PendingPages()) - len(s.ListPages()) + for _, want := range []string{ + "handed out so far", + "in use", "free, reusable now", "freed, waiting on a checkpoint", "holding the free list", + "root = page 1", + } { + if !strings.Contains(out, want) { + t.Errorf("Describe is missing %q\n%s", want, out) + } + } + if inUse <= 0 || !strings.Contains(out, " in use") { + t.Fatalf("expected some pages in use, got %d\n%s", inUse, out) + } +} + +func TestDescribeFreeListNamesEachKindOfFreePage(t *testing.T) { + s := describeStore(t) + defer s.Close() + out := s.DescribeFreeList() + if !strings.Contains(out, "pending, after checkpoint 7") { + t.Errorf("the pending page 7 is not listed:\n%s", out) + } + for _, id := range s.FreePages() { + if !strings.Contains(out, " "+strconv.Itoa(int(id))) { + t.Errorf("free page %d is not listed:\n%s", id, out) + } + } + if joinIDs(nil) != "none" { + t.Errorf("an empty list prints as %q, want none", joinIDs(nil)) + } +} + +func TestDescribeMetaOfAPoisonedStoreReturnsTheError(t *testing.T) { + mf := newMemFile() + s, err := create(page.NewFile(mf)) + if err != nil { + t.Fatal(err) + } + mf.failAtSync = mf.syncs + 1 + if err := s.Sync(); !errors.Is(err, ErrPoisoned) { + t.Fatalf("want ErrPoisoned, got %v", err) + } + if _, err := s.DescribeMeta(); !errors.Is(err, ErrPoisoned) { + t.Errorf("DescribeMeta on a poisoned store gave %v, want ErrPoisoned", err) + } +} + +func TestDescribeOfAPageThatIsNotAMetaPageSaysSo(t *testing.T) { + out := describeMetaPage(page.New(0, page.KindHeap)) + if !strings.Contains(out, "not a readable meta page") { + t.Errorf("describing a heap page as a meta page should say it is unreadable:\n%s", out) + } +} diff --git a/internal/pagestore/faults_test.go b/internal/pagestore/faults_test.go new file mode 100644 index 0000000..9d23efc --- /dev/null +++ b/internal/pagestore/faults_test.go @@ -0,0 +1,1333 @@ +package pagestore + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "sort" + "strings" + "testing" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/fileio" + "github.com/scute-db/scutedb/internal/page" +) + +type faultsKind int + +const ( + faultsWrite faultsKind = iota + faultsTorn + faultsSync + faultsRead + faultsSize + faultsKindCount +) + +const faultsSector = 512 + +var ( + faultsErrWrite = errors.New("faults: injected write failure") + faultsErrTorn = errors.New("faults: injected torn write") + faultsErrRead = errors.New("faults: injected read failure") + faultsErrSize = errors.New("faults: injected size failure") +) + +func (k faultsKind) String() string { + switch k { + case faultsWrite: + return "failed write" + case faultsTorn: + return "torn write" + case faultsSync: + return "failed fsync" + case faultsRead: + return "failed read" + case faultsSize: + return "failed size" + } + return fmt.Sprintf("fault %d", int(k)) +} + +func (k faultsKind) counter() faultsKind { + if k == faultsTorn { + return faultsWrite + } + return k +} + +func (k faultsKind) cause() error { + switch k { + case faultsWrite: + return faultsErrWrite + case faultsTorn: + return faultsErrTorn + case faultsSync: + return errSyncFailed + case faultsRead: + return faultsErrRead + } + return faultsErrSize +} + +type faultsFile struct { + mf *memFile + seen [faultsKindCount]int + kind faultsKind + at int + keep func(int) bool + tear int + fired bool + firedAtMeta bool + metaBefore bool + metaWritten bool +} + +var _ fileio.File = (*faultsFile)(nil) + +func faultsWrap(mf *memFile) *faultsFile { return &faultsFile{mf: mf} } + +func (f *faultsFile) reset() { *f = faultsFile{mf: f.mf} } + +func (f *faultsFile) arm(kind faultsKind, at int, keep func(int) bool, tear int) { + f.reset() + f.kind, f.at, f.keep, f.tear = kind, at, keep, tear +} + +func (f *faultsFile) trip(kind faultsKind) bool { + f.seen[kind]++ + if f.at == 0 || f.fired || f.kind.counter() != kind || f.seen[kind] != f.at { + return false + } + f.fired = true + return true +} + +func faultsTearAt(n, sel int) int { + sectors := n / faultsSector + if sectors < 2 { + return 0 + } + if sel < 0 { + return (sectors - 1) * faultsSector + } + return (1 + sel%(sectors-1)) * faultsSector +} + +func (f *faultsFile) landed(off int64) { + if off < page.Size { + f.metaWritten = true + } +} + +func (f *faultsFile) WriteAt(p []byte, off int64) (int, error) { + if f.trip(faultsWrite) { + f.metaBefore, f.firedAtMeta = f.metaWritten, off < page.Size + if f.kind == faultsWrite { + return 0, faultsErrWrite + } + n := faultsTearAt(len(p), f.tear) + if n > 0 { + if _, err := f.mf.WriteAt(p[:n], off); err != nil { + return 0, err + } + f.landed(off) + } + return n, faultsErrTorn + } + n, err := f.mf.WriteAt(p, off) + if err == nil { + f.landed(off) + } + return n, err +} + +func (f *faultsFile) Sync() error { + if !f.trip(faultsSync) { + return f.mf.Sync() + } + f.mf.failAtSync, f.mf.failKeep = f.mf.syncs+1, f.keep + err := f.mf.Sync() + f.mf.failAtSync, f.mf.failKeep = 0, nil + return err +} + +func (f *faultsFile) ReadAt(p []byte, off int64) (int, error) { + if f.trip(faultsRead) { + n, _ := f.mf.ReadAt(p[:len(p)/2], off) + return n, faultsErrRead + } + return f.mf.ReadAt(p, off) +} + +func (f *faultsFile) Size() (int64, error) { + if f.trip(faultsSize) { + return 0, faultsErrSize + } + return f.mf.Size() +} + +func (f *faultsFile) Close() error { return f.mf.Close() } + +func faultsClone(m *memFile) *memFile { + return &memFile{ + durable: append([]byte(nil), m.durable...), + live: append([]byte(nil), m.live...), + log: append([]memWrite(nil), m.log...), + syncs: m.syncs, + closed: m.closed, + } +} + +func faultsSubset(p byte) func(int) bool { + return func(i int) bool { return (uint(p)>>(uint(i)%8))&1 == 1 } +} + +type faultsGen struct { + inUse map[core.PageID]uint64 + root core.PageID +} + +func faultsEmpty() faultsGen { return faultsGen{inUse: map[core.PageID]uint64{}} } + +func (g faultsGen) copy() faultsGen { + c := faultsGen{inUse: make(map[core.PageID]uint64, len(g.inUse)), root: g.root} + for id, tag := range g.inUse { + c.inUse[id] = tag + } + return c +} + +func (g faultsGen) ids() []core.PageID { + ids := make([]core.PageID, 0, len(g.inUse)) + for id := range g.inUse { + ids = append(ids, id) + } + sort.Slice(ids, func(a, b int) bool { return ids[a] < ids[b] }) + return ids +} + +type faultsModel struct { + live faultsGen + gen uint32 + gens map[uint32]faultsGen + tag uint64 +} + +func faultsNewModel(gen uint32) *faultsModel { + return &faultsModel{live: faultsEmpty(), gen: gen, gens: map[uint32]faultsGen{gen: faultsEmpty()}} +} + +func (m *faultsModel) durable() faultsGen { return m.gens[m.gen] } + +func (m *faultsModel) commit(gen uint32) { + m.gen = gen + m.gens[gen] = m.live.copy() +} + +func (m *faultsModel) revert() { m.live = m.gens[m.gen].copy() } + +func (m *faultsModel) clone() *faultsModel { + c := &faultsModel{live: m.live.copy(), gen: m.gen, tag: m.tag, gens: make(map[uint32]faultsGen, len(m.gens))} + for g, v := range m.gens { + c.gens[g] = v.copy() + } + return c +} + +func faultsGens(allowed map[uint32]faultsGen) []uint32 { + var out []uint32 + for g := range allowed { + out = append(out, g) + } + sort.Slice(out, func(a, b int) bool { return out[a] < out[b] }) + return out +} + +type faultsSnap struct { + durable Meta + meta Meta + free []core.PageID + pending []core.PageID + lists []core.PageID + root core.PageID + dirty bool +} + +func faultsSnapOf(s *Store) faultsSnap { + return faultsSnap{ + durable: s.Durable(), + meta: s.Meta(), + free: s.FreePages(), + pending: s.PendingPages(), + lists: s.ListPages(), + root: s.Root(), + dirty: s.Dirty(), + } +} + +func faultsSameIDs(a, b []core.PageID) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func faultsDiff(was, now faultsSnap) string { + var out []string + if was.durable != now.durable { + out = append(out, fmt.Sprintf("Durable() went from %+v to %+v", was.durable, now.durable)) + } + if was.meta != now.meta { + out = append(out, fmt.Sprintf("Meta() went from %+v to %+v", was.meta, now.meta)) + } + if !faultsSameIDs(was.free, now.free) { + out = append(out, fmt.Sprintf("FreePages() went from %v to %v", was.free, now.free)) + } + if !faultsSameIDs(was.pending, now.pending) { + out = append(out, fmt.Sprintf("PendingPages() went from %v to %v", was.pending, now.pending)) + } + if !faultsSameIDs(was.lists, now.lists) { + out = append(out, fmt.Sprintf("ListPages() went from %v to %v", was.lists, now.lists)) + } + if was.root != now.root { + out = append(out, fmt.Sprintf("Root() went from %d to %d", was.root, now.root)) + } + if was.dirty != now.dirty { + out = append(out, fmt.Sprintf("Dirty() went from %v to %v", was.dirty, now.dirty)) + } + return strings.Join(out, "; ") +} + +func faultsCheck(t *testing.T, where string, s *Store, want faultsGen, tags bool) { + t.Helper() + if err := s.Verify(); err != nil { + t.Fatalf("%s: Verify rejected a reachable state: %v", where, err) + } + if s.Root() != want.root { + t.Fatalf("%s: the root is page %d, want %d", where, s.Root(), want.root) + } + next := s.Meta().NextPage + owner := make(map[core.PageID]string, int(next)) + claim := func(ids []core.PageID, as string) { + for _, id := range ids { + if id == MetaPage || uint32(id) >= uint32(next) { + t.Fatalf("%s: page %d is %s, but only pages 1..%d are handed out", where, id, as, next-1) + } + if prev, dup := owner[id]; dup { + t.Fatalf("%s: page %d is both %s and %s", where, id, prev, as) + } + owner[id] = as + } + } + ids := want.ids() + claim(ids, "in use") + claim(s.FreePages(), "free") + claim(s.PendingPages(), "pending") + claim(s.ListPages(), "a free-list page") + for id := core.PageID(1); id < next; id++ { + if _, ok := owner[id]; !ok { + t.Fatalf("%s: page %d was handed out and is now in no state at all, so it leaked", where, id) + } + } + if !tags { + return + } + for _, id := range ids { + p, err := s.Read(id) + if err != nil { + t.Fatalf("%s: reading in-use page %d: %v", where, id, err) + } + if got := binary.BigEndian.Uint64(p[page.HeaderSize:]); p.ID() != id || got != want.inUse[id] { + t.Fatalf("%s: page %d says it is page %d and holds tag %d, want tag %d", + where, id, p.ID(), got, want.inUse[id]) + } + } +} + +func faultsJudge(t *testing.T, where string, kind faultsKind, fired bool, err error) { + t.Helper() + if !fired { + t.Fatalf("%s: the fault never fired, so the operation did different I/O than its unfaulted run", where) + } + if err == nil { + t.Fatalf("%s: the operation succeeded, so the injected %s was swallowed", where, kind) + } + if !errors.Is(err, kind.cause()) { + t.Fatalf("%s: got %v, which does not carry the injected %q", where, err, kind.cause()) + } + for _, wrong := range []error{ErrCorrupt, ErrNotScuteDB, ErrBadVersion, ErrBadPageSize, ErrFull, core.ErrShortPage} { + if errors.Is(err, wrong) { + t.Fatalf("%s: an I/O failure was reported as %q: %v", where, wrong, err) + } + } +} + +func faultsRules(t *testing.T, where string, f faultsFile, err error, store bool) { + t.Helper() + faultsJudge(t, where, f.kind, f.fired, err) + if !store { + return + } + poisoned := errors.Is(err, ErrPoisoned) + if f.kind == faultsSync && !poisoned { + t.Fatalf("%s: an fsync failed and the store was not poisoned: %v", where, err) + } + if (f.kind == faultsWrite || f.kind == faultsTorn) && !f.metaBefore && !f.firedAtMeta && poisoned { + t.Fatalf("%s: a write failed before page 0 was touched, so nothing durable changed, "+ + "yet the store was poisoned: %v", where, err) + } +} + +func faultsCheckPoisoned(t *testing.T, where string, s *Store, cause error, closed bool) { + t.Helper() + calls := []struct { + name string + call func() error + }{ + {"Allocate", func() error { _, err := s.Allocate(page.KindHeap); return err }}, + {"Free", func() error { return s.Free(1) }}, + {"SetRoot", func() error { return s.SetRoot(NoPage) }}, + {"Write", func() error { return s.Write(page.New(1, page.KindHeap)) }}, + {"Read", func() error { _, err := s.Read(1); return err }}, + {"Verify", s.Verify}, + {"DescribeMeta", func() error { _, err := s.DescribeMeta(); return err }}, + {"Checkpoint", s.Checkpoint}, + {"Sync", s.Sync}, + } + if !closed { + calls = append(calls, []struct { + name string + call func() error + }{ + {"Close", s.Close}, + {"Allocate after Close", func() error { _, err := s.Allocate(page.KindHeap); return err }}, + {"Checkpoint after Close", s.Checkpoint}, + }...) + } + for _, c := range calls { + err := c.call() + if !errors.Is(err, ErrPoisoned) || !errors.Is(err, cause) { + t.Fatalf("%s: after the store was poisoned, %s gave %v; want ErrPoisoned still carrying %q", + where, c.name, err, cause) + } + } +} + +type faultsOp struct { + name string + commits bool + closes bool + noWrites bool + targets func(m *faultsModel) []core.PageID + do func(t *testing.T, s *Store, m *faultsModel, pick byte) error +} + +func faultsFreeTargets(m *faultsModel) []core.PageID { + var out []core.PageID + for _, id := range m.live.ids() { + if id != m.live.root { + out = append(out, id) + } + } + return out +} + +func faultsRootTargets(m *faultsModel) []core.PageID { + return append([]core.PageID{NoPage}, m.live.ids()...) +} + +func faultsWriteTargets(m *faultsModel) []core.PageID { + durable := m.durable().inUse + var fresh []core.PageID + for _, id := range m.live.ids() { + if _, ok := durable[id]; !ok { + fresh = append(fresh, id) + } + } + if len(fresh) > 0 { + return fresh + } + return m.live.ids() +} + +func faultsReadTargets(m *faultsModel) []core.PageID { return m.live.ids() } + +func faultsPick(ids []core.PageID, pick byte) (core.PageID, bool) { + if len(ids) == 0 { + return NoPage, false + } + return ids[int(pick)%len(ids)], true +} + +var faultsOps = []faultsOp{ + {name: "Allocate", do: func(t *testing.T, s *Store, m *faultsModel, pick byte) error { + t.Helper() + p, err := s.Allocate(page.KindHeap) + if err != nil { + return err + } + id := p.ID() + if _, ok := m.live.inUse[id]; ok { + t.Fatalf("Allocate handed out page %d, which is still in use", id) + } + if _, ok := m.durable().inUse[id]; ok { + t.Fatalf("Allocate handed out page %d, which checkpoint %d still references", id, m.gen) + } + m.live.inUse[id] = 0 + return nil + }}, + {name: "Free", noWrites: true, targets: faultsFreeTargets, do: func(t *testing.T, s *Store, m *faultsModel, pick byte) error { + id, ok := faultsPick(faultsFreeTargets(m), pick) + if !ok { + return nil + } + if err := s.Free(id); err != nil { + return err + } + delete(m.live.inUse, id) + return nil + }}, + {name: "SetRoot", targets: faultsRootTargets, do: func(t *testing.T, s *Store, m *faultsModel, pick byte) error { + id, _ := faultsPick(faultsRootTargets(m), pick) + if err := s.SetRoot(id); err != nil { + return err + } + m.live.root = id + return nil + }}, + {name: "Write", targets: faultsWriteTargets, do: func(t *testing.T, s *Store, m *faultsModel, pick byte) error { + id, ok := faultsPick(faultsWriteTargets(m), pick) + if !ok { + return nil + } + tag := m.live.inUse[id] + if _, durable := m.durable().inUse[id]; !durable { + m.tag++ + tag = m.tag + } + p := page.New(id, page.KindHeap) + binary.BigEndian.PutUint64(p[page.HeaderSize:], tag) + if err := s.Write(p); err != nil { + return err + } + m.live.inUse[id] = tag + return nil + }}, + {name: "Read", targets: faultsReadTargets, do: func(t *testing.T, s *Store, m *faultsModel, pick byte) error { + t.Helper() + id, ok := faultsPick(faultsReadTargets(m), pick) + if !ok { + return nil + } + p, err := s.Read(id) + if err != nil { + return err + } + if got := binary.BigEndian.Uint64(p[page.HeaderSize:]); got != m.live.inUse[id] { + t.Fatalf("Read(%d) returned tag %d, want %d", id, got, m.live.inUse[id]) + } + return nil + }}, + {name: "Verify", do: func(t *testing.T, s *Store, m *faultsModel, pick byte) error { return s.Verify() }}, + {name: "DescribeMeta", do: func(t *testing.T, s *Store, m *faultsModel, pick byte) error { + _, err := s.DescribeMeta() + return err + }}, + {name: "Checkpoint", commits: true, do: func(t *testing.T, s *Store, m *faultsModel, pick byte) error { + if err := s.Checkpoint(); err != nil { + return err + } + m.commit(s.Durable().Checkpoint) + return nil + }}, + {name: "Sync", do: func(t *testing.T, s *Store, m *faultsModel, pick byte) error { return s.Sync() }}, + {name: "Close", commits: true, closes: true, do: func(t *testing.T, s *Store, m *faultsModel, pick byte) error { + if err := s.Close(); err != nil { + return err + } + m.commit(s.Durable().Checkpoint) + return nil + }}, +} + +func faultsOpNamed(t *testing.T, name string) faultsOp { + t.Helper() + for _, op := range faultsOps { + if op.name == name { + return op + } + } + t.Fatalf("no operation named %s", name) + return faultsOp{} +} + +type faultsBench struct { + t *testing.T + mf *memFile + ff *faultsFile + s *Store + m *faultsModel +} + +func faultsFresh(t *testing.T) *faultsBench { + t.Helper() + mf := newMemFile() + ff := faultsWrap(mf) + s, err := create(page.NewFile(ff)) + if err != nil { + t.Fatalf("create: %v", err) + } + return &faultsBench{t: t, mf: mf, ff: ff, s: s, m: faultsNewModel(s.Durable().Checkpoint)} +} + +func (b *faultsBench) alloc() core.PageID { + b.t.Helper() + p, err := b.s.Allocate(page.KindHeap) + if err != nil { + b.t.Fatalf("Allocate: %v", err) + } + b.m.tag++ + binary.BigEndian.PutUint64(p[page.HeaderSize:], b.m.tag) + if err := b.s.Write(p); err != nil { + b.t.Fatalf("Write: %v", err) + } + b.m.live.inUse[p.ID()] = b.m.tag + return p.ID() +} + +func (b *faultsBench) free(id core.PageID) { + b.t.Helper() + if err := b.s.Free(id); err != nil { + b.t.Fatalf("Free(%d): %v", id, err) + } + delete(b.m.live.inUse, id) +} + +func (b *faultsBench) setRoot(id core.PageID) { + b.t.Helper() + if err := b.s.SetRoot(id); err != nil { + b.t.Fatalf("SetRoot(%d): %v", id, err) + } + b.m.live.root = id +} + +func (b *faultsBench) checkpoint() { + b.t.Helper() + if err := b.s.Checkpoint(); err != nil { + b.t.Fatalf("Checkpoint: %v", err) + } + b.m.commit(b.s.Durable().Checkpoint) +} + +func (b *faultsBench) close() { + b.t.Helper() + if err := b.s.Close(); err != nil { + b.t.Fatalf("Close: %v", err) + } + b.m.commit(b.s.Durable().Checkpoint) + b.mf.restart() +} + +func faultsBusy(t *testing.T) *faultsBench { + t.Helper() + b := faultsFresh(t) + var ids []core.PageID + for i := 0; i < 8; i++ { + ids = append(ids, b.alloc()) + } + b.setRoot(ids[0]) + b.checkpoint() + for _, id := range ids[2:5] { + b.free(id) + } + b.checkpoint() + b.alloc() + b.free(ids[5]) + b.setRoot(ids[1]) + b.alloc() + return b +} + +func faultsClean(t *testing.T) *faultsBench { + t.Helper() + b := faultsBusy(t) + b.checkpoint() + return b +} + +func faultsEdge(t *testing.T) *faultsBench { + t.Helper() + b := faultsFresh(t) + var ids []core.PageID + for uint32(b.s.Meta().NextPage) < b.s.Meta().FilePages { + ids = append(ids, b.alloc()) + } + b.setRoot(ids[0]) + b.checkpoint() + b.free(ids[1]) + b.free(ids[2]) + return b +} + +func faultsLongImage(t *testing.T) (*memFile, *faultsModel) { + t.Helper() + b := faultsFresh(t) + var ids []core.PageID + for i := 0; i < FreeListCapacity+4; i++ { + ids = append(ids, b.alloc()) + } + b.setRoot(ids[0]) + b.checkpoint() + for _, id := range ids[1:] { + b.free(id) + } + b.checkpoint() + if len(b.s.ListPages()) < 2 { + t.Fatalf("expected a free list of at least two pages, got %v", b.s.ListPages()) + } + b.close() + return b.mf, b.m +} + +func faultsFromImage(t *testing.T, img *memFile, m *faultsModel) *faultsBench { + t.Helper() + mf := faultsClone(img) + ff := faultsWrap(mf) + s, _, err := openFile(ff) + if err != nil { + t.Fatalf("opening the prepared image: %v", err) + } + return &faultsBench{t: t, mf: mf, ff: ff, s: s, m: m.clone()} +} + +type faultsCrash struct { + name string + apply func(*memFile) +} + +var faultsCrashes = []faultsCrash{ + {"process crash", func(m *memFile) { m.restart() }}, + {"process crash that evicts every clean page", func(m *memFile) { + m.restartEvicting(func(int) bool { return true }) + }}, + {"process crash that evicts odd pages", func(m *memFile) { + m.restartEvicting(func(b int) bool { return b%2 == 1 }) + }}, + {"power loss that keeps nothing", func(m *memFile) { m.keepNothing() }}, + {"power loss that keeps every write", func(m *memFile) { m.powerLoss(func(int) bool { return true }) }}, + {"power loss that keeps alternate writes", func(m *memFile) { + m.powerLoss(func(i int) bool { return i%2 == 0 }) + }}, +} + +var faultsFewCrashes = []faultsCrash{faultsCrashes[0], faultsCrashes[2], faultsCrashes[5]} + +func faultsRecover(t *testing.T, where string, mf *memFile, allowed map[uint32]faultsGen, keepDrain bool) { + t.Helper() + back, _, err := openFile(faultsWrap(mf)) + if err != nil { + t.Fatalf("%s: the file no longer opens: %v", where, err) + } + g := back.Durable().Checkpoint + want, ok := allowed[g] + if !ok { + back.Discard() + t.Fatalf("%s: recovered checkpoint %d, want one of %v", where, g, faultsGens(allowed)) + } + faultsCheck(t, where, back, want, true) + for n := 0; n < 40 && len(back.FreePages()) > 0; n++ { + p, err := back.Allocate(page.KindHeap) + if err != nil { + t.Fatalf("%s: allocating a free page after recovery: %v", where, err) + } + if _, ok := want.inUse[p.ID()]; ok { + t.Fatalf("%s: page %d was handed out although recovered checkpoint %d still uses it", where, p.ID(), g) + } + binary.BigEndian.PutUint64(p[page.HeaderSize:], 0xDEADBEEFDEADBEEF) + if err := back.Write(p); err != nil { + t.Fatalf("%s: overwriting a reused page: %v", where, err) + } + } + back.Discard() + mf.powerLoss(func(int) bool { return keepDrain }) + again, _, err := openFile(faultsWrap(mf)) + if err != nil { + t.Fatalf("%s: after reusing free pages and losing power, the file no longer opens: %v", where, err) + } + defer again.Discard() + if got := again.Durable().Checkpoint; got != g { + t.Fatalf("%s: Open recovered checkpoint %d, but a power loss afterwards went back to %d; "+ + "Open handed out pages on the strength of a meta page that was not durable", where, g, got) + } + faultsCheck(t, where+", after a later power loss", again, want, true) +} + +func faultsCrashAll(t *testing.T, where string, img *memFile, allowed map[uint32]faultsGen, crashes []faultsCrash) { + t.Helper() + for i, c := range crashes { + mf := img + if i < len(crashes)-1 { + mf = faultsClone(img) + } + c.apply(mf) + faultsRecover(t, where+", then a "+c.name, mf, allowed, i%2 == 0) + } +} + +type faultsVariant struct { + name string + keep func(int) bool + tear int +} + +func faultsWhere(subject string, kind faultsKind, at int, v faultsVariant) string { + where := fmt.Sprintf("%s with a %s on call %d", subject, kind, at) + if v.name != "" { + where += " " + v.name + } + return where +} + +func faultsVariants(kind faultsKind) []faultsVariant { + switch kind { + case faultsTorn: + return []faultsVariant{ + {"keeping the first sector", nil, 0}, + {"keeping all but the last sector", nil, -1}, + } + case faultsSync: + return []faultsVariant{ + {"dropping every write", func(int) bool { return false }, 0}, + {"keeping every write", func(int) bool { return true }, 0}, + {"keeping alternate writes", func(i int) bool { return i%2 == 0 }, 0}, + } + } + return []faultsVariant{{}} +} + +func faultsPoint(t *testing.T, build func(*testing.T) *faultsBench, op faultsOp, kind faultsKind, at int, + v faultsVariant, want faultsSnap, crashes []faultsCrash) { + t.Helper() + b := build(t) + where := faultsWhere(op.name, kind, at, v) + g := b.s.Durable().Checkpoint + before := faultsSnapOf(b.s) + live := b.m.live.copy() + allowed := map[uint32]faultsGen{g: b.m.durable()} + + b.ff.arm(kind, at, v.keep, v.tear) + err := op.do(t, b.s, b.m, 0) + flags := *b.ff + b.ff.reset() + faultsRules(t, where, flags, err, true) + if op.commits && flags.metaWritten { + allowed[g+1] = live + } + img := faultsClone(b.mf) + + if errors.Is(err, ErrPoisoned) { + faultsCheckPoisoned(t, where, b.s, kind.cause(), op.closes) + } else { + if diff := faultsDiff(before, faultsSnapOf(b.s)); diff != "" { + t.Fatalf("%s: a failure that did not poison changed the store: %s", where, diff) + } + if op.closes { + if err := b.s.Close(); err != nil { + t.Fatalf("%s: a second Close gave %v", where, err) + } + } else { + faultsCheck(t, where+", before retrying", b.s, b.m.live, false) + if err := op.do(t, b.s, b.m, 0); err != nil { + t.Fatalf("%s: retrying after a failure that did not poison: %v", where, err) + } + if diff := faultsDiff(want, faultsSnapOf(b.s)); diff != "" { + t.Fatalf("%s: the retry did not end where an unfaulted run ends: %s", where, diff) + } + faultsCheck(t, where+", after retrying", b.s, b.m.live, true) + } + } + b.s.Discard() + faultsCrashAll(t, where, img, allowed, crashes) +} + +func faultsMatrix(t *testing.T, build func(*testing.T) *faultsBench, op faultsOp, crashes []faultsCrash) int { + t.Helper() + dry := build(t) + if op.targets != nil && len(op.targets(dry.m)) == 0 { + dry.s.Discard() + return 0 + } + dry.ff.reset() + if err := op.do(t, dry.s, dry.m, 0); err != nil { + t.Fatalf("%s with no fault injected: %v", op.name, err) + } + seen := dry.ff.seen + want := faultsSnapOf(dry.s) + dry.s.Discard() + if op.noWrites && seen[faultsWrite] != 0 { + t.Fatalf("%s wrote to the file %d times; a freed page must stay untouched until a checkpoint "+ + "stops referencing it", op.name, seen[faultsWrite]) + } + points := 0 + for kind := faultsWrite; kind < faultsKindCount; kind++ { + for at := 1; at <= seen[kind.counter()]; at++ { + for _, v := range faultsVariants(kind) { + faultsPoint(t, build, op, kind, at, v, want, crashes) + points++ + } + } + } + return points +} + +func TestTheFaultFileFailsExactlyTheCallItIsArmedFor(t *testing.T) { + a := bytes.Repeat([]byte{0xAA}, page.Size) + b := bytes.Repeat([]byte{0xBB}, page.Size) + + ff := faultsWrap(newMemFile()) + ff.arm(faultsWrite, 2, nil, 0) + for i, want := range []error{nil, faultsErrWrite, nil} { + n, err := ff.WriteAt(a, int64(i)*page.Size) + if !errors.Is(err, want) || (want == nil) != (n == page.Size) || (want != nil && n != 0) { + t.Fatalf("write %d gave %d, %v; want only the second write to fail, writing nothing", i+1, n, err) + } + } + if size, _ := ff.Size(); size != 3*page.Size { + t.Fatalf("after a failed middle write the file is %d bytes, want %d", size, 3*page.Size) + } + if !bytes.Equal(ff.mf.live[page.Size:2*page.Size], make([]byte, page.Size)) { + t.Fatal("a failed write changed the bytes it was asked to write") + } + + for _, c := range []struct { + tear, want int + }{{0, faultsSector}, {-1, page.Size - faultsSector}, {3, 4 * faultsSector}} { + ff := faultsWrap(newMemFile()) + ff.arm(faultsTorn, 1, nil, c.tear) + n, err := ff.WriteAt(a, 0) + if n != c.want || !errors.Is(err, faultsErrTorn) { + t.Fatalf("torn write with tear %d gave %d, %v; want %d bytes and the torn error", c.tear, n, err, c.want) + } + if !bytes.Equal(ff.mf.live, a[:c.want]) || !ff.metaWritten || !ff.firedAtMeta { + t.Fatalf("torn write with tear %d left %d bytes in the cache, want a %d-byte prefix", c.tear, len(ff.mf.live), c.want) + } + } + + ff = faultsWrap(newMemFile()) + if _, err := ff.WriteAt(a, 0); err != nil { + t.Fatal(err) + } + if _, err := ff.WriteAt(b, page.Size); err != nil { + t.Fatal(err) + } + ff.arm(faultsSync, 1, func(i int) bool { return i == 0 }, 0) + if err := ff.Sync(); !errors.Is(err, errSyncFailed) { + t.Fatalf("an armed fsync gave %v, want the injected failure", err) + } + ff.reset() + if err := ff.Sync(); err != nil { + t.Fatalf("an fsync after the failure gave %v", err) + } + if !bytes.Equal(ff.mf.durable, a) || !bytes.Equal(ff.mf.live[page.Size:], b) { + t.Fatalf("after a failed fsync that kept only the first write, the disk holds %d bytes; "+ + "the dropped write must stay readable in the cache and never reach the disk", len(ff.mf.durable)) + } + + ff.arm(faultsRead, 1, nil, 0) + buf := make([]byte, page.Size) + if n, err := ff.ReadAt(buf, 0); n != page.Size/2 || !errors.Is(err, faultsErrRead) { + t.Fatalf("an armed read gave %d, %v; want half a page and the injected failure", n, err) + } + if n, err := ff.ReadAt(buf, 0); n != page.Size || err != nil { + t.Fatalf("the read after the failure gave %d, %v", n, err) + } + + ff.arm(faultsSize, 2, nil, 0) + if _, err := ff.Size(); err != nil { + t.Fatalf("the first size call failed: %v", err) + } + if _, err := ff.Size(); !errors.Is(err, faultsErrSize) { + t.Fatalf("the second size call gave %v, want the injected failure", err) + } + if ff.seen[faultsSize] != 2 || ff.seen[faultsRead] != 0 { + t.Fatalf("counted %v calls since arming", ff.seen) + } +} + +func TestEveryFailurePointInCreateLeavesAFileThatOpensAtTheFirstGeneration(t *testing.T) { + dry := faultsWrap(newMemFile()) + if _, err := create(page.NewFile(dry)); err != nil { + t.Fatal(err) + } + seen := dry.seen + if seen[faultsWrite] == 0 || seen[faultsSync] < 2 { + t.Fatalf("create wrote %d times and synced %d times; it must write the meta page and fsync before and after", + seen[faultsWrite], seen[faultsSync]) + } + first := map[uint32]faultsGen{1: faultsEmpty()} + points := 0 + for kind := faultsWrite; kind < faultsKindCount; kind++ { + for at := 1; at <= seen[kind.counter()]; at++ { + for _, v := range faultsVariants(kind) { + where := faultsWhere("create", kind, at, v) + mf := newMemFile() + ff := faultsWrap(mf) + ff.arm(kind, at, v.keep, v.tear) + s, err := create(page.NewFile(ff)) + flags := *ff + ff.reset() + if s != nil { + s.Discard() + } + faultsRules(t, where, flags, err, true) + img := faultsClone(mf) + if !errors.Is(err, ErrPoisoned) { + again, err := create(page.NewFile(ff)) + if err != nil { + t.Fatalf("%s: retrying create after a failure that did not poison: %v", where, err) + } + if g := again.Durable().Checkpoint; g != 1 { + t.Fatalf("%s: the retried create is at checkpoint %d, want 1", where, g) + } + faultsCheck(t, where+", after retrying", again, faultsEmpty(), true) + again.Discard() + } + faultsCrashAll(t, where, img, first, faultsCrashes) + points++ + } + } + } + t.Logf("exercised %d failure points inside create", points) +} + +func TestEveryFailurePointInAStoreOperationEitherPoisonsOrLeavesTheStoreAsItWas(t *testing.T) { + setups := []struct { + name string + build func(*testing.T) *faultsBench + }{ + {"fresh", faultsFresh}, + {"busy", faultsBusy}, + {"clean", faultsClean}, + {"at the end of a chunk", faultsEdge}, + } + total := 0 + for _, su := range setups { + for _, op := range faultsOps { + su, op := su, op + t.Run(su.name+"/"+op.name, func(t *testing.T) { + n := faultsMatrix(t, su.build, op, faultsCrashes) + total += n + t.Logf("exercised %d failure points", n) + }) + } + } + t.Logf("exercised %d failure points across every store operation", total) + if total < 100 { + t.Fatalf("only %d failure points were found; the operations stopped doing the I/O this test expects", total) + } +} + +func TestEveryFailurePointInACheckpointThatWritesTwoFreeListPagesIsRecoverable(t *testing.T) { + img, model := faultsLongImage(t) + build := func(t *testing.T) *faultsBench { + t.Helper() + b := faultsFromImage(t, img, model) + a := b.alloc() + b.alloc() + b.free(a) + return b + } + total := 0 + for _, name := range []string{"Checkpoint", "Allocate"} { + op := faultsOpNamed(t, name) + t.Run(name, func(t *testing.T) { + n := faultsMatrix(t, build, op, faultsFewCrashes) + total += n + t.Logf("exercised %d failure points", n) + }) + } + t.Logf("exercised %d failure points with a two-page free list", total) +} + +func TestEveryFailurePointInOpenLeavesTheFileRecoverable(t *testing.T) { + images := []struct { + name string + build func(*testing.T) (*memFile, map[uint32]faultsGen) + crashes []faultsCrash + }{ + {"a cleanly closed store", func(t *testing.T) (*memFile, map[uint32]faultsGen) { + b := faultsBusy(t) + b.close() + return b.mf, map[uint32]faultsGen{b.m.gen: b.m.durable()} + }, nil}, + {"a store whose last checkpoint lost its final fsync", func(t *testing.T) (*memFile, map[uint32]faultsGen) { + b := faultsBusy(t) + g, live := b.m.gen, b.m.live.copy() + b.ff.arm(faultsSync, 3, nil, 0) + err := b.s.Checkpoint() + meta := b.ff.metaWritten + b.ff.reset() + if !errors.Is(err, ErrPoisoned) || !meta { + t.Fatalf("a checkpoint whose final fsync failed gave %v with the meta written %v", err, meta) + } + b.s.Discard() + b.mf.restart() + return b.mf, map[uint32]faultsGen{g: b.m.durable(), g + 1: live} + }, nil}, + {"a crashed store with unsynced pages in the cache", func(t *testing.T) (*memFile, map[uint32]faultsGen) { + b := faultsBusy(t) + b.s.Discard() + b.mf.restart() + return b.mf, map[uint32]faultsGen{b.m.gen: b.m.durable()} + }, nil}, + {"an all-zero file an interrupted create left", func(t *testing.T) (*memFile, map[uint32]faultsGen) { + mf := newMemFile() + if _, err := mf.WriteAt(make([]byte, ChunkPages*page.Size), 0); err != nil { + t.Fatal(err) + } + return mf, map[uint32]faultsGen{1: faultsEmpty()} + }, nil}, + {"an empty file", func(t *testing.T) (*memFile, map[uint32]faultsGen) { + return newMemFile(), map[uint32]faultsGen{1: faultsEmpty()} + }, nil}, + {"a store whose free list spans two pages", func(t *testing.T) (*memFile, map[uint32]faultsGen) { + img, m := faultsLongImage(t) + return img, map[uint32]faultsGen{m.gen: m.durable()} + }, faultsFewCrashes}, + } + total := 0 + for _, im := range images { + im := im + t.Run(im.name, func(t *testing.T) { + img, allowed := im.build(t) + crashes := im.crashes + if crashes == nil { + crashes = faultsCrashes + } + dry := faultsWrap(faultsClone(img)) + s, _, err := openFile(dry) + if err != nil { + t.Fatalf("opening with no fault injected: %v", err) + } + if _, ok := allowed[s.Durable().Checkpoint]; !ok { + t.Fatalf("opened at checkpoint %d, want one of %v", s.Durable().Checkpoint, faultsGens(allowed)) + } + s.Discard() + seen := dry.seen + points := 0 + for kind := faultsWrite; kind < faultsKindCount; kind++ { + for at := 1; at <= seen[kind.counter()]; at++ { + for _, v := range faultsVariants(kind) { + where := faultsWhere("Open", kind, at, v) + mf := faultsClone(img) + ff := faultsWrap(mf) + ff.arm(kind, at, v.keep, v.tear) + s, _, err := openFile(ff) + flags := *ff + ff.reset() + if s != nil { + s.Discard() + } + faultsRules(t, where, flags, err, false) + faultsCrashAll(t, where, mf, allowed, crashes) + points++ + } + } + } + total += points + t.Logf("exercised %d failure points", points) + }) + } + t.Logf("exercised %d failure points inside Open", total) +} + +type faultsTape struct { + b []byte + i int +} + +func (r *faultsTape) more() bool { return r.i < len(r.b) } + +func (r *faultsTape) next() byte { + if r.i >= len(r.b) { + return 0 + } + c := r.b[r.i] + r.i++ + return c +} + +func faultsSeed(seed uint64, n int) []byte { + out := make([]byte, n) + x := seed*0x9E3779B97F4A7C15 + 1 + for i := range out { + x ^= x << 13 + x ^= x >> 7 + x ^= x << 17 + out[i] = byte(x >> 24) + } + return out +} + +func faultsCrashBy(mf *memFile, mode, p byte) { + switch mode % 3 { + case 0: + mf.restart() + case 1: + mf.restartEvicting(faultsSubset(p)) + default: + mf.powerLoss(faultsSubset(p)) + } +} + +func faultsDrive(t *testing.T, script []byte) int { + if len(script) > 300 { + script = script[:300] + } + r := &faultsTape{b: script} + mf := newMemFile() + ff := faultsWrap(mf) + s, err := create(page.NewFile(ff)) + if err != nil { + t.Fatalf("create: %v", err) + } + defer func() { s.Discard() }() + m := faultsNewModel(s.Durable().Checkpoint) + fired := 0 + + reopen := func(where string, allowed map[uint32]faultsGen) { + t.Helper() + var back *Store + if r.next()&3 == 3 { + kind, at, p := faultsKind(r.next()%byte(faultsKindCount)), 1+int(r.next()%4), r.next() + ff.arm(kind, at, faultsSubset(p), int(p)) + got, _, err := openFile(ff) + flags := *ff + ff.reset() + switch { + case flags.fired: + fired++ + if got != nil { + got.Discard() + } + faultsRules(t, where+", opening", flags, err, false) + mf.restart() + case err != nil: + t.Fatalf("%s: opening failed with no fault injected: %v", where, err) + default: + back = got + } + } + if back == nil { + got, _, err := openFile(ff) + if err != nil { + t.Fatalf("%s: the file no longer opens: %v", where, err) + } + back = got + } + s = back + g := s.Durable().Checkpoint + want, ok := allowed[g] + if !ok { + t.Fatalf("%s: recovered checkpoint %d, want one of %v", where, g, faultsGens(allowed)) + } + if g == m.gen { + m.revert() + } else { + m.live = want.copy() + m.commit(g) + } + } + + for step := 0; r.more(); step++ { + where := fmt.Sprintf("step %d", step) + code := r.next() + if code&0xC0 == 0xC0 { + kind, at, p := faultsKind(r.next()%byte(faultsKindCount)), 1+int(r.next()%3), r.next() + ff.arm(kind, at, faultsSubset(p), int(p)) + } else { + ff.reset() + } + c := int(code&0x3F) % (len(faultsOps) + 3) + if c >= len(faultsOps) { + ff.reset() + s.Discard() + faultsCrashBy(mf, byte(c-len(faultsOps)), r.next()) + reopen(where+", after a crash", map[uint32]faultsGen{m.gen: m.durable()}) + faultsCheck(t, where, s, m.live, true) + continue + } + o := faultsOps[c] + where += ", " + o.name + before := faultsSnapOf(s) + live := m.live.copy() + pick := r.next() + err := o.do(t, s, m, pick) + flags := *ff + ff.reset() + if flags.fired { + fired++ + faultsRules(t, where, flags, err, true) + } else if err != nil { + t.Fatalf("%s: failed with no fault injected: %v", where, err) + } + switch { + case err != nil && errors.Is(err, ErrPoisoned): + faultsCheckPoisoned(t, where, s, flags.kind.cause(), o.closes) + allowed := map[uint32]faultsGen{m.gen: m.durable()} + if o.commits && flags.metaWritten { + allowed[m.gen+1] = live + } + s.Discard() + mode := r.next() + faultsCrashBy(mf, mode, r.next()) + reopen(where+", recovering from the poisoned store", allowed) + case err != nil: + if diff := faultsDiff(before, faultsSnapOf(s)); diff != "" { + t.Fatalf("%s: a failure that did not poison changed the store: %s", where, diff) + } + if o.closes { + mf.restart() + reopen(where+", reopening after a failed Close", map[uint32]faultsGen{m.gen: m.durable()}) + } else if err := o.do(t, s, m, pick); err != nil { + t.Fatalf("%s: retrying after a failure that did not poison: %v", where, err) + } + case o.closes: + mf.restart() + reopen(where+", reopening after Close", map[uint32]faultsGen{m.gen: m.durable()}) + } + faultsCheck(t, where, s, m.live, true) + if g := s.Durable().Checkpoint; g != m.gen { + t.Fatalf("%s: the store is at checkpoint %d, the model at %d", where, g, m.gen) + } + } + return fired +} + +func FuzzRandomFaultsNeverBreakRecovery(f *testing.F) { + f.Add([]byte{0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 3, 1, 2, 1, 1, 1, 7, 0, 0, 0, + 0xC7, 2, 2, 0, 0, 0, 0, 0, 5, 0, 12, 0x55, 0}) + f.Add([]byte{0, 0, 0, 0, 0, 0, 2, 1, 1, 1, 1, 0, 7, 0, + 0xC0, 0, 0, 0, 0, 0, 0, 0xC7, 1, 0, 0, 0, 4, 0, 0xC7, 0, 1, 0, 0, 7, 0, 11, 0x0F, 3, 2, 1, 0}) + f.Add([]byte{0, 0, 0, 0, 1, 0, 7, 0, 0xC5, 3, 0, 0, 0, 0xC5, 4, 0, 0, 0, + 0xC4, 3, 1, 0, 0, 9, 0, 3, 3, 0, 0, 0, 0xC9, 2, 2, 0xAA, 0, 2, 0x33, 0}) + f.Add([]byte{0, 0, 2, 1, 7, 0, 1, 0, 0xC9, 1, 1, 0, 0, 1, 0xF0, 0, + 0xC8, 2, 0, 0x0F, 0, 1, 0x0F, 3, 3, 0, 0, 7, 0, 10, 0, 3, 4, 1, 0}) + f.Add([]byte("\xcd0000A0000Z0\xd621")) + f.Add([]byte("Z0000A0\xc9")) + f.Add(append(make([]byte, 2*(ChunkPages-1)), 0xC0, 0, 0, 0, 0, 4, 0)) + f.Add(append(make([]byte, 2*(ChunkPages-1)), 1, 0, 0xC7, 1, 0, 0, 0, 0xC7, 1, 0, 0xFF, 0, 4, 0)) + for seed := uint64(1); seed <= 16; seed++ { + f.Add(faultsSeed(seed, 200)) + } + f.Fuzz(func(t *testing.T, script []byte) { + faultsDrive(t, script) + }) +} diff --git a/internal/pagestore/fuzz_test.go b/internal/pagestore/fuzz_test.go new file mode 100644 index 0000000..ead12b3 --- /dev/null +++ b/internal/pagestore/fuzz_test.go @@ -0,0 +1,279 @@ +package pagestore + +import ( + "encoding/binary" + "errors" + "sort" + "testing" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/page" +) + +type model struct { + inUse map[core.PageID]uint64 + root core.PageID + gen uint32 + dInUse map[core.PageID]uint64 + dRoot core.PageID + nextTag uint64 +} + +func newModel(gen uint32) *model { + return &model{inUse: map[core.PageID]uint64{}, dInUse: map[core.PageID]uint64{}, gen: gen} +} + +func clone(m map[core.PageID]uint64) map[core.PageID]uint64 { + out := make(map[core.PageID]uint64, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func (m *model) commit(gen uint32) { + m.dInUse = clone(m.inUse) + m.dRoot = m.root + m.gen = gen +} + +func (m *model) revert() { + m.inUse = clone(m.dInUse) + m.root = m.dRoot +} + +func (m *model) sortedInUse() []core.PageID { + ids := make([]core.PageID, 0, len(m.inUse)) + for id := range m.inUse { + ids = append(ids, id) + } + sort.Slice(ids, func(a, b int) bool { return ids[a] < ids[b] }) + return ids +} + +func checkAgainstModel(t *testing.T, step int, s *Store, m *model) { + t.Helper() + if err := s.Verify(); err != nil { + t.Fatalf("step %d: Verify: %v", step, err) + } + if s.Root() != m.root { + t.Fatalf("step %d: root is %d, model says %d", step, s.Root(), m.root) + } + for id, tag := range m.inUse { + p, err := s.Read(id) + if err != nil { + t.Fatalf("step %d: reading in-use page %d: %v", step, id, err) + } + if got := binary.BigEndian.Uint64(p[page.HeaderSize:]); got != tag { + t.Fatalf("step %d: page %d holds tag %d, model wrote %d", step, id, got, tag) + } + } + + owner := make(map[core.PageID]string, int(s.Meta().NextPage)) + claim := func(ids []core.PageID, as string) { + for _, id := range ids { + if prev, ok := owner[id]; ok { + t.Fatalf("step %d: page %d is both %s and %s", step, id, prev, as) + } + owner[id] = as + } + } + claim(m.sortedInUse(), "in use") + claim(s.FreePages(), "free") + claim(s.PendingPages(), "pending") + claim(s.ListPages(), "a free-list page") + for id := core.PageID(1); id < s.Meta().NextPage; id++ { + if _, ok := owner[id]; !ok { + t.Fatalf("step %d: page %d was handed out and is now in no state at all, so it leaked", step, id) + } + } + if len(owner) != int(s.Meta().NextPage)-1 { + t.Fatalf("step %d: %d pages accounted for, %d handed out", step, len(owner), s.Meta().NextPage-1) + } +} + +func FuzzCrashAnywhereKeepsTheLastCheckpoint(f *testing.F) { + f.Add([]byte{0, 0, 0, 3, 0, 2, 1, 4, 0, 0, 2, 2, 7, 5}) + f.Add([]byte{0, 0, 3, 0, 4, 0, 2, 0, 4, 8, 1, 0, 6, 0}) + f.Add([]byte{0, 1, 0, 1, 0, 3, 1, 2, 3, 2, 5, 8, 2, 0, 2, 1, 4, 7, 0xAA}) + f.Add([]byte{0, 0, 0, 0, 2, 0, 2, 1, 2, 2, 8, 0, 0, 0, 8, 1, 8, 2, 7, 0x55}) + f.Add([]byte{0, 0, 3, 0, 4, 0, 1, 9, 0x0F, 0, 4, 9, 0x80, 7, 0xFF}) + + f.Fuzz(func(t *testing.T, ops []byte) { + if len(ops) > 400 { + ops = ops[:400] + } + mf := newMemFile() + s, err := create(page.NewFile(mf)) + if err != nil { + t.Fatal(err) + } + m := newModel(s.Durable().Checkpoint) + + reopen := func(step int) { + back, _, err := openFile(mf) + if err != nil { + t.Fatalf("step %d: reopening: %v", step, err) + } + s = back + } + subset := func(seed byte) func(int) bool { + return func(i int) bool { return (uint(seed)>>(uint(i)%8))&1 == 1 } + } + + for i := 0; i < len(ops); i++ { + var arg byte + if i+1 < len(ops) { + arg = ops[i+1] + } + + switch ops[i] % 10 { + case 0, 1: + p, err := s.Allocate(page.KindHeap) + if err != nil { + t.Fatalf("step %d: Allocate: %v", i, err) + } + if _, dup := m.inUse[p.ID()]; dup { + t.Fatalf("step %d: page %d was handed out while still in use", i, p.ID()) + } + if _, durable := m.dInUse[p.ID()]; durable { + t.Fatalf("step %d: page %d was handed out while the last checkpoint still depends on it", i, p.ID()) + } + m.nextTag++ + binary.BigEndian.PutUint64(p[page.HeaderSize:], m.nextTag) + if err := s.Write(p); err != nil { + t.Fatalf("step %d: Write: %v", i, err) + } + m.inUse[p.ID()] = m.nextTag + + case 2: + ids := m.sortedInUse() + if len(ids) == 0 { + continue + } + id := ids[int(arg)%len(ids)] + err := s.Free(id) + if id == m.root { + if err == nil { + t.Fatalf("step %d: freeing the root %d was allowed", i, id) + } + continue + } + if err != nil { + t.Fatalf("step %d: Free(%d): %v", i, id, err) + } + delete(m.inUse, id) + + case 3: + ids := m.sortedInUse() + if len(ids) == 0 { + continue + } + id := ids[int(arg)%len(ids)] + if err := s.SetRoot(id); err != nil { + t.Fatalf("step %d: SetRoot(%d): %v", i, id, err) + } + m.root = id + + case 4: + if err := s.Checkpoint(); err != nil { + t.Fatalf("step %d: Checkpoint: %v", i, err) + } + m.commit(s.Durable().Checkpoint) + + case 5: + if err := s.Close(); err != nil { + t.Fatalf("step %d: Close: %v", i, err) + } + mf.closed = false + reopen(i) + m.commit(s.Durable().Checkpoint) + + case 6: + if err := s.Discard(); err != nil { + t.Fatal(err) + } + mf.restartEvicting(subset(arg)) + reopen(i) + m.revert() + if s.Durable().Checkpoint != m.gen { + t.Fatalf("step %d: after a process crash the generation is %d, want %d", + i, s.Durable().Checkpoint, m.gen) + } + + case 7: + if err := s.Discard(); err != nil { + t.Fatal(err) + } + mf.powerLoss(subset(arg)) + reopen(i) + m.revert() + if s.Durable().Checkpoint != m.gen { + t.Fatalf("step %d: after a power loss the generation is %d, want %d", + i, s.Durable().Checkpoint, m.gen) + } + + case 8: + before := m.gen + mf.failAtSync = mf.syncs + 1 + int(arg%3) + mf.failKeep = subset(arg >> 2) + err := s.Checkpoint() + mf.failAtSync, mf.failKeep = 0, nil + if err == nil { + m.commit(s.Durable().Checkpoint) + break + } + if err := s.Discard(); err != nil { + t.Fatal(err) + } + if arg&0x80 != 0 { + mf.powerLoss(subset(arg >> 3)) + } else { + mf.restartEvicting(subset(arg >> 1)) + } + reopen(i) + switch got := s.Durable().Checkpoint; got { + case before: + m.revert() + case before + 1: + m.commit(got) + default: + t.Fatalf("step %d: a checkpoint that failed mid-way recovered to generation %d; "+ + "only %d (before) or %d (after) are possible", i, got, before, before+1) + } + + case 9: + mf.failAtSync = mf.syncs + 1 + mf.failKeep = subset(arg) + err := s.Sync() + mf.failAtSync, mf.failKeep = 0, nil + if err == nil { + break + } + if errors.Is(err, ErrPoisoned) { + if err := s.Discard(); err != nil { + t.Fatal(err) + } + if arg&0x80 != 0 { + mf.powerLoss(subset(arg >> 3)) + } else { + mf.restartEvicting(subset(arg >> 1)) + } + reopen(i) + m.revert() + break + } + if err := s.Checkpoint(); err != nil { + t.Fatalf("step %d: Checkpoint after an unpoisoned Sync failure: %v", i, err) + } + m.commit(s.Durable().Checkpoint) + if err := s.Discard(); err != nil { + t.Fatal(err) + } + mf.keepNothing() + reopen(i) + } + checkAgainstModel(t, i, s, m) + } + }) +} diff --git a/internal/pagestore/hostile_test.go b/internal/pagestore/hostile_test.go new file mode 100644 index 0000000..c4d0ff4 --- /dev/null +++ b/internal/pagestore/hostile_test.go @@ -0,0 +1,737 @@ +package pagestore + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/page" +) + +type hostileShape struct { + meta Meta + root core.PageID + lists []core.PageID + free []core.PageID + inUse []core.PageID +} + +func hostileShapeOf(s *Store) hostileShape { + sh := hostileShape{meta: s.Durable(), root: s.Root(), lists: s.ListPages(), free: s.FreePages()} + taken := map[core.PageID]bool{} + for _, id := range sh.lists { + taken[id] = true + } + for _, id := range sh.free { + taken[id] = true + } + for id := core.PageID(1); id < sh.meta.NextPage; id++ { + if !taken[id] { + sh.inUse = append(sh.inUse, id) + } + } + return sh +} + +func (sh hostileShape) others() []core.PageID { + var out []core.PageID + for _, id := range sh.inUse { + if id != sh.root { + out = append(out, id) + } + } + return out +} + +func (sh hostileShape) listAt(i int) int { return int(page.Offset(sh.lists[i])) } + +func (sh hostileShape) entryAt(j int) int { return sh.listAt(0) + FreeListHeader + 4*j } + +func hostileRichWorkload(s *Store) error { + var ids []core.PageID + for i := 0; i < 8; i++ { + p, err := s.Allocate(page.KindHeap) + if err != nil { + return err + } + copy(p[page.HeaderSize:], fmt.Sprintf("hostile payload of page %d", p.ID())) + if err := s.Write(p); err != nil { + return err + } + ids = append(ids, p.ID()) + } + if err := s.SetRoot(ids[0]); err != nil { + return err + } + for _, id := range ids[2:5] { + if err := s.Free(id); err != nil { + return err + } + } + if err := s.Checkpoint(); err != nil { + return err + } + if err := s.Free(ids[5]); err != nil { + return err + } + return s.Checkpoint() +} + +func hostileRichReady(raw []byte, sh hostileShape) error { + if sh.root == NoPage { + return errors.New("the rich fixture has no root") + } + if len(sh.lists) == 0 || sh.meta.FreeListPage != sh.lists[0] { + return fmt.Errorf("the rich fixture has list pages %v and head %d", sh.lists, sh.meta.FreeListPage) + } + if len(sh.others()) < 2 { + return fmt.Errorf("the rich fixture has in-use pages %v, want two besides the root", sh.inUse) + } + if uint32(sh.meta.NextPage)+1 >= sh.meta.FilePages { + return fmt.Errorf("the rich fixture leaves no never-handed-out page: %+v", sh.meta) + } + if len(raw) != int(sh.meta.FilePages)*page.Size { + return fmt.Errorf("the rich fixture is %d bytes, want %d pages", len(raw), sh.meta.FilePages) + } + head := page.Page(raw[sh.listAt(0) : sh.listAt(0)+page.Size]) + if head.ItemCount() < 2 { + return fmt.Errorf("the rich fixture's head list page holds %d entries, want at least 2", head.ItemCount()) + } + return nil +} + +func hostileStoreFile(t *testing.T, rich bool) ([]byte, hostileShape) { + t.Helper() + path := filepath.Join(t.TempDir(), "fixture.db") + s := mustCreate(t, path) + if rich { + if err := hostileRichWorkload(s); err != nil { + t.Fatal(err) + } + } + sh := hostileShapeOf(s) + if err := s.Close(); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if rich { + if err := hostileRichReady(raw, sh); err != nil { + t.Fatal(err) + } + } + return raw, sh +} + +func hostilePut(img []byte, off int, v uint32) { binary.BigEndian.PutUint32(img[off:], v) } + +func hostileView(img []byte, id core.PageID) page.Page { + off := int(page.Offset(id)) + return page.Page(img[off : off+page.Size]) +} + +func hostileSet(off int, v func(hostileShape) uint32) func([]byte, hostileShape) []byte { + return func(img []byte, sh hostileShape) []byte { + hostilePut(img, off, v(sh)) + return img + } +} + +func hostileForge(img []byte, sh hostileShape, id, next core.PageID, entries ...core.PageID) []byte { + p := page.New(id, page.KindFreeList) + binary.BigEndian.PutUint32(p[offFreeListNext:], uint32(next)) + for j, e := range entries { + binary.BigEndian.PutUint32(p[FreeListHeader+4*j:], uint32(e)) + } + p.SetItemCount(uint16(len(entries))) + p.SetFreeStart(uint16(FreeListHeader + 4*len(entries))) + copy(img[page.Offset(id):], p) + hostilePut(img, sh.listAt(len(sh.lists)-1)+offFreeListNext, uint32(id)) + hostilePut(img, offFreeCount, sh.meta.FreeCount+uint32(len(entries))) + return img +} + +func hostileIsAny(err error, want ...error) bool { + for _, w := range want { + if errors.Is(err, w) { + return true + } + } + return false +} + +func hostileFirstDiff(a, b []byte) string { + if len(a) != len(b) { + return fmt.Sprintf("size went from %d to %d bytes", len(a), len(b)) + } + for i := range a { + if a[i] != b[i] { + return fmt.Sprintf("byte %d (page %d) went from %02X to %02X", i, i/page.Size, a[i], b[i]) + } + } + return "no difference" +} + +func hostileMem(img []byte) *memFile { + return &memFile{durable: append([]byte(nil), img...), live: append([]byte(nil), img...)} +} + +func hostileExpectRejected(t *testing.T, path string, img []byte, want []error) { + t.Helper() + if err := os.WriteFile(path, img, 0o644); err != nil { + t.Fatal(err) + } + s, err := Open(path) + if err == nil { + s.Discard() + t.Errorf("Open accepted the damaged file; want one of %v", want) + } else if !hostileIsAny(err, want...) { + t.Errorf("Open gave %v; want one of %v", err, want) + } + after, rerr := os.ReadFile(path) + if rerr != nil { + t.Fatal(rerr) + } + if err != nil && !bytes.Equal(after, img) { + t.Errorf("Open rejected the file but changed it: %s", hostileFirstDiff(img, after)) + } + + mf := hostileMem(img) + ms, _, merr := openFile(mf) + if merr == nil { + ms.Discard() + t.Errorf("openFile over a memFile accepted the damaged image; want one of %v", want) + return + } + if !hostileIsAny(merr, want...) { + t.Errorf("openFile over a memFile gave %v; want one of %v", merr, want) + } + if len(mf.log) != 0 || mf.syncs != 0 { + t.Errorf("rejecting the image left %d unsynced writes and issued %d fsyncs; a rejected file must not be touched", + len(mf.log), mf.syncs) + } + if !bytes.Equal(mf.live, img) || !bytes.Equal(mf.durable, img) { + t.Errorf("rejecting the image changed it: %s", hostileFirstDiff(img, mf.live)) + } +} + +type hostileCase struct { + name string + rich bool + want []error + mutate func(img []byte, sh hostileShape) []byte +} + +func hostileCases() []hostileCase { + corrupt := []error{ErrCorrupt} + u := func(v uint32) func(hostileShape) uint32 { return func(hostileShape) uint32 { return v } } + id := func(f func(hostileShape) core.PageID) func(hostileShape) uint32 { + return func(sh hostileShape) uint32 { return uint32(f(sh)) } + } + next := id(func(sh hostileShape) core.PageID { return sh.meta.NextPage }) + unused := func(sh hostileShape) uint32 { return sh.meta.FilePages - 1 } + pastFile := func(sh hostileShape) uint32 { return sh.meta.FilePages + ChunkPages } + root := id(func(sh hostileShape) core.PageID { return sh.root }) + head := id(func(sh hostileShape) core.PageID { return sh.lists[0] }) + other := func(i int) func(hostileShape) uint32 { + return func(sh hostileShape) uint32 { return uint32(sh.others()[i]) } + } + onHead := func(f func(p page.Page, sh hostileShape)) func([]byte, hostileShape) []byte { + return func(img []byte, sh hostileShape) []byte { + f(hostileView(img, sh.lists[0]), sh) + return img + } + } + onMeta := func(f func(p page.Page)) func([]byte, hostileShape) []byte { + return func(img []byte, sh hostileShape) []byte { + f(page.Page(img[:page.Size])) + return img + } + } + entry := func(j int, v func(hostileShape) uint32) func([]byte, hostileShape) []byte { + return func(img []byte, sh hostileShape) []byte { + hostilePut(img, sh.entryAt(j), v(sh)) + return img + } + } + forge := func(listID func(hostileShape) core.PageID, nextID func(hostileShape) core.PageID, + entries ...func(hostileShape) uint32) func([]byte, hostileShape) []byte { + return func(img []byte, sh hostileShape) []byte { + ids := make([]core.PageID, len(entries)) + for i, e := range entries { + ids[i] = core.PageID(e(sh)) + } + return hostileForge(img, sh, listID(sh), nextID(sh), ids...) + } + } + spare := func(sh hostileShape) core.PageID { return sh.others()[1] } + end := func(hostileShape) core.PageID { return NoPage } + cut := func(n func(hostileShape) int) func([]byte, hostileShape) []byte { + return func(img []byte, sh hostileShape) []byte { return img[:n(sh)] } + } + + return []hostileCase{ + {"the next page is 0, the meta page itself", true, corrupt, hostileSet(offNextPage, u(0))}, + {"the next page is one past the pages the meta says the file holds", true, corrupt, + hostileSet(offNextPage, func(sh hostileShape) uint32 { return sh.meta.FilePages + 1 })}, + {"the next page and the file pages both run past the end of the file", true, corrupt, + func(img []byte, sh hostileShape) []byte { + hostilePut(img, offNextPage, pastFile(sh)) + hostilePut(img, offFilePages, pastFile(sh)) + return img + }}, + {"the file pages claim one page more than the file holds", true, corrupt, + hostileSet(offFilePages, func(sh hostileShape) uint32 { return sh.meta.FilePages + 1 })}, + {"the file pages claim a whole chunk more than the file holds", true, corrupt, hostileSet(offFilePages, pastFile)}, + {"the root is the next page to hand out", true, corrupt, hostileSet(offRoot, next)}, + {"the root is a page that was never handed out", true, corrupt, hostileSet(offRoot, unused)}, + {"the root is past the end of the file", true, corrupt, hostileSet(offRoot, pastFile)}, + {"the root is the largest page id", true, corrupt, hostileSet(offRoot, u(MaxPages))}, + {"the root is the head free-list page", true, corrupt, hostileSet(offRoot, head)}, + {"the root is a free page", true, corrupt, + hostileSet(offRoot, id(func(sh hostileShape) core.PageID { return sh.free[0] }))}, + {"the free count is one lower than the list holds", true, corrupt, + hostileSet(offFreeCount, func(sh hostileShape) uint32 { return sh.meta.FreeCount - 1 })}, + {"the free count is one higher than the list holds", true, corrupt, + hostileSet(offFreeCount, func(sh hostileShape) uint32 { return sh.meta.FreeCount + 1 })}, + {"the free count is 0 although the list holds pages", true, corrupt, hostileSet(offFreeCount, u(0))}, + {"the free count is the largest count", true, corrupt, hostileSet(offFreeCount, u(MaxPages))}, + {"the free list head is missing although the count is not", true, corrupt, + hostileSet(offFreeList, u(uint32(NoPage)))}, + {"the free list head is the next page to hand out", true, corrupt, hostileSet(offFreeList, next)}, + {"the free list head is a page that was never handed out", true, corrupt, hostileSet(offFreeList, unused)}, + {"the free list head is past the end of the file", true, corrupt, hostileSet(offFreeList, pastFile)}, + {"the free list head is the largest page id", true, corrupt, hostileSet(offFreeList, u(MaxPages))}, + {"the free list head is the root", true, corrupt, hostileSet(offFreeList, root)}, + {"the free list head is an in-use page", true, corrupt, hostileSet(offFreeList, other(0))}, + {"the list page has kind heap", true, corrupt, + onHead(func(p page.Page, _ hostileShape) { p.SetKind(page.KindHeap) })}, + {"the list page has kind free", true, corrupt, + onHead(func(p page.Page, _ hostileShape) { p.SetKind(page.KindFree) })}, + {"the list page has kind meta", true, corrupt, + onHead(func(p page.Page, _ hostileShape) { p.SetKind(page.KindMeta) })}, + {"the list page has a kind this build has never heard of", true, corrupt, + onHead(func(p page.Page, _ hostileShape) { p.SetKind(page.Kind(0xEE)) })}, + {"the list page header names an in-use page", true, corrupt, + onHead(func(p page.Page, sh hostileShape) { p.SetID(sh.others()[0]) })}, + {"the list page header names the meta page", true, corrupt, + onHead(func(p page.Page, _ hostileShape) { p.SetID(MetaPage) })}, + {"the list page claims one entry more than fits", true, corrupt, + onHead(func(p page.Page, _ hostileShape) { p.SetItemCount(FreeListCapacity + 1) })}, + {"the list page claims 65535 entries", true, corrupt, + onHead(func(p page.Page, _ hostileShape) { p.SetItemCount(0xFFFF) })}, + {"a free entry is page 0", true, corrupt, entry(0, u(uint32(MetaPage)))}, + {"a free entry is the next page to hand out", true, corrupt, entry(0, next)}, + {"a free entry is a page that was never handed out", true, corrupt, entry(0, unused)}, + {"a free entry is past the end of the file", true, corrupt, entry(0, pastFile)}, + {"a free entry is the largest page id", true, corrupt, entry(0, u(MaxPages))}, + {"a free entry appears twice", true, corrupt, + func(img []byte, sh hostileShape) []byte { + copy(img[sh.entryAt(1):sh.entryAt(1)+4], img[sh.entryAt(0):sh.entryAt(0)+4]) + return img + }}, + {"a free entry is the root", true, corrupt, entry(0, root)}, + {"a free entry is the list page that holds it", true, corrupt, entry(0, head)}, + {"the list chain continues into an in-use page", true, corrupt, + onHead(func(p page.Page, sh hostileShape) { + binary.BigEndian.PutUint32(p[offFreeListNext:], uint32(sh.others()[0])) + })}, + {"the list chain continues into the next page to hand out", true, corrupt, + onHead(func(p page.Page, sh hostileShape) { + binary.BigEndian.PutUint32(p[offFreeListNext:], uint32(sh.meta.NextPage)) + })}, + {"the list chain continues past the end of the file", true, corrupt, + onHead(func(p page.Page, sh hostileShape) { + binary.BigEndian.PutUint32(p[offFreeListNext:], pastFile(sh)) + })}, + {"the list chain continues to the largest page id", true, corrupt, + onHead(func(p page.Page, _ hostileShape) { binary.BigEndian.PutUint32(p[offFreeListNext:], MaxPages) })}, + {"a second list page repeats an entry of the first", true, corrupt, + forge(spare, end, id(func(sh hostileShape) core.PageID { return sh.free[0] }))}, + {"a second list page names the head list page as free", true, corrupt, forge(spare, end, head)}, + {"a second list page names itself as free", true, corrupt, forge(spare, end, other(1))}, + {"a second list page names the root as free", true, corrupt, forge(spare, end, root)}, + {"a second list page names page 0 as free", true, corrupt, forge(spare, end, u(uint32(MetaPage)))}, + {"a second list page names a page never handed out", true, corrupt, forge(spare, end, next)}, + {"a second list page points back at the head", true, corrupt, + forge(spare, func(sh hostileShape) core.PageID { return sh.lists[0] }, other(0))}, + {"a second list page points at itself", true, corrupt, forge(spare, spare, other(0))}, + {"a second list page is the root", true, corrupt, + forge(func(sh hostileShape) core.PageID { return sh.root }, end, other(0))}, + {"the page size is 8192", true, []error{ErrBadPageSize}, hostileSet(offPageSize, u(8192))}, + {"the page size is 2048", true, []error{ErrBadPageSize}, hostileSet(offPageSize, u(2048))}, + {"the page size is 0", true, []error{ErrBadPageSize}, hostileSet(offPageSize, u(0))}, + {"the page size is one byte larger", true, []error{ErrBadPageSize}, hostileSet(offPageSize, u(page.Size+1))}, + {"the version is one newer", true, []error{ErrBadVersion}, hostileSet(offVersion, u(Version+1))}, + {"the version is 0", true, []error{ErrBadVersion}, hostileSet(offVersion, u(0))}, + {"the version is the largest version", true, []error{ErrBadVersion}, hostileSet(offVersion, u(0xFFFFFFFF))}, + {"the first magic byte is changed", true, []error{ErrNotScuteDB}, + func(img []byte, _ hostileShape) []byte { img[offMagic] ^= 0x20; return img }}, + {"the magic lost its terminating NUL", true, []error{ErrNotScuteDB}, + func(img []byte, _ hostileShape) []byte { img[offMagic+len(Magic)-1] = '!'; return img }}, + {"the meta page is wiped to zeros while the rest of the store remains", true, + []error{ErrNotScuteDB, ErrCorrupt}, + func(img []byte, _ hostileShape) []byte { + copy(img[:page.Size], make([]byte, page.Size)) + return img + }}, + {"the meta page is wiped and the file is cut back to the pages handed out", true, + []error{ErrNotScuteDB, ErrCorrupt}, + func(img []byte, sh hostileShape) []byte { + copy(img[:page.Size], make([]byte, page.Size)) + return img[:int(sh.meta.NextPage)*page.Size] + }}, + {"the meta page is wiped and only the root follows it", true, + []error{ErrNotScuteDB, ErrCorrupt}, + func(img []byte, sh hostileShape) []byte { + copy(img[:page.Size], make([]byte, page.Size)) + copy(img[page.Size:], img[page.Offset(sh.root):page.Offset(sh.root)+page.Size]) + return img[:2*page.Size] + }}, + {"page 0 says it is page 1", true, corrupt, onMeta(func(p page.Page) { p.SetID(1) })}, + {"page 0 says it is the largest page id", true, corrupt, onMeta(func(p page.Page) { p.SetID(core.PageID(MaxPages)) })}, + {"page 0 has kind free-list", true, corrupt, onMeta(func(p page.Page) { p.SetKind(page.KindFreeList) })}, + {"page 0 has kind free", true, corrupt, onMeta(func(p page.Page) { p.SetKind(page.KindFree) })}, + {"page 0 has a kind this build has never heard of", true, corrupt, onMeta(func(p page.Page) { p.SetKind(page.Kind(0xEE)) })}, + {"the file is cut one byte short of a page", true, corrupt, cut(func(hostileShape) int { return page.Size - 1 })}, + {"the file is cut just after the magic", true, corrupt, cut(func(hostileShape) int { return offMagic + len(Magic) })}, + {"the file is cut one byte short of the pages it claims", true, corrupt, + cut(func(sh hostileShape) int { return int(sh.meta.FilePages)*page.Size - 1 })}, + {"the file is cut one page short of the pages it claims", true, corrupt, + cut(func(sh hostileShape) int { return int(sh.meta.FilePages-1) * page.Size })}, + {"the file is cut back to the pages handed out", true, corrupt, + cut(func(sh hostileShape) int { return int(sh.meta.NextPage) * page.Size })}, + {"the file is cut back to its meta page", true, corrupt, cut(func(hostileShape) int { return page.Size })}, + + {"the next page of a fresh store is 0", false, corrupt, hostileSet(offNextPage, u(0))}, + {"the file pages of a fresh store are 0", false, corrupt, hostileSet(offFilePages, u(0))}, + {"the root of a fresh store is page 1, which was never handed out", false, corrupt, hostileSet(offRoot, u(1))}, + {"a fresh store claims one free page with no list", false, corrupt, hostileSet(offFreeCount, u(1))}, + {"the free list head of a fresh store is page 1, which was never handed out", false, corrupt, + hostileSet(offFreeList, u(1))}, + {"a fresh store is cut back to its meta page", false, corrupt, cut(func(hostileShape) int { return page.Size })}, + {"the version of a fresh store is 0", false, []error{ErrBadVersion}, hostileSet(offVersion, u(0))}, + } +} + +func hostileOpensCleanly(t *testing.T, path string, raw []byte, sh hostileShape) { + t.Helper() + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + s, err := Open(path) + if err != nil { + t.Fatalf("the undamaged fixture does not open: %v", err) + } + defer s.Close() + if err := s.Verify(); err != nil { + t.Fatalf("the undamaged fixture does not verify: %v", err) + } + if got := hostileShapeOf(s); fmt.Sprint(got) != fmt.Sprint(sh) { + t.Fatalf("the undamaged fixture reopened as %+v, it was written as %+v", got, sh) + } +} + +func TestOpenRejectsEveryHostileRewriteOfACheckpointedStoreAndLeavesTheFileAsItWas(t *testing.T) { + rich, richShape := hostileStoreFile(t, true) + bare, bareShape := hostileStoreFile(t, false) + dir := t.TempDir() + hostileOpensCleanly(t, filepath.Join(dir, "rich.db"), rich, richShape) + hostileOpensCleanly(t, filepath.Join(dir, "bare.db"), bare, bareShape) + + for i, c := range hostileCases() { + c, path := c, filepath.Join(dir, fmt.Sprintf("case%03d.db", i)) + t.Run(c.name, func(t *testing.T) { + t.Parallel() + src, sh := bare, bareShape + if c.rich { + src, sh = rich, richShape + } + img := c.mutate(append([]byte(nil), src...), sh) + if bytes.Equal(img, src) { + t.Fatal("the mutation changed nothing, so the case tests nothing") + } + hostileExpectRejected(t, path, img, c.want) + }) + } +} + +const hostileMaxImage = 4 * ChunkPages * page.Size + +var hostileBase struct { + once sync.Once + rich []byte + bare []byte + shape hostileShape + err error +} + +func hostileMemImage(rich bool) ([]byte, hostileShape, error) { + mf := newMemFile() + s, err := create(page.NewFile(mf)) + if err != nil { + return nil, hostileShape{}, err + } + if rich { + if err := hostileRichWorkload(s); err != nil { + return nil, hostileShape{}, err + } + } + sh := hostileShapeOf(s) + if err := s.Close(); err != nil { + return nil, hostileShape{}, err + } + return append([]byte(nil), mf.live...), sh, nil +} + +func hostileImages(tb testing.TB) ([]byte, []byte, hostileShape) { + tb.Helper() + hostileBase.once.Do(func() { + hostileBase.rich, hostileBase.shape, hostileBase.err = hostileMemImage(true) + if hostileBase.err == nil { + hostileBase.err = hostileRichReady(hostileBase.rich, hostileBase.shape) + } + if hostileBase.err == nil { + hostileBase.bare, _, hostileBase.err = hostileMemImage(false) + } + }) + if hostileBase.err != nil { + tb.Fatalf("building the fuzz base images: %v", hostileBase.err) + } + return hostileBase.rich, hostileBase.bare, hostileBase.shape +} + +type hostilePatch struct { + off int + data []byte +} + +func hostileU32(v uint32) []byte { + var b [4]byte + binary.BigEndian.PutUint32(b[:], v) + return b[:] +} + +func hostileEncode(mode byte, cut int, patches ...hostilePatch) []byte { + out := []byte{mode} + if cut >= 0 { + out[0] |= 4 + out = append(out, byte(cut>>16), byte(cut>>8), byte(cut)) + } + for _, p := range patches { + out = append(out, byte(p.off>>16), byte(p.off>>8), byte(p.off), byte(len(p.data))) + out = append(out, p.data...) + } + return out +} + +func hostileDecode(data, rich, bare []byte) []byte { + if len(data) == 0 { + return nil + } + mode, rest := data[0], data[1:] + var img []byte + switch mode & 3 { + case 1: + img = append([]byte(nil), rich...) + case 2: + img = append([]byte(nil), bare...) + default: + if len(rest) > hostileMaxImage { + rest = rest[:hostileMaxImage] + } + return append([]byte(nil), rest...) + } + if mode&4 != 0 && len(rest) >= 3 { + n := (int(rest[0])<<16 | int(rest[1])<<8 | int(rest[2])) % (hostileMaxImage + 1) + rest = rest[3:] + if n <= len(img) { + img = img[:n] + } else { + img = append(img, make([]byte, n-len(img))...) + } + } + for len(rest) >= 4 && len(img) > 0 { + off := (int(rest[0])<<16 | int(rest[1])<<8 | int(rest[2])) % len(img) + n := int(rest[3]) + rest = rest[4:] + if n > len(rest) { + n = len(rest) + } + copy(img[off:], rest[:n]) + rest = rest[n:] + } + return img +} + +func hostileInterruptedCreate(img []byte) bool { + if len(img) > ChunkPages*page.Size { + return false + } + for _, b := range img { + if b != 0 { + return false + } + } + return true +} + +func hostileExercise(t *testing.T, mf *memFile, s *Store) { + t.Helper() + if err := s.Verify(); err != nil { + t.Fatalf("Open accepted a file that Verify rejects: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("Close of a freshly opened store: %v", err) + } + mf.restart() + s, _, err := openFile(mf) + if err != nil { + t.Fatalf("a file Open accepted and Close closed no longer opens: %v", err) + } + if err := s.Verify(); err != nil { + t.Fatalf("Verify after reopening: %v", err) + } + + m := s.Meta() + lists := map[core.PageID]bool{} + for _, id := range s.ListPages() { + lists[id] = true + } + free := map[core.PageID]bool{} + for _, id := range s.FreePages() { + free[id] = true + } + held := map[core.PageID][]byte{} + for id := core.PageID(1); id < m.NextPage; id++ { + if lists[id] || free[id] { + continue + } + p, err := s.Read(id) + if err != nil { + t.Fatalf("reading in-use page %d: %v", id, err) + } + held[id] = p + } + + p, err := s.Allocate(page.KindHeap) + if err != nil { + t.Fatalf("Allocate on a store Open accepted: %v", err) + } + if p.ID() == MetaPage || uint32(p.ID()) >= uint32(s.Meta().NextPage) { + t.Fatalf("Allocate handed out page %d with next page %d", p.ID(), s.Meta().NextPage) + } + if _, inUse := held[p.ID()]; inUse { + t.Fatalf("Allocate handed out page %d, which the durable state holds in use", p.ID()) + } + if lists[p.ID()] { + t.Fatalf("Allocate handed out page %d, which holds the durable free list", p.ID()) + } + copy(p[page.HeaderSize:], "hostile fuzz page") + if err := s.Write(p); err != nil { + t.Fatal(err) + } + if err := s.SetRoot(p.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + if err := s.Verify(); err != nil { + t.Fatalf("Verify after a checkpoint: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + mf.restart() + + back, _, err := openFile(mf) + if err != nil { + t.Fatalf("reopening after a checkpoint: %v", err) + } + defer back.Close() + if err := back.Verify(); err != nil { + t.Fatalf("Verify after the last reopen: %v", err) + } + if back.Root() != p.ID() { + t.Fatalf("the root is %d after a checkpoint that set it to %d", back.Root(), p.ID()) + } + got, err := back.Read(p.ID()) + if err != nil || !bytes.HasPrefix(got[page.HeaderSize:], []byte("hostile fuzz page")) { + t.Fatalf("the checkpointed root page did not survive: %v", err) + } + for id, want := range held { + got, err := back.Read(id) + if err != nil { + t.Fatalf("in-use page %d is no longer readable: %v", id, err) + } + if !bytes.Equal(got, want) { + t.Fatalf("in-use page %d changed although nothing wrote to it", id) + } + } +} + +func FuzzOpenNeverPanicsOnArbitraryBytes(f *testing.F) { + rich, bare, sh := hostileImages(f) + head := int(page.Offset(sh.lists[0])) + headPage := append(page.Page(nil), rich[head:head+page.Size]...) + heapHeader := append(page.Page(nil), headPage...) + heapHeader.SetKind(page.KindHeap) + countHeader := append(page.Page(nil), headPage...) + countHeader.SetItemCount(FreeListCapacity + 1) + + f.Add(hostileEncode(1, -1)) + f.Add(hostileEncode(2, -1)) + f.Add(hostileEncode(2, -1, hostilePatch{offNextPage, hostileU32(0)})) + f.Add(hostileEncode(1, -1, hostilePatch{offNextPage, hostileU32(0)})) + f.Add(hostileEncode(1, -1, hostilePatch{offFreeCount, hostileU32(sh.meta.FreeCount + 1)})) + f.Add(hostileEncode(1, -1, hostilePatch{offFreeList, hostileU32(sh.meta.FilePages + ChunkPages)})) + f.Add(hostileEncode(1, -1, hostilePatch{offRoot, hostileU32(uint32(sh.lists[0]))})) + f.Add(hostileEncode(1, -1, hostilePatch{head, heapHeader[:page.HeaderSize]})) + f.Add(hostileEncode(1, -1, hostilePatch{head, countHeader[:page.HeaderSize]})) + f.Add(hostileEncode(1, -1, hostilePatch{head + FreeListHeader, hostileU32(uint32(sh.root))})) + f.Add(hostileEncode(1, -1, hostilePatch{head + offFreeListNext, hostileU32(uint32(sh.lists[0]))})) + f.Add(hostileEncode(1, -1, hostilePatch{offMagic + len(Magic) - 1, []byte{'!'}})) + f.Add(hostileEncode(2, -1, hostilePatch{offVersion, hostileU32(0)})) + f.Add(hostileEncode(2, -1, hostilePatch{offPageSize, hostileU32(2048)})) + f.Add(hostileEncode(1, page.Size-1)) + f.Add(hostileEncode(1, int(sh.meta.NextPage)*page.Size)) + f.Add(hostileEncode(1, int(sh.meta.FilePages+3)*page.Size)) + f.Add(hostileEncode(2, 3*page.Size)) + f.Add(hostileEncode(1, -1, hostilePatch{0, make([]byte, 255)})) + f.Add([]byte{0}) + f.Add(append([]byte{0}, Magic[:]...)) + f.Add(append([]byte{0}, make([]byte, page.Size)...)) + f.Add(append([]byte{0}, bytes.Repeat([]byte("this is not a database.\n"), 400)...)) + f.Add(append([]byte{0}, bare[:2*page.Size]...)) + + f.Fuzz(func(t *testing.T, data []byte) { + rich, bare, _ := hostileImages(t) + img := hostileDecode(data, rich, bare) + mf := hostileMem(img) + s, created, err := openFile(mf) + if err == nil && created != hostileInterruptedCreate(img) { + t.Fatalf("openFile reported created=%v for a %d-byte image; only an all-zero image of at most one "+ + "chunk is an interrupted create", created, len(img)) + } + if err != nil { + if !hostileIsAny(err, ErrNotScuteDB, ErrBadVersion, ErrBadPageSize, ErrCorrupt) { + t.Fatalf("Open rejected the file with %v, which is none of the documented classes", err) + } + if len(mf.log) != 0 || mf.syncs != 0 || !bytes.Equal(mf.live, img) || !bytes.Equal(mf.durable, img) { + t.Fatalf("Open rejected the file (%v) but left %d unsynced writes and fsynced %d times: %s", + err, len(mf.log), mf.syncs, hostileFirstDiff(img, mf.live)) + } + return + } + hostileExercise(t, mf, s) + }) +} diff --git a/internal/pagestore/killnine_test.go b/internal/pagestore/killnine_test.go new file mode 100644 index 0000000..738f5c4 --- /dev/null +++ b/internal/pagestore/killnine_test.go @@ -0,0 +1,127 @@ +package pagestore + +import ( + "bufio" + "encoding/binary" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/scute-db/scutedb/internal/page" +) + +const killNineChildEnv = "SCUTEDB_PAGESTORE_KILL_CHILD" + +func TestKillNineChildLoopsUntilKilled(t *testing.T) { + path := os.Getenv(killNineChildEnv) + if path == "" { + t.Skip("runs only as the child process of TestARealKillNineAtAnyMomentAlwaysRecovers") + } + s, err := Create(path) + if err != nil { + os.Exit(3) + } + fmt.Println(s.Durable().Checkpoint) + for { + p, err := s.Allocate(page.KindBTreeLeaf) + if err != nil { + os.Exit(3) + } + binary.BigEndian.PutUint32(p[page.HeaderSize:], s.Durable().Checkpoint+1) + if err := s.Write(p); err != nil { + os.Exit(3) + } + old := s.Root() + if err := s.SetRoot(p.ID()); err != nil { + os.Exit(3) + } + if old != NoPage { + if err := s.Free(old); err != nil { + os.Exit(3) + } + } + if err := s.Checkpoint(); err != nil { + os.Exit(3) + } + fmt.Println(s.Durable().Checkpoint) + } +} + +func TestARealKillNineAtAnyMomentAlwaysRecovers(t *testing.T) { + if os.Getenv(killNineChildEnv) != "" { + t.Skip("this is the child") + } + if testing.Short() { + t.Skip("starts real child processes") + } + const runs = 12 + recovered := 0 + for run := 0; run < runs; run++ { + path := filepath.Join(t.TempDir(), "kill.db") + cmd := exec.Command(os.Args[0], "-test.run=^TestKillNineChildLoopsUntilKilled$", "-test.count=1") + cmd.Env = append(os.Environ(), killNineChildEnv+"="+path) + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + told := make(chan uint32, 1) + go func() { + last := uint32(0) + sc := bufio.NewScanner(out) + for sc.Scan() { + if v, err := strconv.ParseUint(sc.Text(), 10, 32); err == nil { + last = uint32(v) + } + } + told <- last + }() + time.Sleep(time.Duration(15+run*11) * time.Millisecond) + if err := cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + cmd.Wait() + last := <-told + + s, err := Open(path) + if errors.Is(err, fs.ErrNotExist) { + if last != 0 { + t.Fatalf("run %d: the child reported checkpoint %d, but the file does not exist", run, last) + } + continue + } + if err != nil { + t.Fatalf("run %d: after a kill -9 the file does not open: %v", run, err) + } + if err := s.Verify(); err != nil { + t.Fatalf("run %d: after a kill -9 the file does not verify: %v", run, err) + } + gen := s.Durable().Checkpoint + if gen < last || gen > last+1 { + t.Fatalf("run %d: the child was told checkpoint %d was durable, the file says %d; "+ + "it may be one ahead, never behind", run, last, gen) + } + if s.Root() != NoPage { + p, err := s.Read(s.Root()) + if err != nil { + t.Fatalf("run %d: reading the root: %v", run, err) + } + if stamp := binary.BigEndian.Uint32(p[page.HeaderSize:]); stamp != gen { + t.Fatalf("run %d: the root holds the stamp of checkpoint %d, but the file is at checkpoint %d", run, stamp, gen) + } + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + recovered++ + } + t.Logf("%d of %d killed processes left a file that reopened to a consistent checkpoint; the rest died before creating one", recovered, runs) +} diff --git a/internal/pagestore/layers_test.go b/internal/pagestore/layers_test.go new file mode 100644 index 0000000..d1e48f6 --- /dev/null +++ b/internal/pagestore/layers_test.go @@ -0,0 +1,156 @@ +package pagestore + +import ( + "encoding/binary" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/page" +) + +func layersMeta() Meta { + return Meta{Version: Version, PageSize: page.Size, Root: 3, FreeListPage: 9, FreeCount: 2, NextPage: 10, FilePages: 16, Checkpoint: 4} +} + +func TestCheckMetaRejectsEachInconsistencyOnItsOwn(t *testing.T) { + if err := checkMeta(layersMeta(), 16); err != nil { + t.Fatalf("a consistent meta was rejected: %v", err) + } + cases := []struct { + name string + edit func(*Meta) + onDisk uint32 + }{ + {"next page is 0", func(m *Meta) { m.NextPage = 0 }, 16}, + {"more pages handed out than the file holds", func(m *Meta) { m.NextPage = 17 }, 16}, + {"the meta claims more pages than are on disk", func(m *Meta) {}, 15}, + {"the root is the next page to hand out", func(m *Meta) { m.Root = 10 }, 16}, + {"the root is past the end", func(m *Meta) { m.Root = 12 }, 16}, + } + for _, c := range cases { + m := layersMeta() + c.edit(&m) + if err := checkMeta(m, c.onDisk); !errors.Is(err, ErrCorrupt) { + t.Errorf("checkMeta accepted a meta where %s: %v", c.name, err) + } + } + m := layersMeta() + m.Root = NoPage + if err := checkMeta(m, 16); err != nil { + t.Errorf("a store with no root was rejected: %v", err) + } + m.Root = 9 + if err := checkMeta(m, 16); err != nil { + t.Errorf("a root on the last page handed out was rejected: %v", err) + } +} + +func TestCheckFreeSetRejectsEachInconsistencyOnItsOwn(t *testing.T) { + m := layersMeta() + if err := checkFreeSet(m, []core.PageID{4, 5}, []core.PageID{9}); err != nil { + t.Fatalf("a consistent free set was rejected: %v", err) + } + cases := []struct { + name string + entries []core.PageID + lists []core.PageID + }{ + {"an entry is page 0", []core.PageID{0, 5}, []core.PageID{9}}, + {"an entry is the next page to hand out", []core.PageID{4, 10}, []core.PageID{9}}, + {"an entry is past the end", []core.PageID{4, 99}, []core.PageID{9}}, + {"an entry appears twice", []core.PageID{4, 4}, []core.PageID{9}}, + {"an entry is the root", []core.PageID{4, 3}, []core.PageID{9}}, + {"an entry is a free-list page", []core.PageID{4, 9}, []core.PageID{9}}, + {"the root is a free-list page", []core.PageID{4, 5}, []core.PageID{3}}, + } + for _, c := range cases { + if err := checkFreeSet(m, c.entries, c.lists); !errors.Is(err, ErrCorrupt) { + t.Errorf("checkFreeSet accepted a free set where %s: %v", c.name, err) + } + } + if err := checkFreeSet(m, []core.PageID{9}, []core.PageID{8}); err != nil { + t.Errorf("the last page handed out may be free: %v", err) + } +} + +func TestReadFreeListRefusesAChainThatReachesThePageNotYetHandedOut(t *testing.T) { + mf := newMemFile() + pf := page.NewFile(mf) + if err := pf.Grow(0, ChunkPages); err != nil { + t.Fatal(err) + } + m := Meta{Version: Version, PageSize: page.Size, NextPage: 5, FilePages: ChunkPages, FreeListPage: 5, FreeCount: 1} + lp := page.New(5, page.KindFreeList) + binary.BigEndian.PutUint32(lp[FreeListHeader:], 2) + lp.SetItemCount(1) + if err := pf.Write(lp); err != nil { + t.Fatal(err) + } + if _, _, err := readFreeList(pf, m); !errors.Is(err, ErrCorrupt) { + t.Errorf("a well-formed free-list page at the next page to hand out was followed: %v", err) + } + m.NextPage = 6 + if _, _, err := readFreeList(pf, m); err != nil { + t.Errorf("the same page is fine once it has been handed out: %v", err) + } +} + +func TestCreateInAMissingDirectoryFails(t *testing.T) { + if _, err := Create(filepath.Join(t.TempDir(), "missing", "index.db")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("Create in a directory that does not exist gave %v, want os.ErrNotExist", err) + } +} + +type layersShortReader struct{ *memFile } + +func (l layersShortReader) ReadAt(p []byte, off int64) (int, error) { + n, _ := l.memFile.ReadAt(p[:len(p)/2], off) + return n, nil +} + +func TestABlankCheckThatReadsShortWithoutAnErrorStillFails(t *testing.T) { + mf := newMemFile() + if _, err := mf.WriteAt(make([]byte, 100), 0); err != nil { + t.Fatal(err) + } + if _, _, err := openFile(layersShortReader{mf}); !errors.Is(err, io.ErrUnexpectedEOF) { + t.Errorf("a read that came back short with no error gave %v, want io.ErrUnexpectedEOF", err) + } +} + +type layersCloseFails struct{ *memFile } + +var errLayersClose = errors.New("layers: close failed") + +func (l layersCloseFails) Close() error { + l.memFile.Close() + return errLayersClose +} + +func TestCloseReportsAFailureToCloseTheFile(t *testing.T) { + s, err := create(page.NewFile(layersCloseFails{newMemFile()})) + if err != nil { + t.Fatal(err) + } + if err := s.Close(); !errors.Is(err, errLayersClose) { + t.Errorf("Close swallowed the file's close error: %v", err) + } +} + +func TestVerifyReportsAFailedRead(t *testing.T) { + mf := newMemFile() + f := faultsWrap(mf) + s, err := create(page.NewFile(f)) + if err != nil { + t.Fatal(err) + } + defer s.Discard() + f.arm(faultsRead, 1, nil, 0) + if err := s.Verify(); err == nil { + t.Error("Verify passed although reading the meta page failed") + } +} diff --git a/internal/pagestore/lifecycle_test.go b/internal/pagestore/lifecycle_test.go new file mode 100644 index 0000000..ff1cd55 --- /dev/null +++ b/internal/pagestore/lifecycle_test.go @@ -0,0 +1,235 @@ +package pagestore + +import ( + "bytes" + "errors" + "testing" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/page" +) + +func TestUseAfterFreeIsCaught(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + p := mustAlloc(t, s) + mustAlloc(t, s) + if err := s.Free(p.ID()); err != nil { + t.Fatal(err) + } + if _, err := s.Read(p.ID()); !errors.Is(err, ErrNotAllocated) { + t.Errorf("reading a freed page gave %v, want ErrNotAllocated", err) + } + if err := s.Write(p); !errors.Is(err, ErrNotAllocated) { + t.Errorf("writing a freed page gave %v, want ErrNotAllocated", err) + } + if err := s.SetRoot(p.ID()); !errors.Is(err, ErrNotAllocated) { + t.Errorf("making a freed page the root gave %v, want ErrNotAllocated", err) + } +} + +func TestTheRootCannotBeFreedUntilItIsReplaced(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + old := mustAlloc(t, s) + replacement := mustAlloc(t, s) + if err := s.SetRoot(old.ID()); err != nil { + t.Fatal(err) + } + if err := s.Free(old.ID()); !errors.Is(err, ErrFreeRoot) { + t.Fatalf("freeing the current root gave %v, want ErrFreeRoot", err) + } + if err := s.SetRoot(replacement.ID()); err != nil { + t.Fatal(err) + } + if err := s.Free(old.ID()); err != nil { + t.Errorf("freeing the old root after replacing it gave %v, want nil", err) + } +} + +func TestFreeListPagesCannotBeFreedOrUsed(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + a := mustAlloc(t, s) + mustAlloc(t, s) + if err := s.Free(a.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + lists := s.ListPages() + if len(lists) != 1 { + t.Fatalf("expected one free-list page, got %v", lists) + } + if err := s.Free(lists[0]); !errors.Is(err, ErrNotAllocated) { + t.Errorf("freeing a free-list page gave %v, want ErrNotAllocated", err) + } + if _, err := s.Read(lists[0]); !errors.Is(err, ErrNotAllocated) { + t.Errorf("reading a free-list page through the store gave %v, want ErrNotAllocated", err) + } +} + +func TestFreeListSpansSeveralPages(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + + n := FreeListCapacity + 200 + ids := make([]core.PageID, 0, n) + for i := 0; i < n; i++ { + ids = append(ids, mustAlloc(t, s).ID()) + } + for _, id := range ids { + if err := s.Free(id); err != nil { + t.Fatal(err) + } + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + if got := len(s.ListPages()); got != 2 { + t.Fatalf("%d free pages need 2 free-list pages of %d entries, got %d", + len(s.FreePages()), FreeListCapacity, got) + } + wantFree := len(s.FreePages()) + if err := s.Close(); err != nil { + t.Fatal(err) + } + + back := mustOpen(t, path) + defer back.Close() + if err := back.Verify(); err != nil { + t.Fatalf("Verify after reopening a two-page free list: %v", err) + } + if len(back.FreePages()) != wantFree { + t.Errorf("after a restart %d pages are free, want %d", len(back.FreePages()), wantFree) + } +} + +func TestFreeListPagesAreRecycledSoTheFileStopsGrowing(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + + keep := mustAlloc(t, s) + if err := s.SetRoot(keep.ID()); err != nil { + t.Fatal(err) + } + for round := 0; round < 200; round++ { + p := mustAlloc(t, s) + if err := s.Free(p.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + } + if err := s.Verify(); err != nil { + t.Fatal(err) + } + if next := s.Meta().NextPage; next > 8 { + t.Errorf("after 200 allocate-free-checkpoint rounds, %d pages have been handed out; "+ + "free-list pages are leaking instead of being recycled", next-1) + } + if s.Meta().FilePages != ChunkPages { + t.Errorf("the file grew to %d pages over a workload that never holds more than a few", s.Meta().FilePages) + } +} + +func TestAFailedFsyncPoisonsTheStore(t *testing.T) { + mf := newMemFile() + s, err := create(page.NewFile(mf)) + if err != nil { + t.Fatal(err) + } + durable := s.Durable() + + p, err := s.Allocate(page.KindHeap) + if err != nil { + t.Fatal(err) + } + if err := s.SetRoot(p.ID()); err != nil { + t.Fatal(err) + } + + mf.failAtSync = mf.syncs + 1 + if err := s.Checkpoint(); !errors.Is(err, ErrPoisoned) { + t.Fatalf("a checkpoint whose fsync failed gave %v, want ErrPoisoned", err) + } + if _, err := s.Allocate(page.KindHeap); !errors.Is(err, ErrPoisoned) { + t.Errorf("Allocate after a failed checkpoint gave %v, want ErrPoisoned", err) + } + if err := s.Checkpoint(); !errors.Is(err, ErrPoisoned) { + t.Errorf("retrying the checkpoint gave %v, want ErrPoisoned; after a failed fsync nothing is known", err) + } + if err := s.Close(); !errors.Is(err, ErrPoisoned) { + t.Errorf("Close of a poisoned store gave %v, want ErrPoisoned so the failure is not swallowed", err) + } + + mf.keepNothing() + back, err := load(page.NewFile(mf)) + if err != nil { + t.Fatalf("reopening after the failed checkpoint: %v", err) + } + if back.Durable() != durable { + t.Errorf("after recovery the durable meta is %+v, want the last good checkpoint %+v", back.Durable(), durable) + } + if err := back.Verify(); err != nil { + t.Errorf("Verify after recovering from a failed fsync: %v", err) + } +} + +func TestPowerLossKeepsOnlyWhatWasCheckpointed(t *testing.T) { + mf := newMemFile() + s, err := create(page.NewFile(mf)) + if err != nil { + t.Fatal(err) + } + p, err := s.Allocate(page.KindHeap) + if err != nil { + t.Fatal(err) + } + copy(p[page.HeaderSize:], []byte("durable")) + if err := s.Write(p); err != nil { + t.Fatal(err) + } + if err := s.SetRoot(p.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + + for i := 0; i < 20; i++ { + q, err := s.Allocate(page.KindHeap) + if err != nil { + t.Fatal(err) + } + copy(q[page.HeaderSize:], []byte("lost")) + if err := s.Write(q); err != nil { + t.Fatal(err) + } + } + if err := s.Discard(); err != nil { + t.Fatal(err) + } + mf.keepNothing() + + back, err := load(page.NewFile(mf)) + if err != nil { + t.Fatalf("reopening after a power loss: %v", err) + } + if err := back.Verify(); err != nil { + t.Fatalf("Verify after a power loss: %v", err) + } + if back.Root() != p.ID() || back.Meta().NextPage != p.ID()+1 { + t.Fatalf("after a power loss the root is %d and next page %d, want %d and %d", + back.Root(), back.Meta().NextPage, p.ID(), p.ID()+1) + } + got, err := back.Read(back.Root()) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(got[page.HeaderSize:], []byte("durable")) { + t.Errorf("the checkpointed root lost its contents in a power loss") + } +} diff --git a/internal/pagestore/memfile_test.go b/internal/pagestore/memfile_test.go new file mode 100644 index 0000000..bbba0d3 --- /dev/null +++ b/internal/pagestore/memfile_test.go @@ -0,0 +1,146 @@ +package pagestore + +import ( + "errors" + "io" + + "github.com/scute-db/scutedb/internal/fileio" + "github.com/scute-db/scutedb/internal/page" +) + +var errSyncFailed = errors.New("memfile: injected fsync failure") +var errMemClosed = errors.New("memfile: file is closed") + +type memWrite struct { + off int64 + data []byte +} + +type memFile struct { + durable []byte + live []byte + log []memWrite + syncs int + failAtSync int + failKeep func(i int) bool + closed bool +} + +var _ fileio.File = (*memFile)(nil) + +func newMemFile() *memFile { return &memFile{} } + +func (m *memFile) ReadAt(p []byte, off int64) (int, error) { + if m.closed { + return 0, errMemClosed + } + if off >= int64(len(m.live)) { + return 0, io.EOF + } + n := copy(p, m.live[off:]) + if n < len(p) { + return n, io.EOF + } + return n, nil +} + +func (m *memFile) WriteAt(p []byte, off int64) (int, error) { + if m.closed { + return 0, errMemClosed + } + m.live = writeInto(m.live, off, p) + m.log = append(m.log, memWrite{off: off, data: append([]byte(nil), p...)}) + return len(p), nil +} + +func writeInto(img []byte, off int64, p []byte) []byte { + if end := off + int64(len(p)); end > int64(len(img)) { + img = append(img, make([]byte, end-int64(len(img)))...) + } + copy(img[off:], p) + return img +} + +func (m *memFile) apply(keep func(i int) bool) { + for i, w := range m.log { + if keep == nil || keep(i) { + m.durable = writeInto(m.durable, w.off, w.data) + } + } + m.log = nil +} + +func (m *memFile) Sync() error { + if m.closed { + return errMemClosed + } + m.syncs++ + if m.failAtSync != 0 && m.syncs == m.failAtSync { + keep := m.failKeep + if keep == nil { + keep = func(int) bool { return false } + } + m.apply(keep) + return errSyncFailed + } + m.apply(nil) + return nil +} + +func (m *memFile) Size() (int64, error) { + if m.closed { + return 0, errMemClosed + } + return int64(len(m.live)), nil +} + +func (m *memFile) Close() error { + m.closed = true + return nil +} + +func (m *memFile) resetFaults() { + m.syncs = 0 + m.failAtSync = 0 + m.failKeep = nil + m.closed = false +} + +func (m *memFile) restart() { m.resetFaults() } + +func (m *memFile) restartEvicting(evict func(block int) bool) { + dirty := map[int]bool{} + for _, w := range m.log { + for b := int(w.off) / page.Size; b*page.Size < int(w.off)+len(w.data); b++ { + dirty[b] = true + } + } + for b := 0; b*page.Size < len(m.live); b++ { + if dirty[b] || !evict(b) { + continue + } + hi := (b + 1) * page.Size + if hi > len(m.live) { + hi = len(m.live) + } + for i := b * page.Size; i < hi; i++ { + if i < len(m.durable) { + m.live[i] = m.durable[i] + } else { + m.live[i] = 0 + } + } + } + m.resetFaults() +} + +func (m *memFile) powerLoss(keep func(i int) bool) { + if keep == nil { + keep = func(int) bool { return false } + } + m.apply(keep) + m.live = append([]byte(nil), m.durable...) + m.resetFaults() +} + +func (m *memFile) keepNothing() { m.powerLoss(func(int) bool { return false }) } diff --git a/internal/pagestore/oracle_test.go b/internal/pagestore/oracle_test.go new file mode 100644 index 0000000..756ae43 --- /dev/null +++ b/internal/pagestore/oracle_test.go @@ -0,0 +1,1380 @@ +package pagestore + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "reflect" + "sort" + "testing" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/page" +) + +const ( + oraclePageSize = 4096 + oracleListCap = (oraclePageSize - 20) / 4 + oracleMaxOps = 120 + oracleMaxPages = 3000 + oracleOpCount = 19 +) + +const ( + oracleInUse byte = iota + oracleFree + oracleList +) + +var oracleMagic = []byte{'S', 'C', 'U', 'T', 'E', 'D', 'B', 0} + +var oracleErrWrite = errors.New("oracle: injected write failure") + +type oracleImage struct { + meta Meta + chain []core.PageID + entries []core.PageID + state []byte +} + +func oracleU32(b []byte, at int) uint32 { return binary.BigEndian.Uint32(b[at : at+4]) } + +func oracleStateName(st byte) string { + switch st { + case oracleFree: + return "free" + case oracleList: + return "holding the free list" + } + return "in use" +} + +func oracleDecode(img []byte) (*oracleImage, error) { + if len(img) < oraclePageSize { + return nil, fmt.Errorf("the image is %d bytes, less than one page", len(img)) + } + if id := oracleU32(img, 0); id != 0 { + return nil, fmt.Errorf("page 0 says it is page %d", id) + } + if img[4] != 1 { + return nil, fmt.Errorf("page 0 has kind %d, want 1", img[4]) + } + if !bytes.Equal(img[16:24], oracleMagic) { + return nil, fmt.Errorf("bytes 16..23 are % 02X, not the magic", img[16:24]) + } + m := Meta{ + Version: oracleU32(img, 24), + PageSize: oracleU32(img, 28), + Root: core.PageID(oracleU32(img, 32)), + FreeListPage: core.PageID(oracleU32(img, 36)), + FreeCount: oracleU32(img, 40), + NextPage: core.PageID(oracleU32(img, 44)), + FilePages: oracleU32(img, 48), + Checkpoint: oracleU32(img, 52), + } + switch { + case m.Version != 1: + return nil, fmt.Errorf("version %d", m.Version) + case m.PageSize != oraclePageSize: + return nil, fmt.Errorf("page size %d", m.PageSize) + case m.NextPage == 0: + return nil, errors.New("next page is 0, the meta page") + case uint32(m.NextPage) > m.FilePages: + return nil, fmt.Errorf("next page %d is past the %d pages the meta claims", m.NextPage, m.FilePages) + case uint64(m.FilePages)*oraclePageSize > uint64(len(img)): + return nil, fmt.Errorf("the meta claims %d pages, the image holds %d bytes", m.FilePages, len(img)) + case m.Root != 0 && m.Root >= m.NextPage: + return nil, fmt.Errorf("root %d was never handed out, next page is %d", m.Root, m.NextPage) + } + d := &oracleImage{meta: m, state: make([]byte, m.NextPage)} + for id := m.FreeListPage; id != 0; { + if id >= m.NextPage { + return nil, fmt.Errorf("the chain reaches page %d, next page is %d", id, m.NextPage) + } + if d.state[id] == oracleList { + return nil, fmt.Errorf("the chain reaches page %d twice", id) + } + p := img[int(id)*oraclePageSize : (int(id)+1)*oraclePageSize] + if oracleU32(p, 0) != uint32(id) || p[4] != 5 { + return nil, fmt.Errorf("chain page %d says it is page %d of kind %d", id, oracleU32(p, 0), p[4]) + } + n := int(binary.BigEndian.Uint16(p[6:8])) + if n > oracleListCap { + return nil, fmt.Errorf("chain page %d claims %d entries", id, n) + } + d.state[id] = oracleList + d.chain = append(d.chain, id) + for i := 0; i < n; i++ { + d.entries = append(d.entries, core.PageID(oracleU32(p, 20+4*i))) + } + id = core.PageID(oracleU32(p, 16)) + } + if uint32(len(d.entries)) != m.FreeCount { + return nil, fmt.Errorf("the chain holds %d entries, the meta says %d", len(d.entries), m.FreeCount) + } + for _, e := range d.entries { + switch { + case e == 0 || e >= m.NextPage: + return nil, fmt.Errorf("the chain names page %d, outside 1..%d", e, m.NextPage-1) + case d.state[e] != oracleInUse: + return nil, fmt.Errorf("the chain names page %d, which is already %s", e, oracleStateName(d.state[e])) + case e == m.Root: + return nil, fmt.Errorf("the chain names the root %d", e) + } + d.state[e] = oracleFree + } + if m.Root != 0 && d.state[m.Root] == oracleList { + return nil, fmt.Errorf("the root %d holds the free list", m.Root) + } + return d, nil +} + +type oracleDisk struct { + mf *memFile + writes int + failWrite int + failedAt int64 + failedLen int + metaWritten bool + finalFailed bool + guard bool + cached *oracleImage + cachedErr error + violation error +} + +func (d *oracleDisk) ReadAt(p []byte, off int64) (int, error) { return d.mf.ReadAt(p, off) } + +func (d *oracleDisk) WriteAt(p []byte, off int64) (int, error) { + d.writes++ + if d.failWrite != 0 && d.writes == d.failWrite { + d.failWrite = 0 + d.failedAt, d.failedLen = off, len(p) + return 0, oracleErrWrite + } + if d.guard { + d.checkWrite(off, len(p)) + } + n, err := d.mf.WriteAt(p, off) + if err == nil && off < oraclePageSize { + d.metaWritten = true + } + return n, err +} + +func (d *oracleDisk) Sync() error { + err := d.mf.Sync() + if err != nil { + d.finalFailed = d.metaWritten + } + d.metaWritten = false + d.forget() + return err +} + +func (d *oracleDisk) Size() (int64, error) { return d.mf.Size() } +func (d *oracleDisk) Close() error { return d.mf.Close() } +func (d *oracleDisk) forget() { d.cached, d.cachedErr = nil, nil } + +func (d *oracleDisk) image() (*oracleImage, error) { + if d.cached == nil && d.cachedErr == nil { + d.cached, d.cachedErr = oracleDecode(d.mf.durable) + } + return d.cached, d.cachedErr +} + +func (d *oracleDisk) checkWrite(off int64, n int) { + if d.violation != nil || n == 0 { + return + } + img, err := d.image() + if err != nil { + d.violation = fmt.Errorf("the store wrote %d bytes at offset %d while the durable image did not decode: %v", n, off, err) + return + } + for id := off / oraclePageSize; id <= (off+int64(n)-1)/oraclePageSize; id++ { + if id == 0 || id >= int64(len(img.state)) { + continue + } + if st := img.state[id]; st != oracleFree { + d.violation = fmt.Errorf("the store wrote page %d while the durable image, generation %d, says it is %s", + id, img.meta.Checkpoint, oracleStateName(st)) + return + } + } +} + +func oracleSubset(seed byte) func(int) bool { + switch seed % 4 { + case 0: + return func(int) bool { return false } + case 1: + return func(int) bool { return true } + } + return func(i int) bool { + x := uint32(i)*0x9E3779B1 ^ uint32(seed)*0x85EBCA77 + x ^= x >> 13 + return (x>>5)&1 == 1 + } +} + +func oracleKeys[V any](m map[core.PageID]V) []core.PageID { + ids := make([]core.PageID, 0, len(m)) + for id := range m { + ids = append(ids, id) + } + sort.Slice(ids, func(a, b int) bool { return ids[a] < ids[b] }) + return ids +} + +func oracleSameOrder(a, b []core.PageID) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func oracleShort(ids []core.PageID) string { + if len(ids) <= 12 { + return fmt.Sprint(ids) + } + return fmt.Sprintf("%v ... %v (%d pages)", ids[:6], ids[len(ids)-6:], len(ids)) +} + +func oracleFirstDiff(a, b []byte) int { + for i := range a { + if i >= len(b) || a[i] != b[i] { + return i + } + } + return len(a) +} + +func oracleByte(ops []byte, i int) byte { + if i < len(ops) { + return ops[i] + } + return 0 +} + +type oracleSnap struct { + gen uint32 + root core.PageID + pages map[core.PageID][]byte +} + +type oracleView struct { + meta Meta + durable Meta + root core.PageID + free []core.PageID + pending []core.PageID + lists []core.PageID + dirty bool + stats Stats +} + +type oracleRun struct { + t *testing.T + disk *oracleDisk + s *Store + step int + op byte + pages map[core.PageID][]byte + fresh map[core.PageID]bool + pending map[core.PageID]bool + root core.PageID + dirty bool + committed oracleSnap + candidate *oracleSnap + stamp uint64 +} + +func oracleStart(t *testing.T) *oracleRun { + t.Helper() + disk := &oracleDisk{mf: newMemFile(), failedAt: -1} + s, err := create(page.NewFile(disk)) + if err != nil { + t.Fatalf("create: %v", err) + } + disk.guard = true + r := &oracleRun{t: t, disk: disk, s: s} + r.adopt(oracleSnap{gen: r.image().meta.Checkpoint, pages: map[core.PageID][]byte{}}) + r.checkDurable(r.committed) + return r +} + +func (r *oracleRun) fatalf(format string, args ...any) { + r.t.Helper() + r.t.Fatalf("step %d (op %d): %s", r.step, r.op, fmt.Sprintf(format, args...)) +} + +func (r *oracleRun) image() *oracleImage { + r.t.Helper() + img, err := r.disk.image() + if err != nil { + r.fatalf("the durable image does not decode: %v", err) + } + return img +} + +func (r *oracleRun) checkGuard() { + r.t.Helper() + if r.disk.violation != nil { + r.fatalf("%v", r.disk.violation) + } +} + +func (r *oracleRun) snap(gen uint32) oracleSnap { + pages := make(map[core.PageID][]byte, len(r.pages)) + for id, b := range r.pages { + pages[id] = b + } + return oracleSnap{gen: gen, root: r.root, pages: pages} +} + +func (r *oracleRun) adopt(s oracleSnap) { + r.committed = s + r.candidate = nil + r.pages = make(map[core.PageID][]byte, len(s.pages)) + for id, b := range s.pages { + r.pages[id] = b + } + r.root = s.root + r.fresh = map[core.PageID]bool{} + r.pending = map[core.PageID]bool{} + r.dirty = false +} + +func (r *oracleRun) observe() oracleView { + return oracleView{ + meta: r.s.Meta(), + durable: r.s.Durable(), + root: r.s.Root(), + free: r.s.FreePages(), + pending: r.s.PendingPages(), + lists: r.s.ListPages(), + dirty: r.s.Dirty(), + stats: r.s.Stats(), + } +} + +func (r *oracleRun) checkDurable(want oracleSnap) { + r.t.Helper() + r.checkGuard() + img, err := oracleDecode(r.disk.mf.durable) + if err != nil { + r.fatalf("the durable image does not decode: %v", err) + } + if got := r.s.Durable(); got != img.meta { + r.fatalf("the store says its durable meta is %+v, the disk holds %+v", got, img.meta) + } + if img.meta.Checkpoint != want.gen || img.meta.Root != want.root { + r.fatalf("the disk holds generation %d with root %d, want generation %d with root %d", + img.meta.Checkpoint, img.meta.Root, want.gen, want.root) + } + for id := range want.pages { + if id >= img.meta.NextPage { + r.fatalf("page %d is in use at generation %d but the disk says only pages below %d were handed out", + id, want.gen, img.meta.NextPage) + } + } + for id := core.PageID(1); id < img.meta.NextPage; id++ { + _, inUse := want.pages[id] + st := img.state[id] + if inUse && st != oracleInUse { + r.fatalf("page %d is in use at generation %d but the disk says it is %s", id, want.gen, oracleStateName(st)) + } + if !inUse && st == oracleInUse { + r.fatalf("page %d was handed out but at generation %d it is not in use, not free and not holding the list", + id, want.gen) + } + } + for id, b := range want.pages { + at := int(id) * oraclePageSize + if got := r.disk.mf.durable[at : at+oraclePageSize]; !bytes.Equal(got, b) { + r.fatalf("page %d on disk differs from what was written for generation %d, first at byte %d", + id, want.gen, oracleFirstDiff(got, b)) + } + } + if got := r.s.Meta(); got != img.meta { + r.fatalf("the store is clean but its Meta() is %+v while the disk holds %+v", got, img.meta) + } + if got := r.s.FreePages(); !oracleSameOrder(oracleSorted(got), oracleSorted(img.entries)) { + r.fatalf("the store reports free pages %s, the durable chain holds %s", oracleShort(got), oracleShort(img.entries)) + } + if got := r.s.ListPages(); !oracleSameOrder(got, img.chain) { + r.fatalf("the store reports free-list pages %v, the durable chain is %v", got, img.chain) + } + if got := r.s.PendingPages(); len(got) != 0 { + r.fatalf("pages %s are still pending at a clean point", oracleShort(got)) + } + if r.s.Dirty() { + r.fatalf("the store is dirty at a clean point") + } +} + +func (r *oracleRun) checkLive() { + r.t.Helper() + r.checkGuard() + if err := r.s.Verify(); err != nil { + r.fatalf("Verify: %v", err) + } + if got := r.s.Root(); got != r.root { + r.fatalf("the root is %d, want %d", got, r.root) + } + img := r.image() + if got := r.s.Durable(); got != img.meta { + r.fatalf("the store says its durable meta is %+v, the disk holds %+v", got, img.meta) + } + if img.meta.Checkpoint != r.committed.gen { + r.fatalf("the disk holds generation %d without a checkpoint committing it, want %d", + img.meta.Checkpoint, r.committed.gen) + } + lists := r.s.ListPages() + if !oracleSameOrder(lists, img.chain) { + r.fatalf("the store reports free-list pages %v, the durable chain is %v", lists, img.chain) + } + next := r.s.Meta().NextPage + names := [...]string{"", "in use", "free", "pending", "holding the free list"} + owner := make([]byte, next) + claim := func(id core.PageID, as byte) { + if id == 0 || id >= next { + r.fatalf("page %d is %s but only pages 1..%d are handed out", id, names[as], next-1) + } + if owner[id] != 0 { + r.fatalf("page %d is %s and also %s", id, names[owner[id]], names[as]) + } + owner[id] = as + } + for id := range r.pages { + claim(id, 1) + } + for _, id := range r.s.FreePages() { + claim(id, 2) + if int(id) >= len(img.state) { + r.fatalf("page %d is reusable but the durable image never handed it out", id) + } + if img.state[id] != oracleFree { + r.fatalf("page %d is reusable but the durable image says it is %s", id, oracleStateName(img.state[id])) + } + } + pending := r.s.PendingPages() + if len(pending) != len(r.pending) { + r.fatalf("the store holds %d pending pages, %d were freed since the last checkpoint", len(pending), len(r.pending)) + } + for _, id := range pending { + if !r.pending[id] { + r.fatalf("page %d is pending but was not freed since the last checkpoint", id) + } + claim(id, 3) + } + for _, id := range lists { + claim(id, 4) + } + for id := core.PageID(1); id < next; id++ { + if owner[id] == 0 { + r.fatalf("page %d was handed out and is now in no state at all", id) + } + } +} + +func (r *oracleRun) checkPoisoned() { + r.t.Helper() + writes, syncs := r.disk.writes, r.disk.mf.syncs + if _, err := r.s.Allocate(page.KindHeap); !errors.Is(err, ErrPoisoned) { + r.fatalf("Allocate on a poisoned store gave %v", err) + } + if err := r.s.Free(1); !errors.Is(err, ErrPoisoned) { + r.fatalf("Free on a poisoned store gave %v", err) + } + if err := r.s.Checkpoint(); !errors.Is(err, ErrPoisoned) { + r.fatalf("Checkpoint on a poisoned store gave %v", err) + } + if err := r.s.Close(); !errors.Is(err, ErrPoisoned) { + r.fatalf("Close of a poisoned store gave %v", err) + } + if r.disk.writes != writes || r.disk.mf.syncs != syncs { + r.fatalf("a poisoned store went on to write %d times and fsync %d times", + r.disk.writes-writes, r.disk.mf.syncs-syncs) + } +} + +func (r *oracleRun) stampPage(p page.Page) { + r.stamp++ + for _, at := range []int{page.HeaderSize, page.Size / 2, page.Size - 8} { + binary.BigEndian.PutUint64(p[at:], r.stamp*0x9E3779B97F4A7C15^uint64(at)) + } +} + +func (r *oracleRun) handedOut(img *oracleImage, p page.Page, kind page.Kind) { + r.t.Helper() + r.checkGuard() + id := p.ID() + if id == MetaPage { + r.fatalf("Allocate handed out the meta page") + } + if _, ok := r.pages[id]; ok { + r.fatalf("Allocate handed out page %d while it is in use", id) + } + if r.pending[id] { + r.fatalf("Allocate handed out page %d while it is pending until the next checkpoint", id) + } + if int(id) < len(img.state) && img.state[id] != oracleFree { + r.fatalf("Allocate handed out page %d while the durable image, generation %d, says it is %s", + id, img.meta.Checkpoint, oracleStateName(img.state[id])) + } + if len(p) != page.Size || p.Kind() != kind { + r.fatalf("Allocate(%s) returned a %d-byte page of kind %s", kind, len(p), p.Kind()) + } +} + +func (r *oracleRun) allocate(kind page.Kind, write bool) core.PageID { + r.t.Helper() + img := r.image() + p, err := r.s.Allocate(kind) + if err != nil { + r.fatalf("Allocate: %v", err) + } + r.handedOut(img, p, kind) + if write { + r.stampPage(p) + if err := r.s.Write(p); err != nil { + r.fatalf("Write(%d): %v", p.ID(), err) + } + } + r.pages[p.ID()] = append([]byte(nil), p...) + r.fresh[p.ID()] = true + r.dirty = true + return p.ID() +} + +func (r *oracleRun) rewrite(a byte) { + r.t.Helper() + ids := oracleKeys(r.fresh) + if len(ids) == 0 { + return + } + id := ids[int(a)%len(ids)] + p := page.Page(append([]byte(nil), r.pages[id]...)) + r.stampPage(p) + if err := r.s.Write(p); err != nil { + r.fatalf("rewriting page %d: %v", id, err) + } + r.pages[id] = p + r.dirty = true +} + +func (r *oracleRun) freeID(id core.PageID) { + r.t.Helper() + if id == r.root { + before := r.observe() + if err := r.s.Free(id); !errors.Is(err, ErrFreeRoot) { + r.fatalf("freeing the root %d gave %v, want ErrFreeRoot", id, err) + } + if after := r.observe(); !reflect.DeepEqual(before, after) { + r.fatalf("a refused Free changed the store from %+v to %+v", before, after) + } + return + } + if err := r.s.Free(id); err != nil { + r.fatalf("Free(%d): %v", id, err) + } + delete(r.pages, id) + delete(r.fresh, id) + r.pending[id] = true + r.dirty = true +} + +func (r *oracleRun) free(a byte) { + r.t.Helper() + ids := oracleKeys(r.pages) + if len(ids) == 0 { + return + } + r.freeID(ids[int(a)%len(ids)]) +} + +func (r *oracleRun) refuse(a, b byte) { + r.t.Helper() + var id core.PageID + var freeWant, useWant error + switch a % 5 { + case 0: + id, freeWant, useWant = MetaPage, ErrFreeMeta, ErrBadPageID + case 1: + ids := oracleKeys(r.pending) + if len(ids) == 0 { + return + } + id, freeWant, useWant = ids[int(b)%len(ids)], ErrDoubleFree, ErrNotAllocated + case 2: + ids := r.s.FreePages() + if len(ids) == 0 { + return + } + id, freeWant, useWant = ids[int(b)%len(ids)], ErrDoubleFree, ErrNotAllocated + case 3: + ids := r.s.ListPages() + if len(ids) == 0 { + return + } + id, freeWant, useWant = ids[int(b)%len(ids)], ErrNotAllocated, ErrNotAllocated + case 4: + id, freeWant, useWant = r.s.Meta().NextPage+core.PageID(b%3), ErrBadPageID, ErrBadPageID + } + before := r.observe() + writes := r.disk.writes + var err, want error + switch (b >> 2) % 4 { + case 0: + err, want = r.s.Free(id), freeWant + case 1: + if id == NoPage { + return + } + err, want = r.s.SetRoot(id), useWant + case 2: + _, err = r.s.Read(id) + want = useWant + case 3: + err, want = r.s.Write(page.New(id, page.KindHeap)), useWant + } + if !errors.Is(err, want) { + r.fatalf("a call on page %d, which the store does not own, gave %v, want %v", id, err, want) + } + if after := r.observe(); !reflect.DeepEqual(before, after) { + r.fatalf("a refused call on page %d changed the store from %+v to %+v", id, before, after) + } + if r.disk.writes != writes { + r.fatalf("a refused call on page %d wrote to the file", id) + } +} + +func (r *oracleRun) setRootTo(id core.PageID) { + r.t.Helper() + if err := r.s.SetRoot(id); err != nil { + r.fatalf("SetRoot(%d): %v", id, err) + } + r.root = id + r.dirty = true +} + +func (r *oracleRun) setRoot(a byte) { + r.t.Helper() + ids := oracleKeys(r.pages) + if len(ids) == 0 || a%5 == 0 { + r.setRootTo(NoPage) + return + } + r.setRootTo(ids[int(a)%len(ids)]) +} + +func (r *oracleRun) checkpoint() { + r.t.Helper() + want := r.snap(r.committed.gen + 1) + if err := r.s.Checkpoint(); err != nil { + r.fatalf("Checkpoint: %v", err) + } + r.adopt(want) + r.checkDurable(want) +} + +func (r *oracleRun) closeAndReopen(times int) { + r.t.Helper() + for j := 0; j < times; j++ { + gen := r.committed.gen + if err := r.s.Close(); err != nil { + r.fatalf("Close: %v", err) + } + img, err := oracleDecode(r.disk.mf.durable) + if err != nil { + r.fatalf("after a clean Close the durable image does not decode: %v", err) + } + if last := r.s.Meta(); last != img.meta { + r.fatalf("after a clean Close the disk holds %+v but the store's last Meta() was %+v", img.meta, last) + } + got := img.meta.Checkpoint + if r.dirty && got != gen+1 { + r.fatalf("Close left generation %d on disk although work was pending since generation %d", got, gen) + } + if !r.dirty && got != gen && got != gen+1 { + r.fatalf("Close of a store with nothing pending left generation %d on disk after %d", got, gen) + } + want := r.snap(got) + r.adopt(want) + r.checkDurable(want) + r.disk.mf.closed = false + r.reopen() + } +} + +func (r *oracleRun) crash(how, seed byte) { + r.s.Discard() + switch how % 3 { + case 0: + r.disk.mf.restart() + case 1: + r.disk.mf.restartEvicting(oracleSubset(seed)) + case 2: + r.disk.mf.powerLoss(oracleSubset(seed)) + r.disk.metaWritten = false + } + r.disk.forget() + r.disk.failWrite = 0 +} + +func (r *oracleRun) reopen() { + r.t.Helper() + s, created, err := openFile(r.disk) + if err != nil { + r.fatalf("reopening: %v", err) + } + if created { + r.fatalf("reopening an existing store created a fresh one") + } + r.s = s + r.checkGuard() + got := s.Durable().Checkpoint + var want oracleSnap + switch { + case got == r.committed.gen: + want = r.committed + case r.candidate != nil && got == r.candidate.gen: + want = *r.candidate + case r.candidate != nil: + r.fatalf("recovered generation %d; only %d, the last durable checkpoint, or %d, the one whose final fsync failed, are possible", + got, r.committed.gen, r.candidate.gen) + default: + r.fatalf("recovered generation %d, want the last durable checkpoint %d", got, r.committed.gen) + } + r.adopt(want) + r.checkDurable(want) + for id, b := range want.pages { + p, err := s.Read(id) + if err != nil { + r.fatalf("reading page %d of recovered generation %d: %v", id, want.gen, err) + } + if !bytes.Equal(p, b) { + r.fatalf("page %d of recovered generation %d reads back differently, first at byte %d", + id, want.gen, oracleFirstDiff(p, b)) + } + } +} + +func (r *oracleRun) failCheckpointSync(n int, keep func(int) bool) bool { + r.t.Helper() + want := r.snap(r.committed.gen + 1) + mf := r.disk.mf + r.disk.finalFailed = false + mf.failAtSync, mf.failKeep = mf.syncs+n, keep + err := r.s.Checkpoint() + mf.failAtSync, mf.failKeep = 0, nil + if err == nil { + r.adopt(want) + r.checkDurable(want) + return false + } + if !errors.Is(err, ErrPoisoned) || !errors.Is(err, errSyncFailed) { + r.fatalf("a checkpoint whose fsync failed gave %v, want ErrPoisoned keeping the fsync error", err) + } + if r.disk.finalFailed { + r.candidate = &want + } + r.checkPoisoned() + return true +} + +func (r *oracleRun) fsyncFailure(a, b byte) { + r.t.Helper() + if !r.failCheckpointSync(1+int(a%3), oracleSubset(b)) { + return + } + r.crash(a>>2, b>>2) + r.reopen() + if a&0x80 != 0 { + r.crash(a>>4, b>>4) + r.reopen() + } +} + +func (r *oracleRun) failCheckpointWrite(k int, how, seed byte, retry bool) (int64, int) { + r.t.Helper() + before := r.observe() + want := r.snap(r.committed.gen + 1) + r.disk.failedAt, r.disk.failedLen = -1, 0 + r.disk.failWrite = r.disk.writes + k + err := r.s.Checkpoint() + r.disk.failWrite = 0 + at, n := r.disk.failedAt, r.disk.failedLen + switch { + case at == -1: + if err != nil { + r.fatalf("Checkpoint: %v", err) + } + r.adopt(want) + r.checkDurable(want) + case err == nil: + r.fatalf("Checkpoint succeeded although its write at offset %d failed", at) + case !errors.Is(err, oracleErrWrite): + r.fatalf("a checkpoint whose write failed gave %v, which does not keep the cause", err) + case at < oraclePageSize: + if !errors.Is(err, ErrPoisoned) { + r.fatalf("a checkpoint whose meta write failed gave %v, want ErrPoisoned", err) + } + r.checkPoisoned() + r.crash(how, seed) + r.reopen() + default: + if errors.Is(err, ErrPoisoned) { + r.fatalf("a write at offset %d failed before the meta page was touched, yet the store is poisoned: %v", at, err) + } + if after := r.observe(); !reflect.DeepEqual(before, after) { + r.fatalf("a checkpoint whose write at offset %d failed changed the store from %+v to %+v", at, before, after) + } + r.checkLive() + if retry { + r.checkpoint() + } else { + r.crash(how, seed) + r.reopen() + } + } + return at, n +} + +func (r *oracleRun) allocateFailure(a, b byte) { + r.t.Helper() + img := r.image() + before := r.observe() + r.disk.failedAt = -1 + r.disk.failWrite = r.disk.writes + 1 + int(a%2) + p, err := r.s.Allocate(page.KindHeap) + r.disk.failWrite = 0 + if err == nil { + if r.disk.failedAt != -1 { + r.fatalf("Allocate succeeded although its write at offset %d failed", r.disk.failedAt) + } + r.handedOut(img, p, page.KindHeap) + r.pages[p.ID()] = append([]byte(nil), p...) + r.fresh[p.ID()] = true + r.dirty = true + return + } + if !errors.Is(err, oracleErrWrite) || errors.Is(err, ErrPoisoned) { + r.fatalf("an Allocate whose write failed gave %v, want the plain write error", err) + } + after := r.observe() + if after.meta.NextPage != before.meta.NextPage || after.root != before.root || after.durable != before.durable || + after.dirty != before.dirty || !reflect.DeepEqual(after.free, before.free) || + !reflect.DeepEqual(after.pending, before.pending) || !reflect.DeepEqual(after.lists, before.lists) { + r.fatalf("a failed Allocate changed the store from %+v to %+v", before, after) + } + r.crash(b, b>>2) + r.reopen() +} + +func (r *oracleRun) writeFailure(a byte) { + r.t.Helper() + ids := oracleKeys(r.fresh) + if len(ids) == 0 { + return + } + p := page.Page(append([]byte(nil), r.pages[ids[int(a)%len(ids)]]...)) + r.stampPage(p) + r.disk.failWrite = r.disk.writes + 1 + err := r.s.Write(p) + r.disk.failWrite = 0 + if !errors.Is(err, oracleErrWrite) || errors.Is(err, ErrPoisoned) { + r.fatalf("a Write whose write failed gave %v, want the plain write error", err) + } +} + +func (r *oracleRun) storeSync(a, b byte) { + r.t.Helper() + mf := r.disk.mf + if a%2 == 0 { + if err := r.s.Sync(); err != nil { + r.fatalf("Sync: %v", err) + } + return + } + mf.failAtSync, mf.failKeep = mf.syncs+1, oracleSubset(b) + err := r.s.Sync() + mf.failAtSync, mf.failKeep = 0, nil + if !errors.Is(err, ErrPoisoned) || !errors.Is(err, errSyncFailed) { + r.fatalf("a failed Sync gave %v, want ErrPoisoned keeping the fsync error", err) + } + r.checkPoisoned() + r.crash(a>>1, b>>2) + r.reopen() +} + +func (r *oracleRun) reopenFailure(a, b byte) { + r.t.Helper() + r.crash(a, b) + mf := r.disk.mf + cause := errSyncFailed + if a&0x40 != 0 { + cause = oracleErrWrite + r.disk.failWrite = r.disk.writes + 1 + } else { + mf.failAtSync, mf.failKeep = mf.syncs+1, oracleSubset(b>>3) + } + s, _, err := openFile(r.disk) + mf.failAtSync, mf.failKeep = 0, nil + r.disk.failWrite = 0 + if err == nil { + s.Discard() + r.fatalf("a reopen whose rewrite of the meta page failed handed back a store") + } + if !errors.Is(err, cause) { + r.fatalf("a reopen that failed with %v gave %v, which does not keep the cause", cause, err) + } + r.checkGuard() + r.crash(a>>2, b>>1) + r.reopen() +} + +func (r *oracleRun) bulkOf(k int) { + r.t.Helper() + img := r.image() + ids := make([]core.PageID, 0, k) + for j := 0; j < k; j++ { + p, err := r.s.Allocate(page.KindHeap) + if err != nil { + r.fatalf("Allocate %d of %d: %v", j, k, err) + } + r.handedOut(img, p, page.KindHeap) + r.pages[p.ID()] = p + ids = append(ids, p.ID()) + } + for _, id := range ids { + if err := r.s.Free(id); err != nil { + r.fatalf("Free(%d): %v", id, err) + } + delete(r.pages, id) + r.pending[id] = true + } + r.dirty = true +} + +func (r *oracleRun) bulk(a, b byte) { + r.t.Helper() + k := (int(a%2)+1)*oracleListCap - 24 + int(b%48) + if int(r.s.Meta().NextPage)+k-len(r.s.FreePages()) > oracleMaxPages { + return + } + r.bulkOf(k) +} + +func (r *oracleRun) corruptCopy(a, b byte) { + r.t.Helper() + img := r.image() + target := MetaPage + if n := len(img.chain); a%4 != 0 && n > 0 { + target = img.chain[int(a%4-1)%n] + } + if err := oracleFlipAndOpen(r.disk.mf.durable, target, int(b%64), byte(1)<<((a>>2)%8)); err != nil { + r.fatalf("%v", err) + } +} + +func (r *oracleRun) apply(op, a, b byte) { + r.t.Helper() + r.op = op % oracleOpCount + kinds := []page.Kind{page.KindHeap, page.KindBTreeLeaf, page.KindBTreeInternal} + switch r.op { + case 0, 1: + r.allocate(kinds[int(a)%len(kinds)], b%4 != 0) + case 2: + r.rewrite(a) + case 3: + r.free(a) + case 4: + r.refuse(a, b) + case 5: + r.setRoot(a) + case 6: + r.checkpoint() + case 7: + for j := 0; j < 2+int(a%6); j++ { + r.checkpoint() + } + case 8: + r.closeAndReopen(1 + int(a%3)) + case 9: + r.crash(a, b) + r.reopen() + case 10: + r.fsyncFailure(a, b) + case 11: + r.failCheckpointWrite(1+int(a%6), b, b>>2, a&0x40 != 0) + case 12: + r.bulk(a, b) + case 13: + r.crash(a, b) + r.reopen() + r.crash(a>>2, b>>2) + r.reopen() + case 14: + r.storeSync(a, b) + case 15: + r.corruptCopy(a, b) + case 16: + r.allocateFailure(a, b) + case 17: + r.reopenFailure(a, b) + case 18: + r.writeFailure(a) + } +} + +func oracleOpen(mf *memFile) (s *Store, panicked any, err error) { + defer func() { + if p := recover(); p != nil { + panicked = p + } + }() + s, _, err = openFile(mf) + return s, nil, err +} + +func oracleRejection(target core.PageID, field int) error { + if target != MetaPage { + if field <= 4 || field == 6 || field == 7 { + return ErrCorrupt + } + return nil + } + switch { + case field <= 4: + return ErrCorrupt + case field >= 16 && field < 24: + return ErrNotScuteDB + case field >= 24 && field < 28: + return ErrBadVersion + case field >= 28 && field < 32: + return ErrBadPageSize + case field >= 40 && field < 44: + return ErrCorrupt + } + return nil +} + +func oracleFlipAndOpen(image []byte, target core.PageID, field int, mask byte) error { + mf := newMemFile() + mf.durable = append([]byte(nil), image...) + mf.durable[int(target)*oraclePageSize+field] ^= mask + mf.live = append([]byte(nil), mf.durable...) + where := fmt.Sprintf("flipping bits %#02x of byte %d on page %d", mask, field, target) + s, panicked, err := oracleOpen(mf) + if panicked != nil { + return fmt.Errorf("%s made Open panic: %v", where, panicked) + } + want := oracleRejection(target, field) + if err != nil { + if !errors.Is(err, ErrCorrupt) && !errors.Is(err, ErrNotScuteDB) && + !errors.Is(err, ErrBadVersion) && !errors.Is(err, ErrBadPageSize) { + return fmt.Errorf("%s: Open gave %v, which is no class of rejection", where, err) + } + if want != nil && !errors.Is(err, want) { + return fmt.Errorf("%s: Open gave %v, want %v", where, err, want) + } + return nil + } + defer s.Discard() + if want != nil { + return fmt.Errorf("%s: Open accepted the file, want %v", where, want) + } + img, derr := oracleDecode(mf.durable) + if derr != nil { + return fmt.Errorf("%s: Open accepted an image whose own bytes are inconsistent: %v", where, derr) + } + if got := s.Durable(); got != img.meta { + return fmt.Errorf("%s: Open says the durable meta is %+v, the bytes say %+v", where, got, img.meta) + } + if !oracleSameOrder(oracleSorted(s.FreePages()), oracleSorted(img.entries)) || !oracleSameOrder(s.ListPages(), img.chain) { + return fmt.Errorf("%s: Open reports free %s on pages %v, the bytes hold %s on pages %v", where, + oracleShort(s.FreePages()), s.ListPages(), oracleShort(img.entries), img.chain) + } + if err := s.Verify(); err != nil { + return fmt.Errorf("%s: Open accepted the file but Verify then rejected it: %v", where, err) + } + return nil +} + +func oracleOps(triples ...[3]byte) []byte { + out := make([]byte, 0, 3*len(triples)) + for _, t := range triples { + out = append(out, t[:]...) + } + return out +} + +var oracleSeeds = [][]byte{ + oracleOps( + [3]byte{0, 0, 1}, [3]byte{0, 1, 1}, [3]byte{1, 2, 1}, [3]byte{0, 0, 0}, [3]byte{5, 1, 0}, + [3]byte{6, 0, 0}, [3]byte{3, 0, 0}, [3]byte{0, 1, 1}, [3]byte{2, 0, 0}, [3]byte{6, 0, 0}, + [3]byte{8, 2, 0}, [3]byte{7, 3, 0}, [3]byte{9, 0, 0}, [3]byte{9, 1, 6}, [3]byte{9, 2, 7}, + [3]byte{3, 1, 0}, [3]byte{8, 0, 0}, [3]byte{9, 2, 2}, + ), + oracleOps( + [3]byte{0, 0, 1}, [3]byte{5, 1, 0}, [3]byte{12, 2, 24}, [3]byte{6, 0, 0}, [3]byte{8, 1, 0}, + [3]byte{13, 2, 0}, [3]byte{12, 0, 30}, [3]byte{7, 2, 0}, [3]byte{9, 2, 0}, [3]byte{15, 1, 7}, + [3]byte{15, 2, 3}, [3]byte{15, 3, 40}, [3]byte{15, 0, 46}, [3]byte{4, 3, 0}, [3]byte{4, 2, 5}, + ), + oracleOps( + [3]byte{0, 0, 1}, [3]byte{0, 0, 1}, [3]byte{5, 1, 0}, [3]byte{6, 0, 0}, [3]byte{0, 0, 1}, + [3]byte{5, 2, 0}, [3]byte{3, 0, 0}, [3]byte{10, 0x82, 0}, [3]byte{0, 0, 1}, [3]byte{3, 1, 0}, + [3]byte{10, 0x86, 1}, [3]byte{0, 1, 1}, [3]byte{3, 0, 0}, [3]byte{10, 2, 2}, [3]byte{0, 1, 1}, + [3]byte{10, 1, 6}, [3]byte{0, 0, 1}, [3]byte{10, 0, 3}, [3]byte{10, 0x8A, 0}, + ), + oracleOps( + [3]byte{0, 0, 1}, [3]byte{5, 1, 0}, [3]byte{6, 0, 0}, [3]byte{12, 0, 46}, [3]byte{11, 0, 1}, + [3]byte{11, 1, 0}, [3]byte{11, 0x41, 4}, [3]byte{11, 2, 2}, [3]byte{11, 0x42, 6}, [3]byte{11, 3, 5}, + [3]byte{0, 0, 1}, [3]byte{3, 1, 0}, [3]byte{11, 0, 0}, [3]byte{11, 1, 9}, [3]byte{6, 0, 0}, + ), + oracleOps( + [3]byte{0, 0, 1}, [3]byte{0, 0, 1}, [3]byte{0, 0, 1}, [3]byte{0, 0, 1}, [3]byte{5, 1, 0}, + [3]byte{3, 0, 0}, [3]byte{3, 1, 0}, [3]byte{4, 1, 4}, [3]byte{4, 1, 8}, [3]byte{4, 1, 12}, + [3]byte{4, 1, 0}, [3]byte{6, 0, 0}, [3]byte{4, 2, 0}, [3]byte{4, 2, 5}, [3]byte{4, 2, 9}, + [3]byte{4, 2, 13}, [3]byte{4, 3, 0}, [3]byte{4, 3, 5}, [3]byte{4, 3, 9}, [3]byte{4, 3, 13}, + [3]byte{4, 4, 0}, [3]byte{4, 4, 5}, [3]byte{4, 4, 9}, [3]byte{4, 4, 13}, [3]byte{4, 0, 0}, + [3]byte{4, 0, 8}, [3]byte{4, 0, 12}, [3]byte{4, 0, 4}, [3]byte{16, 0, 5}, [3]byte{14, 0, 0}, + [3]byte{0, 0, 1}, [3]byte{16, 1, 6}, [3]byte{14, 1, 2}, [3]byte{0, 0, 1}, [3]byte{17, 0, 1}, + [3]byte{17, 0x42, 6}, [3]byte{0, 0, 1}, [3]byte{18, 0, 0}, [3]byte{2, 0, 0}, [3]byte{8, 0, 0}, + ), + oracleOps( + [3]byte{0, 0, 1}, [3]byte{0, 1, 1}, [3]byte{0, 2, 1}, [3]byte{0, 0, 1}, [3]byte{0, 1, 1}, + [3]byte{0, 2, 1}, [3]byte{0, 0, 1}, [3]byte{0, 1, 1}, [3]byte{0, 2, 1}, [3]byte{0, 0, 1}, + [3]byte{0, 1, 1}, [3]byte{0, 2, 1}, [3]byte{0, 0, 1}, [3]byte{0, 1, 1}, [3]byte{0, 2, 1}, + [3]byte{16, 0, 0}, [3]byte{0, 0, 1}, [3]byte{0, 1, 1}, [3]byte{0, 2, 1}, [3]byte{0, 0, 1}, + [3]byte{0, 1, 1}, [3]byte{0, 2, 1}, [3]byte{0, 0, 1}, [3]byte{0, 1, 1}, [3]byte{0, 2, 1}, + [3]byte{0, 0, 1}, [3]byte{0, 1, 1}, [3]byte{0, 2, 1}, [3]byte{0, 0, 1}, [3]byte{0, 1, 1}, + [3]byte{0, 2, 1}, [3]byte{16, 1, 3}, [3]byte{6, 0, 0}, + ), + oracleOps( + [3]byte{15, 0, 47}, [3]byte{15, 0, 4}, [3]byte{15, 0, 16}, [3]byte{15, 0, 24}, [3]byte{15, 0, 28}, + [3]byte{15, 0, 40}, [3]byte{15, 0, 32}, [3]byte{15, 0, 36}, [3]byte{15, 0, 51}, [3]byte{15, 4, 51}, + [3]byte{0, 0, 1}, [3]byte{0, 0, 1}, [3]byte{3, 0, 0}, [3]byte{6, 0, 0}, [3]byte{15, 1, 0}, + [3]byte{15, 1, 4}, [3]byte{15, 1, 6}, [3]byte{15, 1, 7}, [3]byte{15, 1, 16}, [3]byte{15, 1, 19}, + [3]byte{15, 1, 20}, [3]byte{15, 0, 44}, [3]byte{15, 0, 47}, + ), + oracleOps( + [3]byte{0, 0, 1}, [3]byte{0, 0, 1}, [3]byte{0, 0, 1}, [3]byte{3, 0, 0}, [3]byte{6, 0, 0}, + [3]byte{7, 5, 0}, [3]byte{9, 1, 2}, [3]byte{7, 5, 0}, [3]byte{9, 2, 3}, [3]byte{13, 1, 1}, + [3]byte{8, 2, 0}, [3]byte{3, 0, 0}, [3]byte{8, 0, 0}, + ), +} + +func FuzzDiskImageAgreesWithTheStore(f *testing.F) { + for _, seed := range oracleSeeds { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, ops []byte) { + if len(ops) > 3*oracleMaxOps { + ops = ops[:3*oracleMaxOps] + } + r := oracleStart(t) + for i := 0; i < len(ops); i += 3 { + r.step = i / 3 + r.apply(ops[i], oracleByte(ops, i+1), oracleByte(ops, i+2)) + r.checkLive() + } + r.step++ + r.closeAndReopen(1) + r.checkLive() + r.s.Discard() + }) +} + +func TestAFreeListSpanningThreePagesReadsBackFromTheDiskThroughReopensAndCrashes(t *testing.T) { + for _, k := range []int{2*oracleListCap + 1, 3 * oracleListCap} { + r := oracleStart(t) + r.setRootTo(r.allocate(page.KindBTreeLeaf, true)) + r.allocate(page.KindHeap, true) + r.checkpoint() + r.bulkOf(k) + r.checkpoint() + r.checkLive() + if got := len(r.image().chain); got < 3 { + t.Fatalf("%d freed pages sit on a durable chain of %d pages, want at least 3", k, got) + } + r.closeAndReopen(3) + r.checkLive() + r.crash(2, 0) + r.reopen() + r.crash(0, 0) + r.reopen() + r.checkLive() + for j := 0; j < 5; j++ { + r.checkpoint() + r.checkLive() + } + r.crash(1, 1) + r.reopen() + r.checkLive() + for len(r.s.FreePages()) > 0 { + r.allocate(page.KindHeap, true) + } + r.checkLive() + r.crash(2, 0) + r.reopen() + r.checkLive() + if got := len(r.image().chain); got < 3 { + t.Fatalf("after a power loss the durable chain has %d pages, want the %d-page chain back", got, 3) + } + r.s.Discard() + } +} + +func oracleCheckpointThatMustGrow(t *testing.T) *oracleRun { + t.Helper() + r := oracleStart(t) + r.setRootTo(r.allocate(page.KindBTreeLeaf, true)) + r.checkpoint() + r.bulkOf(1022) + if m := r.s.Meta(); uint32(m.NextPage) != m.FilePages || len(r.s.FreePages()) != 0 { + t.Fatalf("setup: next page %d, file pages %d, %d free; want a full file and nothing reusable", + m.NextPage, m.FilePages, len(r.s.FreePages())) + } + return r +} + +func TestAFailedWriteInsideACheckpointLeavesTheStoreExactlyAsItWas(t *testing.T) { + sawGrow, sawList, sawMeta := false, false, false + for k := 1; k < 32; k++ { + r := oracleCheckpointThatMustGrow(t) + at, n := r.failCheckpointWrite(k, byte(k), byte(3*k), k%2 == 0) + r.checkLive() + switch { + case at == -1: + if !sawGrow || !sawList || !sawMeta { + t.Fatalf("the checkpoint succeeded at write %d before failures were seen in the growth %v, "+ + "a free-list page %v and the meta page %v", k, sawGrow, sawList, sawMeta) + } + r.closeAndReopen(1) + r.checkLive() + r.s.Discard() + return + case at == 0: + sawMeta = true + case n > oraclePageSize: + sawGrow = true + default: + sawList = true + } + r.s.Discard() + } + t.Fatal("the checkpoint never got past its injected write failures") +} + +func TestARecoveredCheckpointSurvivesACrashRightAfterTheReopen(t *testing.T) { + keeps := []struct { + name string + keep func(int) bool + }{ + {"nothing", func(int) bool { return false }}, + {"everything", func(int) bool { return true }}, + {"odd writes", func(i int) bool { return i%2 == 1 }}, + {"even writes", func(i int) bool { return i%2 == 0 }}, + } + for failAt := 1; failAt <= 3; failAt++ { + for _, k := range keeps { + name, keep := k.name, k.keep + for first := byte(0); first < 3; first++ { + for second := byte(0); second < 3; second++ { + r := oracleStart(t) + old := r.allocate(page.KindHeap, true) + r.allocate(page.KindHeap, true) + r.setRootTo(old) + r.checkpoint() + r.setRootTo(r.allocate(page.KindHeap, true)) + r.freeID(old) + r.allocate(page.KindBTreeInternal, true) + before := r.committed.gen + if !r.failCheckpointSync(failAt, keep) { + t.Fatalf("fsync %d of a checkpoint that writes a free list did not happen", failAt) + } + r.crash(first, 1) + r.reopen() + chosen := r.committed.gen + if chosen != before && chosen != before+1 { + t.Fatalf("recovered generation %d after %d", chosen, before) + } + r.checkLive() + r.crash(second, 0) + r.reopen() + if r.committed.gen != chosen { + t.Fatalf("fsync %d failed keeping %s, crashes %d then %d: the reopen reported generation %d, "+ + "a crash right after it recovered %d", failAt, name, first, second, chosen, r.committed.gen) + } + for len(r.s.FreePages()) > 0 { + r.allocate(page.KindHeap, true) + } + r.checkLive() + r.crash(2, 0) + r.reopen() + r.checkLive() + r.s.Discard() + } + } + } + } +} + +func TestEveryBitFlipInTheMetaOrFreeListHeaderIsRejectedOrAgreesWithTheDisk(t *testing.T) { + fresh := oracleStart(t) + freshImage := append([]byte(nil), fresh.disk.mf.durable...) + fresh.s.Discard() + + r := oracleStart(t) + var ids []core.PageID + for i := 0; i < 8; i++ { + ids = append(ids, r.allocate(page.KindBTreeLeaf, true)) + } + r.setRootTo(ids[2]) + for _, id := range []core.PageID{ids[0], ids[4], ids[5], ids[7]} { + r.freeID(id) + } + r.checkpoint() + if len(r.image().chain) != 1 { + t.Fatalf("setup: want one free-list page, the chain is %v", r.image().chain) + } + listImage := append([]byte(nil), r.disk.mf.durable...) + chain := r.image().chain[0] + r.s.Discard() + + cases := []struct { + name string + image []byte + targets []core.PageID + }{ + {"a fresh store", freshImage, []core.PageID{MetaPage}}, + {"a store with a free list", listImage, []core.PageID{MetaPage, chain}}, + } + for _, c := range cases { + for _, target := range c.targets { + for field := 0; field < 64; field++ { + for bit := 0; bit < 8; bit++ { + if err := oracleFlipAndOpen(c.image, target, field, byte(1)< int64(ChunkPages)*page.Size { + return false, nil + } + buf := make([]byte, size) + n, err := f.ReadAt(buf, 0) + if int64(n) != size { + if err == nil { + err = io.ErrUnexpectedEOF + } + return false, err + } + for _, b := range buf { + if b != 0 { + return false, nil + } + } + return true, nil +} + +func create(pf *page.File) (*Store, error) { + if err := pf.Grow(0, ChunkPages); err != nil { + return nil, err + } + s := &Store{ + pf: pf, + root: NoPage, + nextPage: 1, + filePages: ChunkPages, + status: map[core.PageID]pageState{}, + } + s.stats.Grown++ + if err := s.Checkpoint(); err != nil { + return nil, err + } + return s, nil +} + +func load(pf *page.File) (*Store, error) { + raw, err := pf.Read(MetaPage) + if err != nil { + return nil, err + } + m, err := decodeMeta(raw) + if err != nil { + return nil, err + } + onDisk, err := pf.PageCount() + if err != nil { + return nil, err + } + if err := checkMeta(m, onDisk); err != nil { + return nil, err + } + entries, lists, err := readFreeList(pf, m) + if err != nil { + return nil, err + } + if err := checkFreeSet(m, entries, lists); err != nil { + return nil, err + } + + s := &Store{ + pf: pf, + durable: m, + root: m.Root, + nextPage: m.NextPage, + filePages: m.FilePages, + free: entries, + listPage: lists, + status: make(map[core.PageID]pageState, len(entries)+len(lists)), + } + for _, id := range entries { + s.status[id] = stFree + } + for _, id := range lists { + s.status[id] = stFreeList + } + if err := s.verifyLive(onDisk, entries, lists); err != nil { + return nil, err + } + + if err := pf.Write(encodeMeta(m)); err != nil { + return nil, err + } + if err := pf.Sync(); err != nil { + return nil, err + } + return s, nil +} + +func decodeMeta(p page.Page) (Meta, error) { + var m Meta + if !bytes.Equal(p[offMagic:offMagic+8], Magic[:]) { + return m, fmt.Errorf("%w: bytes 16..23 are % 02X, want % 02X", + ErrNotScuteDB, p[offMagic:offMagic+8], Magic) + } + m.Version = binary.BigEndian.Uint32(p[offVersion:]) + if m.Version != Version { + return m, fmt.Errorf("%w: file is version %d, this build reads version %d", + ErrBadVersion, m.Version, Version) + } + m.PageSize = binary.BigEndian.Uint32(p[offPageSize:]) + if m.PageSize != page.Size { + return m, fmt.Errorf("%w: file uses %d-byte pages, this build uses %d", + ErrBadPageSize, m.PageSize, page.Size) + } + if p.Kind() != page.KindMeta { + return m, fmt.Errorf("%w: page 0 is kind %s, want meta", ErrCorrupt, p.Kind()) + } + if p.ID() != MetaPage { + return m, fmt.Errorf("%w: page 0 says it is page %d", ErrCorrupt, p.ID()) + } + m.Root = core.PageID(binary.BigEndian.Uint32(p[offRoot:])) + m.FreeListPage = core.PageID(binary.BigEndian.Uint32(p[offFreeList:])) + m.FreeCount = binary.BigEndian.Uint32(p[offFreeCount:]) + m.NextPage = core.PageID(binary.BigEndian.Uint32(p[offNextPage:])) + m.FilePages = binary.BigEndian.Uint32(p[offFilePages:]) + m.Checkpoint = binary.BigEndian.Uint32(p[offCheckpoint:]) + return m, nil +} + +func encodeMeta(m Meta) page.Page { + p := page.New(MetaPage, page.KindMeta) + copy(p[offMagic:], Magic[:]) + binary.BigEndian.PutUint32(p[offVersion:], m.Version) + binary.BigEndian.PutUint32(p[offPageSize:], m.PageSize) + binary.BigEndian.PutUint32(p[offRoot:], uint32(m.Root)) + binary.BigEndian.PutUint32(p[offFreeList:], uint32(m.FreeListPage)) + binary.BigEndian.PutUint32(p[offFreeCount:], m.FreeCount) + binary.BigEndian.PutUint32(p[offNextPage:], uint32(m.NextPage)) + binary.BigEndian.PutUint32(p[offFilePages:], m.FilePages) + binary.BigEndian.PutUint32(p[offCheckpoint:], m.Checkpoint) + p.SetFreeStart(uint16(page.HeaderSize + MetaSize)) + return p +} + +func checkMeta(m Meta, pagesOnDisk uint32) error { + if m.NextPage == 0 { + return fmt.Errorf("%w: next page is 0, which is the meta page", ErrCorrupt) + } + if uint32(m.NextPage) > m.FilePages { + return fmt.Errorf("%w: %d pages handed out but the meta says the file holds %d", + ErrCorrupt, m.NextPage, m.FilePages) + } + if pagesOnDisk < m.FilePages { + return fmt.Errorf("%w: meta says the file holds %d pages, it actually holds %d", + ErrCorrupt, m.FilePages, pagesOnDisk) + } + if m.Root != NoPage && uint32(m.Root) >= uint32(m.NextPage) { + return fmt.Errorf("%w: root is page %d, only %d handed out", ErrCorrupt, m.Root, m.NextPage) + } + return nil +} + +func readFreeList(pf *page.File, m Meta) ([]core.PageID, []core.PageID, error) { + var entries, lists []core.PageID + seen := map[core.PageID]bool{} + for id := m.FreeListPage; id != NoPage; { + if uint32(id) >= uint32(m.NextPage) { + return nil, nil, fmt.Errorf("%w: free list reaches page %d, only %d handed out", + ErrCorrupt, id, m.NextPage) + } + if seen[id] { + return nil, nil, fmt.Errorf("%w: free-list page %d is reached twice, so the chain loops", + ErrCorrupt, id) + } + seen[id] = true + p, err := pf.Read(id) + if err != nil { + return nil, nil, err + } + if p.Kind() != page.KindFreeList { + return nil, nil, fmt.Errorf("%w: page %d is in the free-list chain but its kind is %s", + ErrCorrupt, id, p.Kind()) + } + if p.ID() != id { + return nil, nil, fmt.Errorf("%w: free-list page read from slot %d says it is page %d", + ErrCorrupt, id, p.ID()) + } + n := int(p.ItemCount()) + if n > FreeListCapacity { + return nil, nil, fmt.Errorf("%w: free-list page %d claims %d entries, capacity is %d", + ErrCorrupt, id, n, FreeListCapacity) + } + for i := 0; i < n; i++ { + entries = append(entries, core.PageID(binary.BigEndian.Uint32(p[FreeListHeader+4*i:]))) + } + lists = append(lists, id) + id = core.PageID(binary.BigEndian.Uint32(p[offFreeListNext:])) + } + if uint32(len(entries)) != m.FreeCount { + return nil, nil, fmt.Errorf("%w: free list holds %d entries, meta says %d", + ErrCorrupt, len(entries), m.FreeCount) + } + return entries, lists, nil +} + +func checkFreeSet(m Meta, entries, lists []core.PageID) error { + seen := make(map[core.PageID]string, len(entries)+len(lists)) + for _, id := range lists { + if id == m.Root && m.Root != NoPage { + return fmt.Errorf("%w: the root page %d is also a free-list page", ErrCorrupt, id) + } + seen[id] = "a free-list page" + } + for _, id := range entries { + if id == MetaPage || uint32(id) >= uint32(m.NextPage) { + return fmt.Errorf("%w: free list names page %d, outside 1..%d", ErrCorrupt, id, m.NextPage-1) + } + if what, dup := seen[id]; dup { + return fmt.Errorf("%w: page %d is on the free list and is also %s", ErrCorrupt, id, what) + } + if id == m.Root { + return fmt.Errorf("%w: the root page %d is on the free list", ErrCorrupt, id) + } + seen[id] = "already on the free list" + } + return nil +} + +func pagesFor(entries int) int { + return (entries + FreeListCapacity - 1) / FreeListCapacity +} + +func growTo(filePages uint32) uint32 { + g := uint64(filePages) + ChunkPages + if g > uint64(MaxPages) { + g = uint64(MaxPages) + } + return uint32(g) +} + +type mustPoison struct{ err error } + +func (e mustPoison) Error() string { return e.err.Error() } + +func (s *Store) poison(err error) error { + if s.poisoned == nil { + s.poisoned = err + } + return fmt.Errorf("%w: %w", ErrPoisoned, err) +} + +func (s *Store) usable() error { + if s.poisoned != nil { + return fmt.Errorf("%w: %w", ErrPoisoned, s.poisoned) + } + if s.closed { + return ErrClosed + } + return nil +} + +func (s *Store) Durable() Meta { return s.durable } +func (s *Store) Stats() Stats { return s.stats } +func (s *Store) Dirty() bool { return s.dirty } + +func (s *Store) Root() core.PageID { return s.root } + +func (s *Store) Meta() Meta { + m := s.durable + m.Root = s.root + m.NextPage = s.nextPage + m.FilePages = s.filePages + m.FreeCount = uint32(len(s.free)) + return m +} + +func (s *Store) FreePages() []core.PageID { return append([]core.PageID(nil), s.free...) } +func (s *Store) PendingPages() []core.PageID { return append([]core.PageID(nil), s.pending...) } +func (s *Store) ListPages() []core.PageID { return append([]core.PageID(nil), s.listPage...) } + +func (s *Store) inRange(id core.PageID) error { + if id == MetaPage || uint32(id) >= uint32(s.nextPage) { + return fmt.Errorf("%w: page %d, pages 1..%d are handed out", ErrBadPageID, id, s.nextPage-1) + } + return nil +} + +func (s *Store) owned(id core.PageID) error { + if err := s.inRange(id); err != nil { + return err + } + if st, ok := s.status[id]; ok { + return fmt.Errorf("%w: page %d is %s", ErrNotAllocated, id, st) + } + return nil +} + +func (s *Store) SetRoot(id core.PageID) error { + if err := s.usable(); err != nil { + return err + } + if id != NoPage { + if err := s.owned(id); err != nil { + return err + } + } + s.root = id + s.dirty = true + return nil +} + +func (s *Store) Read(id core.PageID) (page.Page, error) { + if err := s.usable(); err != nil { + return nil, err + } + if err := s.owned(id); err != nil { + return nil, err + } + return s.pf.Read(id) +} + +func (s *Store) Write(p page.Page) error { + if err := s.usable(); err != nil { + return err + } + if err := s.owned(p.ID()); err != nil { + return err + } + if err := s.pf.Write(p); err != nil { + return err + } + s.dirty = true + return nil +} + +func (s *Store) Allocate(kind page.Kind) (page.Page, error) { + if err := s.usable(); err != nil { + return nil, err + } + + reused := len(s.free) > 0 + filePages, grown := s.filePages, s.stats.Grown + var id core.PageID + if reused { + id = s.free[len(s.free)-1] + s.free = s.free[:len(s.free)-1] + delete(s.status, id) + } else { + id = s.nextPage + if uint32(id) >= MaxPages { + return nil, ErrFull + } + if uint32(id) >= s.filePages { + grown := growTo(s.filePages) + if grown <= uint32(id) { + return nil, ErrFull + } + if err := s.pf.Grow(s.filePages, grown); err != nil { + return nil, err + } + s.filePages = grown + s.stats.Grown++ + } + s.nextPage = id + 1 + } + + fresh := page.New(id, kind) + if err := s.pf.Write(fresh); err != nil { + if reused { + s.free = append(s.free, id) + s.status[id] = stFree + } else { + s.nextPage = id + s.filePages = filePages + s.stats.Grown = grown + } + return nil, err + } + if reused { + s.stats.Reused++ + } else { + s.stats.Allocated++ + } + s.dirty = true + return fresh, nil +} + +func (s *Store) Free(id core.PageID) error { + if err := s.usable(); err != nil { + return err + } + if id == MetaPage { + return ErrFreeMeta + } + if err := s.inRange(id); err != nil { + return err + } + if st, ok := s.status[id]; ok { + if st == stFreeList { + return fmt.Errorf("%w: page %d is %s", ErrNotAllocated, id, st) + } + return fmt.Errorf("%w: page %d is already %s", ErrDoubleFree, id, st) + } + if id == s.root { + return fmt.Errorf("%w: page %d", ErrFreeRoot, id) + } + s.pending = append(s.pending, id) + s.status[id] = stPending + s.dirty = true + s.stats.Freed++ + return nil +} + +func (s *Store) Sync() error { + if err := s.usable(); err != nil { + return err + } + if err := s.pf.Sync(); err != nil { + return s.poison(err) + } + return nil +} + +func (s *Store) Checkpoint() error { + if err := s.usable(); err != nil { + return err + } + if err := s.checkpoint(); err != nil { + var fatal mustPoison + if errors.As(err, &fatal) { + return s.poison(fatal.err) + } + return err + } + return nil +} + +func (s *Store) checkpoint() error { + if err := s.pf.Sync(); err != nil { + return mustPoison{err} + } + + free := append([]core.PageID(nil), s.free...) + nextPage, filePages := s.nextPage, s.filePages + grew := 0 + + count := func() int { return len(free) + len(s.pending) + len(s.listPage) } + var lists []core.PageID + for pagesFor(count()) > len(lists) { + var id core.PageID + if n := len(free); n > 0 { + id = free[n-1] + free = free[:n-1] + } else { + id = nextPage + if uint32(id) >= MaxPages { + return ErrFull + } + if uint32(id) >= filePages { + grown := growTo(filePages) + if grown <= uint32(id) { + return ErrFull + } + if err := s.pf.Grow(filePages, grown); err != nil { + return err + } + filePages = grown + grew++ + } + nextPage = id + 1 + } + lists = append(lists, id) + } + + entries := make([]core.PageID, 0, count()) + entries = append(entries, s.listPage...) + entries = append(entries, free...) + entries = append(entries, s.pending...) + + for i, id := range lists { + p := page.New(id, page.KindFreeList) + next := NoPage + if i+1 < len(lists) { + next = lists[i+1] + } + binary.BigEndian.PutUint32(p[offFreeListNext:], uint32(next)) + lo := i * FreeListCapacity + hi := lo + FreeListCapacity + if hi > len(entries) { + hi = len(entries) + } + for j, e := range entries[lo:hi] { + binary.BigEndian.PutUint32(p[FreeListHeader+4*j:], uint32(e)) + } + p.SetItemCount(uint16(hi - lo)) + p.SetFreeStart(uint16(FreeListHeader + 4*(hi-lo))) + if err := s.pf.Write(p); err != nil { + return err + } + } + if len(lists) > 0 { + if err := s.pf.Sync(); err != nil { + return mustPoison{err} + } + } + + head := NoPage + if len(lists) > 0 { + head = lists[0] + } + next := Meta{ + Version: Version, + PageSize: page.Size, + Root: s.root, + FreeListPage: head, + FreeCount: uint32(len(entries)), + NextPage: nextPage, + FilePages: filePages, + Checkpoint: s.durable.Checkpoint + 1, + } + if err := s.pf.Write(encodeMeta(next)); err != nil { + return mustPoison{err} + } + if err := s.pf.Sync(); err != nil { + return mustPoison{err} + } + + s.durable = next + s.nextPage = nextPage + s.filePages = filePages + s.free = entries + s.pending = nil + s.listPage = lists + s.status = make(map[core.PageID]pageState, len(entries)+len(lists)) + for _, id := range entries { + s.status[id] = stFree + } + for _, id := range lists { + s.status[id] = stFreeList + } + s.dirty = false + s.stats.Grown += grew + s.stats.Checkpoints++ + return nil +} + +func (s *Store) Close() error { + if s.closed { + return nil + } + var err error + if s.poisoned == nil && s.dirty { + err = s.Checkpoint() + } + s.closed = true + cerr := s.pf.Close() + if s.poisoned != nil { + return fmt.Errorf("%w: %w", ErrPoisoned, s.poisoned) + } + if err != nil { + return err + } + return cerr +} + +func (s *Store) Discard() error { + if s.closed { + return nil + } + s.closed = true + return s.pf.Close() +} + +func (s *Store) Verify() error { + if err := s.usable(); err != nil { + return err + } + + raw, err := s.pf.Read(MetaPage) + if err != nil { + return err + } + m, err := decodeMeta(raw) + if err != nil { + return err + } + if m != s.durable { + return fmt.Errorf("%w: meta page on disk %+v differs from the last checkpoint %+v", + ErrCorrupt, m, s.durable) + } + onDisk, err := s.pf.PageCount() + if err != nil { + return err + } + if err := checkMeta(m, onDisk); err != nil { + return err + } + entries, lists, err := readFreeList(s.pf, m) + if err != nil { + return err + } + if err := checkFreeSet(m, entries, lists); err != nil { + return err + } + return s.verifyLive(onDisk, entries, lists) +} + +func (s *Store) verifyLive(pagesOnDisk uint32, entries, lists []core.PageID) error { + if s.nextPage < s.durable.NextPage { + return fmt.Errorf("%w: next page went backwards, %d after a checkpoint at %d", + ErrCorrupt, s.nextPage, s.durable.NextPage) + } + if uint32(s.nextPage) > s.filePages || s.filePages > pagesOnDisk { + return fmt.Errorf("%w: %d pages handed out, %d claimed, %d on disk", + ErrCorrupt, s.nextPage, s.filePages, pagesOnDisk) + } + if s.root != NoPage { + if err := s.inRange(s.root); err != nil { + return fmt.Errorf("%w: root: %v", ErrCorrupt, err) + } + if st, ok := s.status[s.root]; ok { + return fmt.Errorf("%w: the root page %d is %s", ErrCorrupt, s.root, st) + } + } + + if len(s.listPage) != len(lists) { + return fmt.Errorf("%w: %d free-list pages in memory, %d on disk", ErrCorrupt, len(s.listPage), len(lists)) + } + for i := range lists { + if s.listPage[i] != lists[i] { + return fmt.Errorf("%w: free-list pages are %v in memory, %v on disk", ErrCorrupt, s.listPage, lists) + } + } + + seen := make(map[core.PageID]pageState, len(s.status)) + record := func(ids []core.PageID, want pageState) error { + for _, id := range ids { + if err := s.inRange(id); err != nil { + return fmt.Errorf("%w: a %s page: %v", ErrCorrupt, want, err) + } + if prev, dup := seen[id]; dup { + return fmt.Errorf("%w: page %d is listed as %s and again as %s", ErrCorrupt, id, prev, want) + } + seen[id] = want + if got := s.status[id]; got != want { + return fmt.Errorf("%w: page %d is listed as %s but marked %s", ErrCorrupt, id, want, got) + } + } + return nil + } + if err := record(s.free, stFree); err != nil { + return err + } + if err := record(s.pending, stPending); err != nil { + return err + } + if err := record(s.listPage, stFreeList); err != nil { + return err + } + if len(seen) != len(s.status) { + for id, st := range s.status { + if _, ok := seen[id]; !ok { + return fmt.Errorf("%w: page %d is marked %s but is on no list", ErrCorrupt, id, st) + } + } + } + + durableFree := make(map[core.PageID]bool, len(entries)) + for _, id := range entries { + durableFree[id] = true + } + for _, id := range s.free { + if !durableFree[id] { + return fmt.Errorf("%w: page %d is reusable now but was not free at the last checkpoint, "+ + "so overwriting it could damage the durable state", ErrCorrupt, id) + } + } + return nil +} diff --git a/internal/pagestore/pagestore_test.go b/internal/pagestore/pagestore_test.go new file mode 100644 index 0000000..cdccaa9 --- /dev/null +++ b/internal/pagestore/pagestore_test.go @@ -0,0 +1,757 @@ +package pagestore + +import ( + "bytes" + "encoding/binary" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/page" +) + +func tmpPath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "index.db") +} + +func mustCreate(t *testing.T, path string) *Store { + t.Helper() + s, err := Create(path) + if err != nil { + t.Fatalf("Create: %v", err) + } + return s +} + +func mustOpen(t *testing.T, path string) *Store { + t.Helper() + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + return s +} + +func mustAlloc(t *testing.T, s *Store) page.Page { + t.Helper() + p, err := s.Allocate(page.KindBTreeLeaf) + if err != nil { + t.Fatalf("Allocate: %v", err) + } + return p +} + +func TestCreateWritesAReadableMetaPage(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + m := s.Meta() + if m.Version != Version || m.PageSize != page.Size { + t.Fatalf("meta says version %d page size %d", m.Version, m.PageSize) + } + if m.Root != NoPage || m.FreeListPage != NoPage || m.FreeCount != 0 { + t.Fatalf("a fresh store should have no root and no free pages: %+v", m) + } + if m.NextPage != 1 { + t.Fatalf("next page is %d, want 1 — page 0 is the meta page", m.NextPage) + } + if err := s.Verify(); err != nil { + t.Fatalf("Verify on a fresh store: %v", err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if want := int64(ChunkPages) * page.Size; info.Size() != want { + t.Errorf("a fresh file is %d bytes, want one chunk of %d", info.Size(), want) + } +} + +func TestGoldenMetaLayout(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + if err := s.Close(); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := []struct { + at int + bytes []byte + what string + }{ + {0, []byte{0, 0, 0, 0}, "page id 0"}, + {4, []byte{byte(page.KindMeta)}, "kind meta"}, + {8, []byte{0, 0x38}, "free start 56 = 16 header + 40 meta"}, + {10, []byte{0x10, 0x00}, "free end 4096"}, + {16, []byte{'S', 'C', 'U', 'T', 'E', 'D', 'B', 0x00}, "magic, with no version inside it"}, + {24, []byte{0, 0, 0, 1}, "format version 1"}, + {28, []byte{0, 0, 0x10, 0x00}, "page size 4096"}, + {32, []byte{0, 0, 0, 0}, "root, none yet"}, + {36, []byte{0, 0, 0, 0}, "free list page, none"}, + {40, []byte{0, 0, 0, 0}, "free count 0"}, + {44, []byte{0, 0, 0, 1}, "next page 1"}, + {48, []byte{0, 0, 0, 16}, "file holds 16 pages"}, + {52, []byte{0, 0, 0, 1}, "checkpoint generation 1"}, + } + for _, w := range want { + got := raw[w.at : w.at+len(w.bytes)] + if !bytes.Equal(got, w.bytes) { + t.Errorf("bytes %d..%d are % 02X, want % 02X (%s)", + w.at, w.at+len(w.bytes)-1, got, w.bytes, w.what) + } + } +} + +func TestOpenRejectsAForeignFile(t *testing.T) { + path := tmpPath(t) + junk := bytes.Repeat([]byte("this is not a database, it is a poem.\n"), 200) + if err := os.WriteFile(path, junk, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Open(path); !errors.Is(err, ErrNotScuteDB) { + t.Fatalf("Open of a text file gave %v, want ErrNotScuteDB", err) + } +} + +func TestOpenRejectsAShortFile(t *testing.T) { + path := tmpPath(t) + if err := os.WriteFile(path, []byte("SCUTEDB"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Open(path); !errors.Is(err, ErrNotScuteDB) { + t.Fatalf("Open of a 7-byte file gave %v, want ErrNotScuteDB", err) + } +} + +func TestOpenRejectsAVersionItCannotRead(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + if err := s.Close(); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + binary.BigEndian.PutUint32(raw[offVersion:], Version+1) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Open(path); !errors.Is(err, ErrBadVersion) { + t.Fatalf("Open of a future version gave %v, want ErrBadVersion", err) + } +} + +func TestOpenRejectsADifferentPageSize(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + if err := s.Close(); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + binary.BigEndian.PutUint32(raw[offPageSize:], 8192) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Open(path); !errors.Is(err, ErrBadPageSize) { + t.Fatalf("Open of an 8192-byte-page file gave %v, want ErrBadPageSize", err) + } +} + +func TestAllocateHandsOutConsecutivePages(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + for want := core.PageID(1); want <= 10; want++ { + p := mustAlloc(t, s) + if p.ID() != want { + t.Fatalf("allocation %d handed out page %d", want, p.ID()) + } + if p.Kind() != page.KindBTreeLeaf { + t.Fatalf("page %d has kind %s, want btree-leaf", p.ID(), p.Kind()) + } + } + if err := s.Verify(); err != nil { + t.Fatalf("Verify: %v", err) + } +} + +func TestAFreedPageIsReusableOnlyAfterACheckpoint(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + + a := mustAlloc(t, s) + mustAlloc(t, s) + if err := s.Free(a.ID()); err != nil { + t.Fatalf("Free: %v", err) + } + if got := s.PendingPages(); len(got) != 1 || got[0] != a.ID() { + t.Fatalf("pending is %v, want [%d]", got, a.ID()) + } + if got := s.FreePages(); len(got) != 0 { + t.Fatalf("free is %v before a checkpoint, want empty", got) + } + + early := mustAlloc(t, s) + if early.ID() == a.ID() { + t.Fatalf("page %d was handed out again before the checkpoint that freed it", a.ID()) + } + + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + if len(s.PendingPages()) != 0 { + t.Fatalf("pending is %v after a checkpoint, want empty", s.PendingPages()) + } + again := mustAlloc(t, s) + if again.ID() != a.ID() { + t.Errorf("after the checkpoint, allocate handed out page %d, want the freed page %d", again.ID(), a.ID()) + } + if st := s.Stats(); st.Reused != 1 { + t.Errorf("stats say %d reused, want 1", st.Reused) + } + if err := s.Verify(); err != nil { + t.Fatalf("Verify: %v", err) + } +} + +func TestFreeListHandsBackTheMostRecentlyFreed(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + + var ids []core.PageID + for i := 0; i < 5; i++ { + ids = append(ids, mustAlloc(t, s).ID()) + } + for _, id := range ids { + if err := s.Free(id); err != nil { + t.Fatal(err) + } + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + for i := range ids { + got := mustAlloc(t, s).ID() + if want := ids[len(ids)-1-i]; got != want { + t.Fatalf("reuse %d handed out page %d, want %d — the most recently freed page comes back first", i, got, want) + } + } +} + +func TestDoubleFreeIsRejected(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + p := mustAlloc(t, s) + if err := s.Free(p.ID()); err != nil { + t.Fatal(err) + } + if err := s.Free(p.ID()); !errors.Is(err, ErrDoubleFree) { + t.Errorf("freeing a pending page again gave %v, want ErrDoubleFree", err) + } + if len(s.PendingPages()) != 1 { + t.Errorf("pending is %v after a rejected double free, want one page", s.PendingPages()) + } + + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + if err := s.Free(p.ID()); !errors.Is(err, ErrDoubleFree) { + t.Errorf("freeing a page that is already on the free list gave %v, want ErrDoubleFree", err) + } +} + +func TestFreeingTheMetaPageIsRejected(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + if err := s.Free(MetaPage); !errors.Is(err, ErrFreeMeta) { + t.Errorf("freeing page 0 gave %v, want ErrFreeMeta", err) + } +} + +func TestFreeingAPageNeverHandedOutIsRejected(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Close() + mustAlloc(t, s) + if err := s.Free(9); !errors.Is(err, ErrBadPageID) { + t.Errorf("freeing an unallocated page gave %v, want ErrBadPageID", err) + } + if _, err := s.Read(9); !errors.Is(err, ErrBadPageID) { + t.Errorf("reading an unallocated page gave %v, want ErrBadPageID", err) + } +} + +func TestFileGrowsOneChunkAtATime(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + defer s.Close() + + sizeNow := func() int64 { + t.Helper() + if err := s.Sync(); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info.Size() + } + + chunk := int64(ChunkPages) * page.Size + if got := sizeNow(); got != chunk { + t.Fatalf("fresh file is %d bytes, want %d", got, chunk) + } + + for i := 0; i < ChunkPages-1; i++ { + mustAlloc(t, s) + } + if got := sizeNow(); got != chunk { + t.Errorf("after filling the first chunk the file is %d bytes, want %d — no growth yet", got, chunk) + } + if s.Stats().Grown != 1 { + t.Errorf("the file grew %d times filling the first chunk, want 1 (the create)", s.Stats().Grown) + } + + mustAlloc(t, s) + if got, want := sizeNow(), 2*chunk; got != want { + t.Errorf("after one more page the file is %d bytes, want %d", got, want) + } + if s.Stats().Grown != 2 { + t.Errorf("the file grew %d times, want 2", s.Stats().Grown) + } + if err := s.Verify(); err != nil { + t.Fatalf("Verify: %v", err) + } +} + +func TestRootSurvivesARestart(t *testing.T) { + path := tmpPath(t) + + s := mustCreate(t, path) + root := mustAlloc(t, s) + mustAlloc(t, s) + if err := s.SetRoot(root.ID()); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + + back := mustOpen(t, path) + defer back.Close() + if back.Root() != root.ID() { + t.Fatalf("after a restart the root is page %d, want %d", back.Root(), root.ID()) + } + if err := back.Verify(); err != nil { + t.Fatalf("Verify after a restart: %v", err) + } + if back.Meta().NextPage != 3 { + t.Errorf("after a restart the next page is %d, want 3 — the high-water mark must persist", + back.Meta().NextPage) + } + next := mustAlloc(t, back) + if next.ID() != 3 { + t.Errorf("the first allocation after a restart handed out page %d, want 3", next.ID()) + } +} + +func TestWorkWithoutACheckpointIsLostOnRestart(t *testing.T) { + path := tmpPath(t) + + s := mustCreate(t, path) + first := mustAlloc(t, s) + if err := s.SetRoot(first.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + + second := mustAlloc(t, s) + if err := s.SetRoot(second.ID()); err != nil { + t.Fatal(err) + } + if !s.Dirty() { + t.Fatal("the store should be dirty after moving the root") + } + if err := s.Discard(); err != nil { + t.Fatal(err) + } + + back := mustOpen(t, path) + defer back.Close() + if back.Root() != first.ID() { + t.Errorf("after a crash the root is page %d, want the last checkpointed root %d", + back.Root(), first.ID()) + } + if err := back.Verify(); err != nil { + t.Errorf("a file abandoned without a checkpoint should still be structurally valid: %v", err) + } + if back.Meta().Checkpoint != 2 { + t.Errorf("checkpoint generation is %d, want 2 — create plus one explicit checkpoint", + back.Meta().Checkpoint) + } +} + +func TestFreeListSurvivesARestart(t *testing.T) { + path := tmpPath(t) + + s := mustCreate(t, path) + var ids []core.PageID + for i := 0; i < 6; i++ { + ids = append(ids, mustAlloc(t, s).ID()) + } + for _, id := range ids[:4] { + if err := s.Free(id); err != nil { + t.Fatal(err) + } + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + wantFree := s.FreePages() + wantLists := s.ListPages() + if err := s.Close(); err != nil { + t.Fatal(err) + } + + back := mustOpen(t, path) + defer back.Close() + if err := back.Verify(); err != nil { + t.Fatalf("Verify after a restart: %v", err) + } + gotFree, gotLists := back.FreePages(), back.ListPages() + if len(gotFree) != len(wantFree) || len(gotLists) != len(wantLists) { + t.Fatalf("after a restart free is %v and list pages %v, were %v and %v", + gotFree, gotLists, wantFree, wantLists) + } + for i := range wantFree { + if gotFree[i] != wantFree[i] { + t.Fatalf("after a restart free is %v, was %v", gotFree, wantFree) + } + } + reused := mustAlloc(t, back).ID() + if want := wantFree[len(wantFree)-1]; reused != want { + t.Errorf("after a restart the first allocation took page %d, want %d", reused, want) + } +} + +func TestCloseCheckpointsPendingWork(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + p := mustAlloc(t, s) + if err := s.SetRoot(p.ID()); err != nil { + t.Fatal(err) + } + if !s.Dirty() { + t.Fatal("expected the store to be dirty") + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + + back := mustOpen(t, path) + defer back.Close() + if back.Root() != p.ID() { + t.Errorf("Close did not checkpoint: root is %d, want %d", back.Root(), p.ID()) + } +} + +func TestOperationsAfterCloseAreRejected(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + if err := s.Close(); err != nil { + t.Fatal(err) + } + if _, err := s.Allocate(page.KindHeap); !errors.Is(err, ErrClosed) { + t.Errorf("Allocate after Close gave %v, want ErrClosed", err) + } + if err := s.Free(1); !errors.Is(err, ErrClosed) { + t.Errorf("Free after Close gave %v, want ErrClosed", err) + } + if err := s.Checkpoint(); !errors.Is(err, ErrClosed) { + t.Errorf("Checkpoint after Close gave %v, want ErrClosed", err) + } + if err := s.Close(); err != nil { + t.Errorf("a second Close gave %v, want nil", err) + } +} + +func TestOpenCreatesAnEmptyFile(t *testing.T) { + path := tmpPath(t) + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + s, err := Open(path) + if err != nil { + t.Fatalf("Open of a zero-byte file should create: %v", err) + } + defer s.Close() + if s.Meta().NextPage != 1 { + t.Errorf("next page is %d, want 1", s.Meta().NextPage) + } +} + +func TestCreateRefusesAnExistingFile(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + if err := s.Close(); err != nil { + t.Fatal(err) + } + if _, err := Create(path); err == nil { + t.Error("Create on a file that already holds a database should fail") + } +} + +func freeListPage(t *testing.T, s *Store) page.Page { + t.Helper() + if len(s.listPage) == 0 { + t.Fatal("expected at least one free-list page") + } + p, err := s.pf.Read(s.listPage[0]) + if err != nil { + t.Fatal(err) + } + return p +} + +func rewrite(t *testing.T, s *Store, p page.Page) { + t.Helper() + if err := s.pf.Write(p); err != nil { + t.Fatal(err) + } +} + +func TestVerifyCatchesCorruption(t *testing.T) { + cases := []struct { + name string + corrupt func(*testing.T, *Store) + }{ + {"the meta page on disk no longer matches the last checkpoint", func(t *testing.T, s *Store) { + raw, err := s.pf.Read(MetaPage) + if err != nil { + t.Fatal(err) + } + binary.BigEndian.PutUint32(raw[offRoot:], 900) + rewrite(t, s, raw) + }}, + {"a free-list page has the wrong kind", func(t *testing.T, s *Store) { + p := freeListPage(t, s) + p.SetKind(page.KindHeap) + rewrite(t, s, p) + }}, + {"the free-list chain loops", func(t *testing.T, s *Store) { + p := freeListPage(t, s) + binary.BigEndian.PutUint32(p[offFreeListNext:], uint32(p.ID())) + rewrite(t, s, p) + }}, + {"the free-list chain leaves the file", func(t *testing.T, s *Store) { + p := freeListPage(t, s) + binary.BigEndian.PutUint32(p[offFreeListNext:], 4000) + rewrite(t, s, p) + }}, + {"a free-list page claims more entries than fit", func(t *testing.T, s *Store) { + p := freeListPage(t, s) + p.SetItemCount(FreeListCapacity + 1) + rewrite(t, s, p) + }}, + {"the free list names one page twice", func(t *testing.T, s *Store) { + p := freeListPage(t, s) + copy(p[FreeListHeader+4:FreeListHeader+8], p[FreeListHeader:FreeListHeader+4]) + rewrite(t, s, p) + }}, + {"the free list names a page never handed out", func(t *testing.T, s *Store) { + p := freeListPage(t, s) + binary.BigEndian.PutUint32(p[FreeListHeader:], 9999) + rewrite(t, s, p) + }}, + {"the free list names the meta page", func(t *testing.T, s *Store) { + p := freeListPage(t, s) + binary.BigEndian.PutUint32(p[FreeListHeader:], uint32(MetaPage)) + rewrite(t, s, p) + }}, + {"the free list names the durable root", func(t *testing.T, s *Store) { + p := freeListPage(t, s) + binary.BigEndian.PutUint32(p[FreeListHeader:], uint32(s.durable.Root)) + rewrite(t, s, p) + }}, + {"the free list names its own page", func(t *testing.T, s *Store) { + p := freeListPage(t, s) + binary.BigEndian.PutUint32(p[FreeListHeader:], uint32(p.ID())) + rewrite(t, s, p) + }}, + {"the live root was never handed out", func(t *testing.T, s *Store) { s.root = 900 }}, + {"the live root is free", func(t *testing.T, s *Store) { s.root = s.free[0] }}, + {"more pages handed out than the file holds", func(t *testing.T, s *Store) { s.nextPage = 9999 }}, + {"the next page went backwards", func(t *testing.T, s *Store) { s.nextPage = 1 }}, + {"a page is both free and pending", func(t *testing.T, s *Store) { s.pending = append(s.pending, s.free[0]) }}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Discard() + var ids []core.PageID + for i := 0; i < 6; i++ { + ids = append(ids, mustAlloc(t, s).ID()) + } + if err := s.SetRoot(ids[0]); err != nil { + t.Fatal(err) + } + for _, id := range []core.PageID{ids[2], ids[3], ids[4]} { + if err := s.Free(id); err != nil { + t.Fatal(err) + } + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + if err := s.Verify(); err != nil { + t.Fatalf("the clean store did not verify: %v", err) + } + c.corrupt(t, s) + if err := s.Verify(); err == nil { + t.Errorf("Verify accepted a store where %s", c.name) + } + }) + } +} + +func TestOpenRejectsACorruptFreeList(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + a := mustAlloc(t, s) + mustAlloc(t, s) + if err := s.Free(a.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + p := freeListPage(t, s) + binary.BigEndian.PutUint32(p[offFreeListNext:], uint32(p.ID())) + rewrite(t, s, p) + if err := s.Discard(); err != nil { + t.Fatal(err) + } + if _, err := Open(path); !errors.Is(err, ErrCorrupt) { + t.Errorf("Open of a file whose free list loops gave %v, want ErrCorrupt", err) + } +} + +func TestCorruptedMetaOnDiskIsRejectedOnOpen(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + if err := s.Close(); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + raw[4] = byte(page.KindHeap) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Open(path); !errors.Is(err, ErrCorrupt) { + t.Errorf("Open of a file whose page 0 is not a meta page gave %v, want ErrCorrupt", err) + } +} + +func TestDescribeMetaDecodesWhatIsOnDisk(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + defer s.Close() + root := mustAlloc(t, s) + if err := s.SetRoot(root.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + + out, err := s.DescribeMeta() + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "53 43 55 54 45 44 42 00", "format version = 1", "page size = 4096", "root = page 1", + } { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Errorf("DescribeMeta is missing %q\n%s", want, out) + } + } +} + +func TestPagesWrittenBeforeACheckpointAreStillReadable(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + + p := mustAlloc(t, s) + copy(p[page.HeaderSize:], []byte("payload")) + if err := s.Write(p); err != nil { + t.Fatal(err) + } + if err := s.SetRoot(p.ID()); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + + back := mustOpen(t, path) + defer back.Close() + got, err := back.Read(back.Root()) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(got[page.HeaderSize:], []byte("payload")) { + t.Errorf("page contents did not survive the restart: % 02X", got[page.HeaderSize:page.HeaderSize+8]) + } +} + +func TestPageZeroIsASafeSentinel(t *testing.T) { + if NoPage != MetaPage { + t.Fatalf("NoPage is %d and MetaPage is %d; the format relies on them being the same page", NoPage, MetaPage) + } + + s := mustCreate(t, tmpPath(t)) + defer s.Close() + + if err := s.SetRoot(NoPage); err != nil { + t.Fatalf("SetRoot(NoPage) should mean no root: %v", err) + } + if err := s.Verify(); err != nil { + t.Errorf("a store with no root should verify: %v", err) + } + if err := s.Free(MetaPage); !errors.Is(err, ErrFreeMeta) { + t.Errorf("page 0 must never become free, or 0 could not mean end-of-list: %v", err) + } + + p := mustAlloc(t, s) + mustAlloc(t, s) + if err := s.Free(p.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + lp := freeListPage(t, s) + if next := binary.BigEndian.Uint32(lp[offFreeListNext:]); next != uint32(NoPage) { + t.Errorf("the last free-list page points at %d, want %d for end of chain", next, NoPage) + } +} diff --git a/internal/pagestore/recovery_test.go b/internal/pagestore/recovery_test.go new file mode 100644 index 0000000..1a719f4 --- /dev/null +++ b/internal/pagestore/recovery_test.go @@ -0,0 +1,844 @@ +package pagestore + +import ( + "bytes" + "encoding/binary" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/scute-db/scutedb/internal/core" + "github.com/scute-db/scutedb/internal/fileio" + "github.com/scute-db/scutedb/internal/page" +) + +func mustAllocOn(t *testing.T, s *Store, kind page.Kind) page.Page { + t.Helper() + p, err := s.Allocate(kind) + if err != nil { + t.Fatalf("Allocate: %v", err) + } + return p +} + +func mustReopen(t *testing.T, mf *memFile) *Store { + t.Helper() + s, _, err := openFile(mf) + if err != nil { + t.Fatalf("reopening: %v", err) + } + return s +} + +func TestReopenMakesTheMetaItReadDurableBeforeReusingPages(t *testing.T) { + mf := newMemFile() + s, err := create(page.NewFile(mf)) + if err != nil { + t.Fatal(err) + } + old := mustAllocOn(t, s, page.KindHeap) + copy(old[page.HeaderSize:], "committed") + if err := s.Write(old); err != nil { + t.Fatal(err) + } + if err := s.SetRoot(old.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + + replacement := mustAllocOn(t, s, page.KindHeap) + copy(replacement[page.HeaderSize:], "replacement") + if err := s.Write(replacement); err != nil { + t.Fatal(err) + } + if err := s.SetRoot(replacement.ID()); err != nil { + t.Fatal(err) + } + if err := s.Free(old.ID()); err != nil { + t.Fatal(err) + } + + mf.failAtSync = mf.syncs + 3 + if err := s.Checkpoint(); !errors.Is(err, ErrPoisoned) { + t.Fatalf("a checkpoint whose final fsync failed gave %v, want ErrPoisoned", err) + } + s.Close() + mf.restart() + + back := mustReopen(t, mf) + seen := back.Durable().Checkpoint + for len(back.FreePages()) > 0 { + p := mustAllocOn(t, back, page.KindHeap) + copy(p[page.HeaderSize:], "overwrite") + if err := back.Write(p); err != nil { + t.Fatal(err) + } + } + back.Discard() + mf.keepNothing() + + final := mustReopen(t, mf) + defer final.Close() + if final.Durable().Checkpoint != seen { + t.Fatalf("the reopened store believed generation %d, but a power loss later recovered %d; "+ + "it handed out pages on the strength of a meta page that was never durable", + seen, final.Durable().Checkpoint) + } + if err := final.Verify(); err != nil { + t.Fatalf("Verify after the power loss: %v", err) + } + got, err := final.Read(final.Root()) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(got[page.HeaderSize:], []byte("replacement")) { + t.Fatalf("the recovered root holds %q", got[page.HeaderSize:page.HeaderSize+11]) + } +} + +func TestReopenAfterAFailedFinalSyncKeepsTheFreeListOpenable(t *testing.T) { + mf := newMemFile() + s, err := create(page.NewFile(mf)) + if err != nil { + t.Fatal(err) + } + a := mustAllocOn(t, s, page.KindHeap) + mustAllocOn(t, s, page.KindHeap) + if err := s.Free(a.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + + mustAllocOn(t, s, page.KindHeap) + mf.failAtSync = mf.syncs + 3 + if err := s.Checkpoint(); !errors.Is(err, ErrPoisoned) { + t.Fatalf("want ErrPoisoned, got %v", err) + } + s.Close() + mf.restart() + + back := mustReopen(t, mf) + for len(back.FreePages()) > 0 { + mustAllocOn(t, back, page.KindBTreeLeaf) + } + back.Discard() + mf.keepNothing() + + final, _, err := openFile(mf) + if err != nil { + t.Fatalf("after a power loss the file no longer opens: %v", err) + } + defer final.Close() + if err := final.Verify(); err != nil { + t.Fatal(err) + } +} + +func TestAFailedSyncPoisonsTheStore(t *testing.T) { + mf := newMemFile() + s, err := create(page.NewFile(mf)) + if err != nil { + t.Fatal(err) + } + durable := s.Durable() + + p := mustAllocOn(t, s, page.KindHeap) + copy(p[page.HeaderSize:], "never durable") + if err := s.Write(p); err != nil { + t.Fatal(err) + } + if err := s.SetRoot(p.ID()); err != nil { + t.Fatal(err) + } + + mf.failAtSync = mf.syncs + 1 + if err := s.Sync(); !errors.Is(err, ErrPoisoned) { + t.Fatalf("a failed Sync gave %v, want ErrPoisoned", err) + } + mf.failAtSync = 0 + if err := s.Checkpoint(); !errors.Is(err, ErrPoisoned) { + t.Fatalf("Checkpoint after a failed Sync gave %v; the page it would commit never reached the disk", err) + } + if err := s.Close(); !errors.Is(err, ErrPoisoned) { + t.Errorf("Close after a failed Sync gave %v, want ErrPoisoned", err) + } + + mf.keepNothing() + back := mustReopen(t, mf) + defer back.Close() + if back.Durable() != durable { + t.Errorf("recovered %+v, want the last good checkpoint %+v", back.Durable(), durable) + } +} + +func TestACrashDuringCreateLeavesAFileThatStillOpens(t *testing.T) { + keeps := map[string]func(int) bool{ + "nothing survives": func(int) bool { return false }, + "everything survives": func(int) bool { return true }, + "odd writes survive": func(i int) bool { return i%2 == 1 }, + } + for failAt := 1; failAt <= 2; failAt++ { + for name, keep := range keeps { + for _, powerCut := range []bool{false, true} { + mf := newMemFile() + mf.failAtSync = failAt + mf.failKeep = keep + if _, err := create(page.NewFile(mf)); !errors.Is(err, ErrPoisoned) { + t.Fatalf("sync %d, %s: create gave %v, want ErrPoisoned", failAt, name, err) + } + if powerCut { + mf.powerLoss(keep) + } else { + mf.restart() + } + s, _, err := openFile(mf) + if err != nil { + t.Fatalf("sync %d failed during create, %s, power cut %v: the file cannot be opened: %v", + failAt, name, powerCut, err) + } + if err := s.Verify(); err != nil { + t.Fatalf("sync %d, %s, power cut %v: %v", failAt, name, powerCut, err) + } + s.Close() + } + } + } +} + +func TestAnAllZeroFileFromAnInterruptedCreateOpensAndCreates(t *testing.T) { + for _, size := range []int{1, 100, page.Size, ChunkPages * page.Size} { + path := tmpPath(t) + if err := os.WriteFile(path, make([]byte, size), 0o644); err != nil { + t.Fatal(err) + } + s, err := Open(path) + if err != nil { + t.Fatalf("Open of %d zero bytes, what an interrupted create leaves: %v", size, err) + } + s.Close() + + if err := os.WriteFile(path, make([]byte, size), 0o644); err != nil { + t.Fatal(err) + } + c, err := Create(path) + if err != nil { + t.Fatalf("Create over %d zero bytes: %v", size, err) + } + c.Close() + } +} + +func TestAZeroFileLargerThanOneChunkIsStillForeign(t *testing.T) { + path := tmpPath(t) + if err := os.WriteFile(path, make([]byte, ChunkPages*page.Size+1), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Open(path); !errors.Is(err, ErrNotScuteDB) { + t.Errorf("a create never grows past one chunk before its meta is durable, so a larger zero file "+ + "is not ours; Open gave %v, want ErrNotScuteDB", err) + } +} + +func TestOpenRejectsARootThatIsAFreeListPage(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + a := mustAlloc(t, s) + mustAlloc(t, s) + if err := s.Free(a.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + bad := s.Durable() + bad.Root = s.ListPages()[0] + if err := s.pf.Write(encodeMeta(bad)); err != nil { + t.Fatal(err) + } + s.Discard() + _, err := Open(path) + if !errors.Is(err, ErrCorrupt) { + t.Fatalf("Open of a file whose root is its own free-list page gave %v, want ErrCorrupt", err) + } + if !strings.Contains(err.Error(), "also a free-list page") { + t.Errorf("the durable free list should be rejected on its own terms, before any live state is built; got %v", err) + } +} + +func TestVerifyComparesLiveStateWithTheDurableFreeList(t *testing.T) { + build := func(t *testing.T) (*Store, []core.PageID) { + s := mustCreate(t, tmpPath(t)) + var ids []core.PageID + for i := 0; i < 6; i++ { + ids = append(ids, mustAlloc(t, s).ID()) + } + if err := s.SetRoot(ids[0]); err != nil { + t.Fatal(err) + } + for _, id := range ids[3:] { + if err := s.Free(id); err != nil { + t.Fatal(err) + } + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + if err := s.Verify(); err != nil { + t.Fatalf("clean store: %v", err) + } + return s, ids + } + + cases := []struct { + name string + corrupt func(*Store, []core.PageID) + }{ + {"a reusable page was in use at the last checkpoint", func(s *Store, ids []core.PageID) { + s.free = append(s.free, ids[1]) + s.status[ids[1]] = stFree + }}, + {"one list names a page twice while another page is on no list", func(s *Store, ids []core.PageID) { + s.free = append(s.free[:0:0], s.free[0], s.free[0]) + s.status = map[core.PageID]pageState{s.free[0]: stFree, ids[1]: stFree, s.listPage[0]: stFreeList} + }}, + {"one list names a page twice, which would hand it out twice", func(s *Store, ids []core.PageID) { + s.free = append(s.free, s.free[0]) + }}, + {"a page is marked pending but is on no list", func(s *Store, ids []core.PageID) { + s.status[ids[1]] = stPending + }}, + {"the live free-list pages differ from the durable chain", func(s *Store, ids []core.PageID) { + s.status[s.listPage[0]] = stFree + s.free = append(s.free, s.listPage[0]) + s.listPage = nil + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s, ids := build(t) + defer s.Discard() + c.corrupt(s, ids) + if err := s.Verify(); err == nil { + t.Errorf("Verify accepted a store where %s", c.name) + } + }) + } +} + +func TestPageIDsCannotWrapIntoTheMetaPage(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Discard() + s.nextPage = core.PageID(MaxPages) + s.filePages = MaxPages + if _, err := s.Allocate(page.KindHeap); !errors.Is(err, ErrFull) { + t.Fatalf("allocating past the last page id gave %v, want ErrFull", err) + } + if s.nextPage != core.PageID(MaxPages) { + t.Fatalf("a refused allocation moved next page to %d", s.nextPage) + } + + if got := growTo(10); got != 10+ChunkPages { + t.Errorf("growTo(10) = %d, want %d", got, 10+ChunkPages) + } + if got := growTo(MaxPages - 3); got != MaxPages { + t.Errorf("growTo near the limit = %d, want it capped at %d rather than wrapping", got, MaxPages) + } + if got := growTo(MaxPages); got != MaxPages { + t.Errorf("growTo at the limit = %d, want %d", got, MaxPages) + } +} + +func TestAFreeListPageMustSayWhichPageItIs(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + a := mustAlloc(t, s) + mustAlloc(t, s) + if err := s.Free(a.ID()); err != nil { + t.Fatal(err) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + p := freeListPage(t, s) + at := page.Offset(s.ListPages()[0]) + s.Discard() + binary.BigEndian.PutUint32(p[0:4], 999) + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteAt(p, at); err != nil { + t.Fatal(err) + } + f.Close() + if _, err := Open(path); !errors.Is(err, ErrCorrupt) { + t.Errorf("a free-list page whose header names another page was accepted: %v; "+ + "a misdirected write would go unnoticed", err) + } +} + +func TestAMetaPageThatNamesAnotherPageIsRejected(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + if err := s.Close(); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + binary.BigEndian.PutUint32(raw[0:4], 5) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Open(path); !errors.Is(err, ErrCorrupt) { + t.Errorf("Open of a page 0 that says it is page 5 gave %v, want ErrCorrupt", err) + } +} + +func TestGrowthDoesNotTrustTheFileSizeTheCacheReports(t *testing.T) { + mf := newMemFile() + s, err := create(page.NewFile(mf)) + if err != nil { + t.Fatal(err) + } + for int(s.Meta().NextPage) < ChunkPages { + mustAllocOn(t, s, page.KindHeap) + } + if err := s.Checkpoint(); err != nil { + t.Fatal(err) + } + + zeroFill := len(mf.log) + edge := mustAllocOn(t, s, page.KindHeap) + if edge.ID() != core.PageID(ChunkPages) || s.Meta().FilePages != 2*ChunkPages { + t.Fatalf("expected page %d to grow the file to %d pages, got page %d and %d pages", + ChunkPages, 2*ChunkPages, edge.ID(), s.Meta().FilePages) + } + mf.failAtSync = mf.syncs + 1 + mf.failKeep = func(i int) bool { return i != zeroFill } + if err := s.Checkpoint(); !errors.Is(err, ErrPoisoned) { + t.Fatalf("want ErrPoisoned, got %v", err) + } + s.Close() + mf.restart() + + back := mustReopen(t, mf) + if want := uint32(ChunkPages); back.Meta().FilePages != want { + t.Fatalf("reopened with %d pages, want the checkpointed %d", back.Meta().FilePages, want) + } + mustAllocOn(t, back, page.KindHeap) + if err := back.Checkpoint(); err != nil { + t.Fatal(err) + } + back.Discard() + mf.keepNothing() + + final, _, err := openFile(mf) + if err != nil { + t.Fatalf("the page cache said the file was already big enough, so the growth was never rewritten: %v", err) + } + defer final.Close() + if err := final.Verify(); err != nil { + t.Fatal(err) + } +} + +func TestOpenAlwaysMakesTheDirectoryEntryDurable(t *testing.T) { + dir := t.TempDir() + path := dir + "/index.db" + + f, err := fileio.Open(path) + if err != nil { + t.Fatal(err) + } + s, err := create(page.NewFile(f)) + if err != nil { + t.Fatal(err) + } + s.Discard() + + if err := os.Chmod(dir, 0o300); err != nil { + t.Fatal(err) + } + defer os.Chmod(dir, 0o700) + if err := fileio.SyncDir(dir); err == nil { + t.Skip("this environment can fsync a directory it cannot read, so the detector does not work here") + } + + if s, err := Open(path); err == nil { + s.Close() + t.Fatal("Open succeeded without fsyncing the directory; a file left by an interrupted Create " + + "may have a name that exists only in the cache, and a power loss would delete it") + } else if !errors.Is(err, fs.ErrPermission) { + t.Fatalf("Open(path) failed, but not because the directory could not be fsynced: %v", err) + } + + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + back, err := Open(path) + if err != nil { + t.Fatalf("once the directory can be fsynced, Open should recover the file: %v", err) + } + defer back.Close() + if err := back.Verify(); err != nil { + t.Fatal(err) + } +} + +func TestRunningOutOfPageIDsInACheckpointDoesNotPoison(t *testing.T) { + s := mustCreate(t, tmpPath(t)) + defer s.Discard() + a := mustAlloc(t, s) + mustAlloc(t, s) + if err := s.Free(a.ID()); err != nil { + t.Fatal(err) + } + + realNext, realFile := s.nextPage, s.filePages + s.nextPage, s.filePages = core.PageID(MaxPages), MaxPages + err := s.Checkpoint() + if !errors.Is(err, ErrFull) { + t.Fatalf("a checkpoint that cannot get a free-list page gave %v, want ErrFull", err) + } + if errors.Is(err, ErrPoisoned) { + t.Fatalf("ErrFull happens before anything durable is touched, so it must not poison the store: %v", err) + } + + s.nextPage, s.filePages = realNext, realFile + if err := s.Checkpoint(); err != nil { + t.Fatalf("after a refused checkpoint the store should still work: %v", err) + } + if err := s.Verify(); err != nil { + t.Fatal(err) + } +} + +func TestAPoisonedErrorKeepsItsCause(t *testing.T) { + mf := newMemFile() + s, err := create(page.NewFile(mf)) + if err != nil { + t.Fatal(err) + } + mustAllocOn(t, s, page.KindHeap) + mf.failAtSync = mf.syncs + 1 + err = s.Checkpoint() + if !errors.Is(err, ErrPoisoned) || !errors.Is(err, errSyncFailed) { + t.Fatalf("got %v; want both ErrPoisoned and the underlying fsync error in the chain", err) + } + if _, err := s.Allocate(page.KindHeap); !errors.Is(err, errSyncFailed) { + t.Errorf("later calls should still name the original cause, got %v", err) + } +} + +func TestANewerFormatIsCalledNewerNotForeign(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + if err := s.Close(); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + magic := append([]byte(nil), raw[16:24]...) + binary.BigEndian.PutUint32(raw[offVersion:], Version+1) + raw[4] = 0x7F + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + if !bytes.Equal(raw[16:24], magic) { + t.Fatal("changing the version changed the magic; the magic must never carry the version") + } + if _, err := Open(path); !errors.Is(err, ErrBadVersion) { + t.Errorf("a version-%d file whose header layout also changed gave %v; want ErrBadVersion, "+ + "because identity and version must be checked before anything a new version may change", Version+1, err) + } +} + +func interruptedCreate(t *testing.T, path string) { + t.Helper() + f, err := fileio.Open(path) + if err != nil { + t.Fatal(err) + } + s, err := create(page.NewFile(f)) + if err != nil { + t.Fatal(err) + } + s.Discard() +} + +func unreadable(t *testing.T, dir string) { + t.Helper() + if err := os.Chmod(dir, 0o300); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(dir, 0o700) }) + if err := fileio.SyncDir(dir); err == nil { + t.Skip("this environment can fsync a directory it cannot read, so the detector does not work here") + } +} + +func TestOpenThroughASymlinkFsyncsTheDirectoryHoldingTheFile(t *testing.T) { + linkDir, realDir := t.TempDir(), t.TempDir() + realPath := filepath.Join(realDir, "real.db") + linkPath := filepath.Join(linkDir, "index.db") + interruptedCreate(t, realPath) + if err := os.Symlink(realPath, linkPath); err != nil { + t.Fatal(err) + } + unreadable(t, realDir) + + if s, err := Open(linkPath); err == nil { + s.Close() + t.Fatalf("Open through a symlink succeeded without fsyncing %s, which holds the file's real entry", realDir) + } else if !errors.Is(err, fs.ErrPermission) { + t.Fatalf("Open(linkPath) failed, but not because the directory could not be fsynced: %v", err) + } +} + +func TestCreateThroughADanglingSymlinkFsyncsTheDirectoryHoldingTheFile(t *testing.T) { + linkDir, realDir := t.TempDir(), t.TempDir() + realPath := filepath.Join(realDir, "real.db") + linkPath := filepath.Join(linkDir, "index.db") + if err := os.Symlink(realPath, linkPath); err != nil { + t.Fatal(err) + } + unreadable(t, realDir) + + if s, err := Create(linkPath); err == nil { + s.Close() + t.Fatalf("Create through a dangling symlink made %s without fsyncing its directory", realPath) + } else if !errors.Is(err, fs.ErrPermission) { + t.Fatalf("Create(linkPath) failed, but not because the directory could not be fsynced: %v", err) + } +} + +func TestADotDotAfterASymlinkedDirectoryFsyncsTheRealParent(t *testing.T) { + base := t.TempDir() + realParent := filepath.Join(base, "realparent") + linkDir := filepath.Join(base, "linkdir") + for _, d := range []string{filepath.Join(realParent, "target"), linkDir} { + if err := os.MkdirAll(d, 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(filepath.Join(realParent, "target"), filepath.Join(linkDir, "link")); err != nil { + t.Fatal(err) + } + path := filepath.Join(linkDir, "link") + "/../index.db" + interruptedCreate(t, filepath.Join(realParent, "index.db")) + unreadable(t, realParent) + + if s, err := Open(path); err == nil { + s.Close() + t.Fatalf("Open(%s) succeeded; the kernel resolves it into %s, which was never fsynced", path, realParent) + } else if !errors.Is(err, fs.ErrPermission) { + t.Fatalf("Open(path) failed, but not because the directory could not be fsynced: %v", err) + } +} + +func TestSymlinkedPathsStillOpenWhenEverythingIsFine(t *testing.T) { + linkDir, realDir := t.TempDir(), t.TempDir() + realPath := filepath.Join(realDir, "real.db") + linkPath := filepath.Join(linkDir, "index.db") + if err := os.Symlink(realPath, linkPath); err != nil { + t.Fatal(err) + } + s, err := Create(linkPath) + if err != nil { + t.Fatalf("Create through a dangling symlink: %v", err) + } + p := mustAlloc(t, s) + if err := s.SetRoot(p.ID()); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + back, err := Open(linkPath) + if err != nil { + t.Fatalf("Open through the symlink: %v", err) + } + defer back.Close() + if back.Root() != p.ID() { + t.Errorf("root is %d through the symlink, want %d", back.Root(), p.ID()) + } +} + +func TestAMissingDatabaseIsAnErrorNotAnEmptyDatabase(t *testing.T) { + path := tmpPath(t) + s, err := Open(path) + if err == nil { + s.Close() + t.Fatal("Open of a path that does not exist returned a store; a lost directory entry would " + + "then look like an empty database instead of a missing one") + } + if !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Open of a missing path gave %v, want fs.ErrNotExist", err) + } + if _, err := os.Lstat(path); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Open created the file it was asked to open: %v", err) + } +} + +func TestATruncatedStoreIsCorruptNotForeign(t *testing.T) { + path := tmpPath(t) + s := mustCreate(t, path) + if err := s.Close(); err != nil { + t.Fatal(err) + } + if err := os.Truncate(path, 100); err != nil { + t.Fatal(err) + } + if _, err := Open(path); !errors.Is(err, ErrCorrupt) { + t.Errorf("a store cut down to 100 bytes gave %v; it is recognisably ours, so want ErrCorrupt", err) + } +} + +func TestCreateSaysWhatItFoundInTheWay(t *testing.T) { + ours := tmpPath(t) + s := mustCreate(t, ours) + if err := s.Close(); err != nil { + t.Fatal(err) + } + if _, err := Create(ours); err == nil || !strings.Contains(err.Error(), "use Open") { + t.Errorf("Create over an existing store gave %v; it should point the caller at Open", err) + } + + foreign := tmpPath(t) + if err := os.WriteFile(foreign, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Create(foreign); err == nil || !strings.Contains(err.Error(), "not a scutedb store") { + t.Errorf("Create over a foreign file gave %v; it should not suggest Open", err) + } +} + +func TestAResolvedPathLongerThanPathMaxStillOpens(t *testing.T) { + long := func(c byte) string { return strings.Repeat(string(c), 250) } + shallow := filepath.Join(t.TempDir(), long('a'), long('b'), long('c')) + if err := os.MkdirAll(shallow, 0o700); err != nil { + t.Fatal(err) + } + home, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(shallow); err != nil { + t.Fatal(err) + } + err = os.MkdirAll(filepath.Join(long('d'), long('e')), 0o700) + if err == nil { + err = os.Symlink(filepath.Join(long('d'), long('e')), "S") + } + if cerr := os.Chdir(home); cerr != nil { + t.Fatal(cerr) + } + if err != nil { + t.Fatal(err) + } + path := filepath.Join(shallow, "S", "index.db") + if len(path) >= 1024 { + t.Skipf("the temp directory is too deep for this layout: %d bytes", len(path)) + } + + s, err := Create(path) + if err != nil { + t.Fatalf("Create of a %d-byte path the kernel can reach: %v", len(path), err) + } + p := mustAlloc(t, s) + if err := s.SetRoot(p.ID()); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + back, err := Open(path) + if err != nil { + t.Fatalf("a valid store whose resolved path is longer than PATH_MAX cannot be reopened: %v", err) + } + defer back.Close() + if back.Root() != p.ID() { + t.Errorf("root is %d, want %d", back.Root(), p.ID()) + } +} + +func TestADotDotThatLeadsWhereTheTextDoesNotStillOpens(t *testing.T) { + base := t.TempDir() + for _, d := range []string{"deep/dir", "deep/c", "linkdir"} { + if err := os.MkdirAll(filepath.Join(base, d), 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(filepath.Join(base, "deep", "dir"), filepath.Join(base, "linkdir", "link")); err != nil { + t.Fatal(err) + } + path := filepath.Join(base, "linkdir", "link") + "/../c/index.db" + + s, err := Create(path) + if err != nil { + t.Fatalf("Create(%s): the kernel resolves it into deep/c, but got %v", path, err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + back, err := Open(path) + if err != nil { + t.Fatalf("Open(%s): %v", path, err) + } + back.Close() + if _, err := os.Stat(filepath.Join(base, "deep", "c", "index.db")); err != nil { + t.Errorf("the store is not where the kernel put it: %v", err) + } +} + +func TestAChainOfSymlinksFsyncsTheDirectoryAtTheEnd(t *testing.T) { + a, b, c := t.TempDir(), t.TempDir(), t.TempDir() + real := filepath.Join(c, "real.db") + interruptedCreate(t, real) + middle := filepath.Join(b, "middle") + first := filepath.Join(a, "first") + if err := os.Symlink(real, middle); err != nil { + t.Fatal(err) + } + if err := os.Symlink(middle, first); err != nil { + t.Fatal(err) + } + unreadable(t, c) + if s, err := Open(first); err == nil { + s.Close() + t.Fatalf("Open through two symlinks succeeded without fsyncing %s, which holds the file", c) + } else if !errors.Is(err, fs.ErrPermission) { + t.Fatalf("Open(first) failed, but not because the directory could not be fsynced: %v", err) + } +} + +func TestARelativeSymlinkResolvesFromItsOwnDirectory(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "sub") + if err := os.Mkdir(sub, 0o700); err != nil { + t.Fatal(err) + } + interruptedCreate(t, filepath.Join(sub, "real.db")) + link := filepath.Join(dir, "index.db") + if err := os.Symlink("sub/real.db", link); err != nil { + t.Fatal(err) + } + unreadable(t, sub) + if s, err := Open(link); err == nil { + s.Close() + t.Fatalf("Open through a relative symlink succeeded without fsyncing %s", sub) + } else if !errors.Is(err, fs.ErrPermission) { + t.Fatalf("Open(link) failed, but not because the directory could not be fsynced: %v", err) + } +}