What a returned write guarantees

A crash-safe, append-only key-value store in C++20.

Fig. 1, one record, little-endian

The checksum covers the lengths too, so a corrupt length cannot mis-parse the rest of the log.

Durability costs 624×

ModeWhen put() returnsWorst-case lossops/sµs/op
NoneIn the page cacheAll not yet flushed by the OS284,6293.51
BatchedFlushed every 128 appendsFewer than 128 records34,38729.08
AlwaysFlushed to media (F_FULLFSYNC on macOS)Nothing acknowledged4562,195.27

Apple M2, APFS, 20,000 records, 128 B values. Batched does 75 writes per Always write; one run. Six-figure “durable” writes on a laptop measure the drive’s cache.

Recovery

  1. 01

    Replay from offset 0

    Each record is checked for structure and checksum.

  2. 02

    Stop at the first bad record

    What follows is a torn tail from a crash mid-append.

  3. 03

    Truncate to the last good offset

    A partial write surfaces as absence, never as garbage.

  4. 04

    Verify on every read

    The index says where a record is, not that its bytes are still good.

Killed mid-append

A process crash, not a power cut: SIGKILL leaves the page cache intact, so this checks ordering and recovery, not media durability. Torn tails are tested by truncating the log mid-record.

The writer prints an index after every put() that returns, then dies by SIGKILL. The verifier checks every acknowledged write.

One more recovered than acknowledged is correct: it landed before the acknowledgement printed.

$ ./tools/crashtest.sh 5 build
crash test: 5 rounds, SIGKILL mid-append

round 1: PASS  acked=248 present=248 missing=0 wrong=0 recovered=249 truncated=0
round 2: PASS  acked=344 present=344 missing=0 wrong=0 recovered=345 truncated=0
round 3: PASS  acked=339 present=339 missing=0 wrong=0 recovered=340 truncated=0
round 4: PASS  acked=261 present=261 missing=0 wrong=0 recovered=262 truncated=0
round 5: PASS  acked=211 present=211 missing=0 wrong=0 recovered=212 truncated=0

all rounds passed: no acknowledged write was lost

Scope

  • Single node, single writer, no locking.
  • The index lives in memory; recovery replays the whole log. A hint file is next.
  • Compaction is stop-the-world, then rename(2) and an fsync of the directory.
  • A bad record mid-log is treated as a torn tail: everything after it is dropped.

Build

40 assertions, including a torn tail and a flipped bit. ASan and UBSan run in CI on Linux.

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j4
./build/test_store
./tools/crashtest.sh 10 build
./build/bench 20000 128 128