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) + } +}