Skip to content

fix(engine): power-loss crash tests and the five durability bugs they found - #98

Merged
venkat1701 merged 6 commits into
mainfrom
test/crash-durability
Oct 14, 2026
Merged

venkat1701 merged 6 commits into
mainfrom
test/crash-durability

Conversation

@venkat1701

Copy link
Copy Markdown
Collaborator

Closes #62

CrashRecoveryTest stops the engine at a CrashPoint, but every byte written before that point stays on disk. That is a process crash. This branch adds tests for a power failure, where only what was forced is certain to survive, and fixes the five bugs they found.

How the tests see the I/O

The engine now opens every file it writes or syncs through FileHandle.channel, and forces pages.dir and pages.ref (memory-mapped) through FileHandle.force. Both go through FileHandle.channels, a package-private static that defaults to FileChannel::open. Nothing else changes in production: the catalog, the reference journal and the WAL and feed directory syncs used to call FileChannel.open themselves, and CatalogStore and ReferenceJournal now share FileHandle.syncDirectory instead of keeping their own copies. The seam has to sit in FileHandle because the mapped files are written through memory, so a wrapped channel alone can't see when they reach the disk.

PowerLoss (test sources, io.hstore.engine.page) replaces channels for one directory. Per file it records every write, truncation and force, every force of a mapping and every force of a directory. It can cut the power before any of these events (the call throws PowerLoss.Cut; later I/O is no longer recorded), and restore rewrites the directory as the disk could hold it:

  • NOTHING: each file holds what its last force saw
  • ALL: every unsynced write kept, as after a process crash
  • HALF_LAST: the last unsynced write of each file cut in half (a WAL record cut short, half a page)
  • FLIP_LAST: one byte flipped in the middle of the last unsynced write, so its checksum fails
  • RANDOM: per file a prefix, a random subset or all of the unsynced writes, some applied only in part; mapped files mix 4 KiB blocks from the last force and from the moment of the crash; sometimes one unsynced byte flipped

A file created since its directory was last forced may vanish (always under NOTHING). A file written without going through FileHandle comes back empty, unless it was renamed from a forced .tmp file. PowerLossModelTest checks these rules on plain files.

The tests

PowerLossTest.powerCutsAtRandomEventsRecoverAPrefixOfTheCommits runs a seeded workload for SYNC/ASYNC x PAGE_IMAGES/PAGE_REFERENCES: small commits appending a node to one ordered edge, bulk loads of 300 or 5,000 members (spilled pages, pages written at commit), deletes of earlier bulk edges, aborted transactions with and without spilled pages, checkpoints and compactions. A dry run counts the I/O events, then it cuts the power at four random events, each with a random tear. After restoring it opens the database, checks it, commits once more and checkpoints, with a second power cut somewhere in that sequence (recovery included), restores and checks again. Each check requires:

  • the surviving commits are a prefix of the attempted ones
  • under SYNC every acknowledged commit survived; under ASYNC every commit covered by a completed checkpoint
  • no aborted work appears, and each bulk edge exists with its full size exactly when the prefix says so
  • the change feed holds one event per surviving commit, generations 1 to n (this check found bugs 4 and 5)
  • the second recovery found the same prefix as the first
  • TreeVerifier over every tree and verifyReferences, the same checks hstore check runs

One commit may have been in flight when the power went; it is allowed to be there or not.

Smaller tests pin the four cases from the issue: async commits after the last checkpoint are lost together; a Commit record cut in half or with a flipped byte loses only the unacknowledged commit; a PAGE_REFERENCES commit whose page was torn is discarded although its log records survived.

PowerLossTest took 14 to 28 s and PowerLossModelTest under 1 s in my last runs, on a laptop busy with other builds (load average 13 to 20). -Dhstore.powerloss.seeds=N runs more seeds. Locally I also ran seeds 100 to 111 and 200 to 211 (8 cuts each, the second set with longer workloads) before the feed check existed, and seeds 300 to 311 with it, all passing after the fixes.

Bugs found

  1. FORMAT could come back empty (StorageEngine.verifyFormat). It was written with Files.newOutputStream and never forced. After a power loss shortly after creating a database, FORMAT could be empty while the WAL held acknowledged commits. The next open read no format, took it for format 1 and refused the database with uses storage format 1. Now it is written to FORMAT.tmp, forced, renamed and the directory forced. Test: aDatabaseCreatedJustBeforeAPowerLossStillOpens.

  2. data/segments was never forced (SegmentFile.open, MappedLongs.open). A new segment file, or pages.dir and pages.ref themselves, could disappear after a power loss even though their contents were forced. With PAGE_REFERENCES, a commit acknowledged after pages.sync() and wal.sync() lost its pages, and recovery discarded it and everything after it. Now the directory is forced when one of these files is created: once per new segment (256 MiB by default) and twice per database. Test: pagesInASegmentCreatedSinceTheLastCheckpointSurvive (fails for PAGE_REFERENCES; PAGE_IMAGES rewrites the pages from the log and passed already).

  3. pages.dir could keep next page number 0 (PageDirectory.open). A new directory's header is forced with only magic and format, and the next number is set to 1 after that, to be forced at the first checkpoint. A power cut in between left next number 0; allocate handed out 0 and every checkpoint after that failed with page number out of range: 0, so the database could no longer write pages. Number 0 is never used, so a stored 0 is now read as 1, which also repairs a directory already in that state. Test: aPageDirectoryWhoseFirstPageNumberWasNeverForcedStillHandsOutNumbers.

  4. Zeros in the feed read as frames (ChangeFeed.restore, FrameReader). A feed frame is [length][crc32c of payload][generation][payload]; the CRC doesn't cover the header. Where an unsynced frame never reached the disk but a later one did, the zeros read as frames of length 0, CRC 0 (the CRC of an empty payload) and generation 0. restore set lastGeneration back to 0, and recovery appended the events of every replayed commit again, so the feed listed acknowledged commits twice. Test: aZeroedFeedFrameBeforeASurvivingOneDoesNotRepeatEvents.

  5. A damaged feed generation hid real events (same code). A frame of generation 3 with one bad byte in its header came back as generation 5898243 and passed the CRC. Recovery skipped the Feed records of the replayed commits as already present, then truncateAfter(3) removed the bad frame, so the event of commit 3, which had been acknowledged, was gone from the feed. In the random suite the same thing dropped the events of two acknowledged commits. Test: aFeedFrameWhoseUnsyncedGenerationWasDamagedDoesNotHideCommittedEvents.

    For 4 and 5, FeedCodec already writes the generation first in the payload, which the CRC covers. A frame is now valid only if its payload is not empty and starts with the header's generation, and restore stops at a frame that isn't newer than the one before it. Covering the header with the CRC would be cleaner but changes the feed format, so I left that alone.

Each test failed before its fix and passes after it. None of the fixes changes a file format.

Not covered

  • Directories created by Files.createDirectories are assumed durable, and a deleted file is assumed to stay deleted.
  • The workload is single-threaded, so races between the committer and a checkpoint are not explored.
  • Files of the database module (PayloadStore, SemanticPlane) don't go through FileHandle and are not modelled.

Docs

docs/transactions/wal-and-recovery.md describes the power loss tests and the new FORMAT write, docs/storage/pages.md the directory syncs and the next-number rule, and docs/storage/change-feed.md the stricter frame check.

…power

Every file the engine writes or syncs is now opened through
FileHandle.channel, and memory-mapped files are forced through
FileHandle.force. Both go through FileHandle.channels, a package-private
static that defaults to FileChannel::open and only tests replace. The
catalog, the reference journal and the WAL and feed directory syncs used
FileChannel.open directly; CatalogStore and ReferenceJournal now share
FileHandle.syncDirectory instead of their own copies.

PowerLoss, a test helper, installs itself there for one directory. It
records every write, truncation and force per file, every force of a
mapping (pages.dir, pages.ref) and of a directory, can throw at a chosen
event, and then rewrites the directory as a power failure could leave
it: only forced bytes, all bytes, the last unsynced write cut in half or
with a flipped byte, or a random mix. Files created since their
directory was last forced may vanish.
verifyFormat wrote FORMAT with Files.newOutputStream and never forced
it or the directory. A power loss soon after a database was created
could leave FORMAT empty while the WAL already held acknowledged
commits. The next open then read no format property, took it for
format 1 and refused the database ("uses storage format 1").

FORMAT is now written to FORMAT.tmp, forced, renamed over FORMAT and the
directory forced, as the catalog files already are. It happens once per
database.
Nothing ever forced data/segments. A new segment file, and pages.dir and
pages.ref themselves, could vanish after a power loss even though their
contents had been forced, because the directory entry was never made
durable. With PAGE_REFERENCES a commit whose pages sat in such a segment
was acknowledged after pages.sync and wal.sync, but after the power loss
recovery found its pages missing and discarded it and every commit after
it.

SegmentFile.open now forces the directory when it creates the file, and
MappedLongs does the same when it creates pages.dir or pages.ref. That
is one directory sync per new segment (256 MiB by default) and two per
database.
MappedLongs forces the header of a new pages.dir with only its magic and
format. PageDirectory.open sets the next page number to 1 after that and
the first checkpoint forces it. If the power went out in between, the
directory came back with next number 0, which PageDirectory.allocate
handed out, and every checkpoint from then on failed in PageId.pack with
"page number out of range: 0". The database could no longer write a
page.

Page number 0 is never used, so open now reads a stored 0 as 1. That
also repairs a directory already left in this state.
…load

A feed frame is [length][crc32c of payload][generation][payload], so the
CRC does not cover the length or the generation. When power was lost
with unsynced feed frames, two things got past ChangeFeed.restore:

* A run of zeros, where a frame never reached the disk but a later one
  did, reads as frames of length 0, CRC 0 and generation 0 (the CRC of
  an empty payload is 0). restore set lastGeneration back to 0, so
  recovery re-appended the Feed records of every replayed commit after
  events that were already there, and the feed listed those commits
  twice.
* A damaged generation field passed the check. A frame of generation 3
  came back as generation 5898243, recovery skipped the Feed records of
  the replayed commits as already present, and truncateAfter(3) then
  dropped the bad frame, so the event of commit 3, which had been
  acknowledged, was gone for good.

FeedCodec always writes the generation first in the payload, which the
CRC does cover. A frame is now valid only if its payload is not empty
and starts with the header's generation, and restore stops at a frame
that is not newer than the one before it. The file format is unchanged.
PowerLossTest runs a seeded workload of small commits, bulk loads that
spill and write pages at commit, deletes, aborted transactions,
checkpoints and compactions, for both durability levels and both WAL
modes. It cuts the power at four random I/O events per configuration
with a random tear of the unsynced writes, recovers, cuts the power
again somewhere in recovery and the commit and checkpoint after it, and
recovers again. Every time the surviving commits must be a prefix of
the attempted ones, holding every acknowledged commit under sync and
every checkpointed one under async, with no aborted work, one feed event
per commit, the same prefix after both recoveries, and trees and
reference counts that pass TreeVerifier and verifyReferences.

Four smaller tests pin the cases from the issue: async commits after the
last checkpoint are lost together, a Commit record cut in half or with a
flipped byte loses only the unacknowledged commit, and a torn page
discards its commit although the log records survived.

-Dhstore.powerloss.seeds=N runs more seeds.
@venkat1701
venkat1701 merged commit 0abdf6b into main Oct 14, 2026
@venkat1701
venkat1701 deleted the test/crash-durability branch October 14, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Crash tests never lose unsynced writes or tear a page

1 participant