minidb build plan: eight steps¶
Time: ~50 hours over 4 weeks (Mon 2026-11-10 to Sun 2026-12-07), two steps a week.
Optional: start only after tcpkv v1.0.0 is tagged, public and pinned (README of this course).
Read first, for the whole project: 07_Databases_Internals: 02_storage_engines_pages_and_heap_files.md,
03_btree_indexes_in_depth.md, 04_wal_and_crash_recovery.md. 05_Memory_Debugging_RCA:
08_valgrind_and_sanitizers.md. 06_Linux_Toolchain_DevOps: 04_make_and_cmake_from_scratch.md,
05_testing_with_googletest_and_ctest.md. tcpkv steps 01 and 08 (the CMake presets, the CI
matrix and the sanitizer setup are reused unchanged). The specification is 00_spec.md;
read it fully before step 1.
How to use this plan¶
Same rules as tcpkv (README of this course, section 5): read the step, type the code yourself, build, run the tests, commit with the given message, and write the interview paragraph in your own words before moving on.
State of the code in this plan. The five key classes, Pager, PageCache, BPlusTree,
Wal and Recovery, are given in full, together with the small headers they need
(common.hpp, crc32, page.hpp) and the Db class that wires them together. This exact
code was compiled with -std=c++17 -Wall -Wextra -Wpedantic -Wshadow -Wnon-virtual-dtor
-Woverloaded-virtual -Werror on GCC 13.3 in WSL Ubuntu 24.04, and it passes all 21 tests
shown here, the SIGKILL crash test included, both at -O2 and under AddressSanitizer plus
UndefinedBehaviorSanitizer. src/file.cpp, the CLI and the benchmark are outlined: you have
written their tcpkv equivalents already.
The first draft of this code had two recovery bugs, and its own tests caught both. They are kept in the text as stories (steps 6 and 7), because "my test caught my bug, here is the test" is the most convincing thing you can say in an interview about testing storage code.
Repository. github.com/khanshaki-r/minidb, laid out as in 00_spec.md section 10.
Copy CMakePresets.json, .clang-format, .gitignore, LICENSE and the CI workflow from
tcpkv, and rename TCPKV_SANITIZER to MINIDB_SANITIZER in the presets. Work under
~/dev/minidb in WSL, not under /mnt/d: fdatasync on the Windows file system through WSL
is both slow and not a fair test of Linux durability.
| step | title | week | hours |
|---|---|---|---|
| 1 | Skeleton, File, CRC-32, the page format |
6 (Nov 10–16) | 5 |
| 2 | Pager: pages on disk, checksums, atomic creation |
6 | 5 |
| 3 | PageCache: pinning, LRU, dirty pages, no-steal |
7 (Nov 17–23) | 7 |
| 4 | BPlusTree I: node format, search, insert, splits, invariant checker |
7 | 8 |
| 5 | BPlusTree II: erase, range scan, model-based test |
8 (Nov 24–30) | 5 |
| 6 | Wal: records, LSNs, sync, replay, truncate, reset |
8 | 6 |
| 7 | Recovery and Db: the commit protocol and checkpoints |
9 (Dec 01–07) | 8 |
| 8 | The crash test, CLI, benchmark, CI, README, tag v0.1.0 | 9 | 6 |
Step 1. Skeleton, File, CRC-32, the page format¶
Time: ~5 hours Week: 6
Read first: 07_Databases_Internals/02_storage_engines_pages_and_heap_files.md (pages, why
fixed-size blocks). 06_Linux_Toolchain_DevOps/04_make_and_cmake_from_scratch.md.
02_Modern_CPP: 03_raii_the_core_idea.md, 11_const_constexpr_and_compile_time.md (the
constexpr CRC table). tcpkv step 02 (FileDescriptor, the same RAII idea).
Goal¶
A repository that builds and tests in CI, an RAII file class with positioned I/O, a checksum, and the definition of a page. Nothing is stored yet; everything later stands on this.
In plain words: before you can build a library you need the shelves (pages), a way to put a
shelf into a numbered slot of the warehouse and take it back out (pwrite/pread at
id * 4096), and a seal on every shelf that breaks if the contents change behind your back
(the CRC).
Design¶
- 4 KiB pages. It is the x86 memory page size, the usual ext4 block size, and the unit the kernel page cache works in. One page is one I/O. Most engines use 4, 8 or 16 KiB.
- A checksum on every page. Disks, controllers and filesystems occasionally return wrong bytes, and a crash can leave a page half old and half new (a torn write: the drive persists 512-byte or 4 KiB sectors, and a power cut can land between them). Without a checksum you would read garbage as data. With one you detect it, and the log repairs it (step 7).
- The page LSN in the header. The log sequence number of the log record that last carried this page. Recovery uses it to skip work. Defined now so the format never changes.
memcpy, never pointer casts, for fixed-size fields:*(uint32_t*)(p + 4)violates strict aliasing and may be misaligned, which is undefined behaviour that UBSan reports. A 4-bytememcpycompiles to onemovanyway.pread/pwrite, notlseekplusread: no shared file offset (no race if two threads ever share the descriptor), and one syscall instead of two.fdatasync, notfsync: it skips metadata that is not needed to read the data back (the modification time) but still flushes the file size. Cheaper, same durability here.
Files¶
CMakeLists.txt, CMakePresets.json (copied), .github/workflows/ci.yml (copied: keep the
build-and-test matrix; drop the tcpkv valgrind job), include/minidb/common.hpp,
include/minidb/crc32.hpp, src/crc32.cpp, include/minidb/file.hpp, src/file.cpp,
include/minidb/page.hpp, tests/test_util.hpp, tests/storage_test.cpp (the Crc32 test
only, for now).
Code¶
CMakeLists.txt, final version. In step 1 list only src/crc32.cpp and src/file.cpp in
the library and only storage in the test loop, and leave out the two executables; add each
piece in the step that introduces it.
cmake_minimum_required(VERSION 3.22)
project(minidb
VERSION 0.1.0
DESCRIPTION "Page-based key-value storage engine with a B+tree and a write-ahead log"
LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type" FORCE)
endif()
option(MINIDB_BUILD_TESTS "Build the GoogleTest suite" ON)
set(MINIDB_SANITIZER "" CACHE STRING "Sanitizer to enable: address | thread | undefined | (empty)")
find_package(Threads REQUIRED)
add_library(minidb_warnings INTERFACE)
target_compile_options(minidb_warnings INTERFACE
-Wall -Wextra -Wpedantic -Wshadow -Wnon-virtual-dtor -Woverloaded-virtual -Werror)
if(MINIDB_SANITIZER)
message(STATUS "minidb: building with -fsanitize=${MINIDB_SANITIZER}")
add_compile_options(-fsanitize=${MINIDB_SANITIZER} -fno-omit-frame-pointer -g)
add_link_options(-fsanitize=${MINIDB_SANITIZER})
endif()
add_library(minidb STATIC
src/crc32.cpp
src/file.cpp
src/pager.cpp
src/page_cache.cpp
src/wal.cpp
src/recovery.cpp
src/btree.cpp
src/db.cpp)
target_include_directories(minidb PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(minidb PUBLIC Threads::Threads PRIVATE minidb_warnings)
add_executable(minidb_cli apps/minidb_cli.cpp)
target_link_libraries(minidb_cli PRIVATE minidb minidb_warnings)
add_executable(minidb_bench tools/bench.cpp)
target_link_libraries(minidb_bench PRIVATE minidb minidb_warnings)
if(MINIDB_BUILD_TESTS)
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0
GIT_SHALLOW TRUE)
set(INSTALL_GTEST OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
enable_testing()
include(GoogleTest)
foreach(name IN ITEMS storage btree db crash)
add_executable(${name}_test tests/${name}_test.cpp)
target_include_directories(${name}_test PRIVATE tests)
target_link_libraries(${name}_test PRIVATE minidb GTest::gtest_main minidb_warnings)
# fsync-bound tests are slow, more so under sanitizers: generous timeouts.
gtest_discover_tests(${name}_test DISCOVERY_TIMEOUT 120 PROPERTIES TIMEOUT 900)
endforeach()
endif()
#pragma once
#include <cstddef>
#include <cstdint>
#include <stdexcept>
namespace minidb {
using PageId = std::uint32_t;
using Lsn = std::uint64_t; // log sequence number: byte position in the logical WAL stream
inline constexpr std::size_t kPageSize = 4096;
inline constexpr PageId kMetaPageId = 0;
inline constexpr PageId kNoPage = 0; // page 0 is the meta page, so 0 never names a tree node
// Chosen so that at least four maximum-size leaf cells fit in one page; that guarantees a
// byte-balanced split always produces two halves that fit (see btree.cpp).
inline constexpr std::size_t kMaxKeySize = 128;
inline constexpr std::size_t kMaxValueSize = 880;
class DbError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// The on-disk bytes are not what we wrote: bad checksum, bad magic, impossible field values.
class CorruptionError : public DbError {
public:
using DbError::DbError;
};
} // namespace minidb
include/minidb/crc32.hpp and src/crc32.cpp. The table is computed by a constexpr
function, so the compiler builds it and it lives in read-only data: no start-up cost and no
static-initialisation-order question.
#pragma once
#include <cstddef>
#include <cstdint>
namespace minidb {
// CRC-32 (IEEE 802.3, reflected polynomial 0xEDB88320), the one zlib and Ethernet use.
// Chainable: crc32(b, nb, crc32(a, na)) == crc32(a followed by b).
std::uint32_t crc32(const void* data, std::size_t len, std::uint32_t previous = 0) noexcept;
} // namespace minidb
#include "minidb/crc32.hpp"
#include <array>
namespace minidb {
namespace {
constexpr std::array<std::uint32_t, 256> make_table() {
std::array<std::uint32_t, 256> table{};
for (std::uint32_t i = 0; i < 256; ++i) {
std::uint32_t c = i;
for (int bit = 0; bit < 8; ++bit) {
c = (c & 1u) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
}
table[i] = c;
}
return table;
}
constexpr std::array<std::uint32_t, 256> kTable = make_table(); // built at compile time
} // namespace
std::uint32_t crc32(const void* data, std::size_t len, std::uint32_t previous) noexcept {
const auto* p = static_cast<const unsigned char*>(data);
std::uint32_t c = previous ^ 0xFFFFFFFFu;
for (std::size_t i = 0; i < len; ++i) {
c = kTable[(c ^ p[i]) & 0xFFu] ^ (c >> 8);
}
return c ^ 0xFFFFFFFFu;
}
} // namespace minidb
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
namespace minidb {
// RAII wrapper over a POSIX file descriptor with positioned I/O. Move-only.
// Every failure throws std::system_error carrying errno.
class File {
public:
File() noexcept = default;
~File();
File(File&& other) noexcept;
File& operator=(File&& other) noexcept;
File(const File&) = delete;
File& operator=(const File&) = delete;
static File open(const std::string& path, bool create);
// Reads up to len bytes at offset; returns fewer only at end of file. Retries EINTR and
// short reads.
std::size_t read_at(std::uint64_t offset, char* buf, std::size_t len) const;
// Writes all len bytes at offset or throws.
void write_at(std::uint64_t offset, const char* buf, std::size_t len);
void sync(); // fdatasync: data plus the metadata needed to read it back (size)
void truncate(std::uint64_t size);
std::uint64_t size() const;
bool valid() const noexcept { return fd_ >= 0; }
private:
explicit File(int fd) noexcept : fd_(fd) {}
void close() noexcept;
int fd_ = -1;
};
bool file_exists(const std::string& path);
void rename_file(const std::string& from, const std::string& to);
// fsync on the directory containing `path`, so a create or rename in it is durable.
void sync_parent_directory(const std::string& path);
} // namespace minidb
src/file.cpp, outlined. You wrote its twin in tcpkv step 02; the differences:
open:::open(path, O_RDWR | O_CLOEXEC | (create ? O_CREAT : 0), 0644); on failure throwstd::system_error(errno, std::generic_category(), "open(" + path + ")").read_at: loop::pread(fd_, buf + done, len - done, offset + done); retry onEINTR; stop at a return of 0 (end of file) and return the count, so the caller can see a short read.write_at: loop::pwriteuntil every byte is written; retry onEINTR.sync:::fdatasync(fd_).truncate:::ftruncate.size:::fstat(...).st_size.- Move operations take the descriptor and set the source's to -1; the destructor closes it.
rename_file:std::rename, throwing on failure.sync_parent_directory(path): open the directory that containspathwithO_RDONLY | O_DIRECTORY | O_CLOEXEC,fsyncit, close it. This is what makes a newly created or renamed file's name durable. Without it a power cut can lose the directory entry even though the file's data was synced.
include/minidb/page.hpp, the format every later step depends on:
#pragma once
#include <array>
#include <cstring>
#include "minidb/common.hpp"
#include "minidb/crc32.hpp"
namespace minidb {
// Fixed-width fields are stored in host byte order with memcpy (never a pointer cast, which
// would break strict aliasing and alignment rules). x86-64 and ARM64 Linux are both
// little-endian; a portable format would convert explicitly.
template <class T>
T read_as(const char* p) noexcept {
T value;
std::memcpy(&value, p, sizeof value);
return value;
}
template <class T>
void write_as(char* p, T value) noexcept {
std::memcpy(p, &value, sizeof value);
}
enum class PageType : std::uint16_t { Free = 0, Meta = 1, Leaf = 2, Internal = 3 };
// Every page starts with a 16-byte header:
// [0, 4) CRC-32 of bytes [4, kPageSize)
// [4, 6) PageType
// [6, 8) reserved, zero
// [8, 16) page LSN: the LSN of the WAL record that carried this page image
inline constexpr std::size_t kPageHeaderSize = 16;
inline constexpr std::size_t kPagePayloadSize = kPageSize - kPageHeaderSize;
struct Page {
alignas(64) std::array<char, kPageSize> bytes{};
PageType type() const noexcept { return static_cast<PageType>(read_as<std::uint16_t>(bytes.data() + 4)); }
void set_type(PageType type) noexcept { write_as(bytes.data() + 4, static_cast<std::uint16_t>(type)); }
Lsn lsn() const noexcept { return read_as<Lsn>(bytes.data() + 8); }
void set_lsn(Lsn lsn) noexcept { write_as(bytes.data() + 8, lsn); }
char* payload() noexcept { return bytes.data() + kPageHeaderSize; }
const char* payload() const noexcept { return bytes.data() + kPageHeaderSize; }
void clear_payload() noexcept { std::memset(payload(), 0, kPagePayloadSize); }
// Computes and stores the checksum. Call after the last modification, before any write.
void seal() noexcept { write_as(bytes.data(), crc32(bytes.data() + 4, kPageSize - 4)); }
// A page of all zeros (never written, or a hole) fails this check: crc32 of zeros is not 0.
bool verify() const noexcept {
return read_as<std::uint32_t>(bytes.data()) == crc32(bytes.data() + 4, kPageSize - 4);
}
};
// Page 0. Logged and recovered like every other page.
// payload: u64 magic | u32 version | u32 root | u32 page_count
struct Meta {
static constexpr std::uint64_t kMagic = 0x3142444E494D2121ULL; // arbitrary, fixed forever
static constexpr std::uint32_t kVersion = 1;
PageId root = kNoPage;
std::uint32_t page_count = 0; // pages allocated so far, including page 0
static Meta decode(const Page& page) {
const char* p = page.payload();
if (page.type() != PageType::Meta || read_as<std::uint64_t>(p) != kMagic) {
throw CorruptionError("meta page: bad type or magic (not a minidb file?)");
}
if (read_as<std::uint32_t>(p + 8) != kVersion) {
throw CorruptionError("meta page: unsupported format version");
}
Meta meta;
meta.root = read_as<std::uint32_t>(p + 12);
meta.page_count = read_as<std::uint32_t>(p + 16);
if (meta.root == kNoPage || meta.root >= meta.page_count) {
throw CorruptionError("meta page: root page out of range");
}
return meta;
}
void encode(Page& page) const noexcept {
page.set_type(PageType::Meta);
page.clear_payload();
char* p = page.payload();
write_as(p, kMagic);
write_as(p + 8, kVersion);
write_as(p + 12, root);
write_as(p + 16, page_count);
}
};
} // namespace minidb
static_cast<PageType> of an arbitrary uint16_t read from disk is well defined because the
enum has a fixed underlying type; an unknown value matches no enumerator and the tree code
rejects it. alignas(64) starts each page on a cache-line boundary; it is a nicety, not a
correctness requirement.
tests/test_util.hpp (a fresh temporary directory per test, removed afterwards):
#pragma once
#include <stdlib.h>
#include <filesystem>
#include <string>
#include <system_error>
// A fresh directory under /tmp, removed (with its contents) when the object dies.
class TempDir {
public:
TempDir() {
std::string pattern = (std::filesystem::temp_directory_path() / "minidb_test_XXXXXX").string();
if (::mkdtemp(pattern.data()) == nullptr) {
throw std::system_error(errno, std::generic_category(), "mkdtemp");
}
path_ = pattern;
}
~TempDir() {
std::error_code ec;
std::filesystem::remove_all(path_, ec);
}
TempDir(const TempDir&) = delete;
TempDir& operator=(const TempDir&) = delete;
std::string file(const std::string& name) const { return path_ + "/" + name; }
private:
std::string path_;
};
The start of tests/storage_test.cpp; the file grows in steps 2, 3 and 6:
// Pager, PageCache and Wal unit tests.
#include <cstring>
#include <string>
#include <vector>
#include <gtest/gtest.h>
#include "minidb/btree.hpp"
#include "minidb/page_cache.hpp"
#include "minidb/pager.hpp"
#include "minidb/wal.hpp"
#include "test_util.hpp"
using namespace minidb;
namespace {
Page page_with_byte(char c) {
Page page;
page.set_type(PageType::Leaf);
std::memset(page.payload(), c, kPagePayloadSize);
return page;
}
} // namespace
TEST(Crc32, KnownVector) {
// The standard check value for CRC-32/ISO-HDLC.
EXPECT_EQ(crc32("123456789", 9), 0xCBF43926u);
// Chaining equals one pass.
EXPECT_EQ(crc32("6789", 4, crc32("12345", 5)), 0xCBF43926u);
}
0xCBF43926 is the published check value of this CRC variant (CRC-32/ISO-HDLC, the one zlib
uses) for the input "123456789". Testing against a published vector is how you know you
implemented the CRC-32 and not a CRC.
Build and run (WSL Ubuntu 24.04)¶
mkdir -p ~/dev && cd ~/dev && git init minidb && cd minidb
# create the files above; copy presets, .clang-format, .gitignore, LICENSE and ci.yml from tcpkv
cmake --preset debug && cmake --build --preset debug && ctest --preset debug
cmake --preset asan && cmake --build --preset asan && ctest --preset asan
Tests¶
Crc32.KnownVector passes under debug and asan. CI is green on the first push.
Commit¶
git add -A
git commit -m "Project skeleton: CMake presets, CI, RAII File, CRC-32, page format
- File: pread/pwrite loops with EINTR, fdatasync, ftruncate, atomic rename, directory fsync
- crc32: table built at compile time; tested against the published check value
- Page: 4 KiB with a 16-byte header (CRC, type, page LSN); seal/verify; Meta page codec"
git push -u origin main
What to say in an interview¶
"Every page is 4 KiB with a 16-byte header: a CRC-32 over the rest of the page, the page type,
and the page LSN, the log sequence number of the last log record that carried this page. The
checksum is how the engine notices a torn or corrupted page instead of returning garbage; the
LSN lets recovery skip pages that are already up to date. Fields go through memcpy, which
avoids strict-aliasing and alignment undefined behaviour and costs nothing after optimisation."
Step 2. Pager: pages on disk, checksums, atomic creation¶
Time: ~5 hours Week: 6
Read first: 07_Databases_Internals/02_storage_engines_pages_and_heap_files.md (page
addressing, torn pages). 06_Linux_Toolchain_DevOps/01_linux_for_a_windows_developer.md (the
page cache, fsync). 05_Memory_Debugging_RCA/04_the_catalogue_of_memory_bugs.md (why every
length read from disk must be bounds-checked).
Goal¶
Read and write page N of the data file, checksummed, and create a new database file so that a crash during creation can never leave a half-initialised database.
Design¶
- Page N at offset N * 4096. No indirection. The meta page (page 0) holds the root page id and the page count.
- Seal on write, verify on read.
write()computes the CRC;read()throwsCorruptionErroron a mismatch or a short read. read_if_valid()for recovery. During recovery a missing or torn page is an expected state, not an error: it is about to be overwritten. A separate function says so in its name.- Atomic creation: write
<path>.tmp,fdatasyncit,renameit over<path>,fsyncthe directory.rename(2)replaces the name atomically, so after a crash there is either no database or a complete one. - No caching, no policy. The Pager does raw I/O; deciding when to write is the cache's job. One class, one responsibility, testable alone.
Files¶
include/minidb/pager.hpp, src/pager.cpp; add src/pager.cpp to the library. The tests
call BPlusTree::initial_pages(), which arrives in step 4; until then use a local helper in
the test that returns two pages: a meta page (Meta{1, 2}.encode(page0)) and an empty leaf
(page1.set_type(PageType::Leaf)).
Code¶
#pragma once
#include <string>
#include <vector>
#include "minidb/file.hpp"
#include "minidb/page.hpp"
namespace minidb {
// Raw page I/O on the single data file: page N lives at byte offset N * kPageSize.
// Checksums are applied on write and verified on read. No caching, no policy: that is the
// PageCache's job. Not thread-safe; the Db serialises access.
class Pager {
public:
// Opens an existing data file. Throws std::system_error if it does not exist.
explicit Pager(const std::string& path);
// Creates a new data file atomically: writes `pages` (page i at index i) to path + ".tmp",
// fsyncs it, renames it over `path`, fsyncs the directory. A crash leaves either no file
// or a complete one, never a half-initialised database.
static void create(const std::string& path, std::vector<Page> pages);
// Reads and verifies page `id`. Throws CorruptionError on a short read or bad checksum.
void read(PageId id, Page& out) const;
// Recovery helper: like read(), but reports a missing or torn page as `false` instead
// of throwing, because during redo that is an expected state, not corruption.
bool read_if_valid(PageId id, Page& out) const;
// Seals (checksums) the page and writes it in place. Not durable until sync().
void write(PageId id, Page& page);
void sync() { file_.sync(); }
// Pages physically present in the file (may be fewer than Meta::page_count after a
// crash: allocated pages are only written at eviction or checkpoint).
PageId file_page_count() const;
const std::string& path() const noexcept { return path_; }
private:
std::string path_;
File file_;
};
} // namespace minidb
#include "minidb/pager.hpp"
#include <string>
#include <utility>
namespace minidb {
namespace {
std::uint64_t offset_of(PageId id) noexcept { return static_cast<std::uint64_t>(id) * kPageSize; }
} // namespace
// The file size need not be a multiple of kPageSize: a crash while extending the file can
// leave a partial last page. Such a page was written at eviction or checkpoint, so its
// committed image is in the WAL; read_if_valid() reports it as missing and redo rewrites it.
Pager::Pager(const std::string& path) : path_(path), file_(File::open(path, /*create=*/false)) {}
void Pager::create(const std::string& path, std::vector<Page> pages) {
const std::string tmp = path + ".tmp";
{
File file = File::open(tmp, /*create=*/true);
file.truncate(0); // a leftover .tmp from an earlier crashed create
for (std::size_t i = 0; i < pages.size(); ++i) {
pages[i].seal();
file.write_at(offset_of(static_cast<PageId>(i)), pages[i].bytes.data(), kPageSize);
}
file.sync();
}
rename_file(tmp, path);
sync_parent_directory(path);
}
void Pager::read(PageId id, Page& out) const {
const std::size_t n = file_.read_at(offset_of(id), out.bytes.data(), kPageSize);
if (n != kPageSize) {
throw CorruptionError("page " + std::to_string(id) + ": short read (beyond end of file)");
}
if (!out.verify()) {
throw CorruptionError("page " + std::to_string(id) + ": checksum mismatch");
}
}
bool Pager::read_if_valid(PageId id, Page& out) const {
const std::size_t n = file_.read_at(offset_of(id), out.bytes.data(), kPageSize);
return n == kPageSize && out.verify();
}
void Pager::write(PageId id, Page& page) {
page.seal();
file_.write_at(offset_of(id), page.bytes.data(), kPageSize);
}
PageId Pager::file_page_count() const { return static_cast<PageId>(file_.size() / kPageSize); }
} // namespace minidb
Why file.truncate(0) in create: a .tmp left by an earlier crashed creation may be longer
than the new one; without the truncate, stale pages would survive past the new content.
Build and run¶
Tests¶
Add to tests/storage_test.cpp:
TEST(Pager, CreateWriteReadRoundTrip) {
TempDir dir;
const std::string path = dir.file("db");
Pager::create(path, BPlusTree::initial_pages());
Pager pager(path);
EXPECT_EQ(pager.file_page_count(), 2u);
Page out = page_with_byte('x');
pager.write(5, out); // beyond the end: the file grows, pages 2..4 are holes
pager.sync();
Page in;
pager.read(5, in);
EXPECT_EQ(std::memcmp(in.bytes.data(), out.bytes.data(), kPageSize), 0);
EXPECT_EQ(pager.file_page_count(), 6u);
EXPECT_THROW(pager.read(3, in), CorruptionError); // a hole is all zeros: checksum fails
EXPECT_FALSE(pager.read_if_valid(3, in));
EXPECT_FALSE(pager.read_if_valid(100, in)); // beyond EOF
}
TEST(Pager, DetectsCorruptedPage) {
TempDir dir;
const std::string path = dir.file("db");
Pager::create(path, BPlusTree::initial_pages());
{
File f = File::open(path, false);
const char junk = 0x5A;
f.write_at(kPageSize + 100, &junk, 1); // flip a byte inside page 1
}
Pager pager(path);
Page page;
EXPECT_THROW(pager.read(1, page), CorruptionError);
EXPECT_NO_THROW(pager.read(0, page));
}
CreateWriteReadRoundTrip also documents a Linux fact worth knowing: writing page 5 of a
two-page file creates a hole (pages 2 to 4) that reads back as zeros and takes no disk
space. Zeros fail the checksum, so a hole can never be mistaken for a valid page.
Commit¶
git add -A
git commit -m "Add Pager: checksummed page I/O and atomic database creation
- page N at offset N*4096; seal on write, verify on read (CorruptionError)
- read_if_valid() for recovery, where a torn or missing page is expected
- create(): write .tmp, fdatasync, rename, fsync the directory
- tests: round trip, holes, corrupted byte detection"
git push
What to say in an interview¶
"Creating the database file is atomic: write a temporary file, fdatasync it, rename it into
place, fsync the directory. Rename is atomic on POSIX filesystems, and the directory sync
makes the rename itself durable, which people forget: without it a power cut can lose the
directory entry of a file whose data was synced. Every page read is verified against its CRC,
so corruption becomes an exception, never wrong data."
Step 3. PageCache: pinning, LRU, dirty pages, no-steal¶
Time: ~7 hours Week: 7
Read first: 07_Databases_Internals/02_storage_engines_pages_and_heap_files.md (buffer
pool) and 04_wal_and_crash_recovery.md (steal/no-steal, force/no-force).
02_Modern_CPP: 05_smart_pointers_and_ownership.md (RAII handles), 06_stl_containers_deep_dive.md
(std::list iterator stability, unordered_map). 04_Concurrency is not needed: the cache is
single-threaded by design; the Db mutex protects it.
Goal¶
A fixed pool of page frames in front of the Pager, with the four properties the rest of the engine relies on: a pinned page never moves; a dirty page is written back before its frame is reused; the write-ahead rule is enforced at that moment; pages changed by an uncommitted transaction never reach the disk.
In plain words: the cache is a desk with room for N open books. You may only put a book back on the shelf if nobody is reading it (pin count zero), you put back the one used least recently first (LRU), and if you wrote in it you must first copy your notes into the safe (the log, the WAL rule). Books you are in the middle of editing stay on the desk until the change is signed off (no-steal).
Design¶
- Frames and a page table. A
std::vector<Frame>sized once, so references into it stay valid, plusunordered_map<PageId, frame index>. PageRef, an RAII pin.fetch()pins; thePageRefdestructor unpins. Move-only. An exception anywhere in the tree code cannot leak a pin, so it cannot slowly make every frame unevictable. tcpkv'sFileDescriptoridea, applied to a buffer-pool pin.read()versuswrite()on the handle.write()must be called before modifying the page: it saves the before-image (the first time in this transaction), marks the frame dirty and joins it to the transaction. Making it the only way to get a mutable reference means you cannot forget.- LRU over unpinned frames with a
std::listand stored iterators: O(1) touch, O(1) evict. Real engines use CLOCK (a cheaper approximation) or LRU-K / 2Q (resistant to one big scan flushing the hot set); plain LRU is the clear version. - The WAL-rule hook. The cache knows nothing about the log. It receives a
std::function<void(Lsn)>that must make the log durable up to an LSN, and calls it before every write-back. Dependency inversion in one line, and it lets this step's tests run with a no-op hook. - No-steal and abort. Frames in the open transaction are skipped by eviction.
abort_txn()restores before-images and drops pages created in the transaction (the meta page's before-image undoes the allocation). In-memory undo is all a no-steal engine needs.
Files¶
include/minidb/page_cache.hpp, src/page_cache.cpp.
Code¶
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include <list>
#include <unordered_map>
#include <utility>
#include <vector>
#include "minidb/page.hpp"
#include "minidb/pager.hpp"
namespace minidb {
// Fixed-size buffer pool in front of the Pager.
//
// * Pinning: fetch()/create() return a PageRef that pins the frame; a pinned frame is never
// evicted. The PageRef unpins in its destructor (RAII), so an exception cannot leak a pin.
// * Replacement: least recently unpinned frame first (LRU over unpinned frames).
// * WAL rule: before a dirty page is written to the data file, wal_flush(page.lsn()) is
// called so the log record carrying that image is durable first.
// * No-steal: a page modified by the current transaction is never written to the data file
// before commit_txn(). On abort_txn() its before-image is restored from memory. This is
// what makes a redo-only WAL sufficient: the data file never contains uncommitted bytes.
//
// Not thread-safe; the Db holds one mutex around every operation.
class PageCache {
public:
using WalFlush = std::function<void(Lsn)>;
PageCache(Pager& pager, std::size_t capacity, WalFlush wal_flush);
PageCache(const PageCache&) = delete;
PageCache& operator=(const PageCache&) = delete;
class PageRef {
public:
PageRef() noexcept = default;
PageRef(PageRef&& other) noexcept : cache_(other.cache_), frame_(other.frame_) { other.cache_ = nullptr; }
PageRef& operator=(PageRef&& other) noexcept {
if (this != &other) {
release();
cache_ = other.cache_;
frame_ = other.frame_;
other.cache_ = nullptr;
}
return *this;
}
PageRef(const PageRef&) = delete;
PageRef& operator=(const PageRef&) = delete;
~PageRef() { release(); }
PageId id() const noexcept { return cache_->frames_[frame_].id; }
const Page& read() const noexcept { return cache_->frames_[frame_].page; }
// Joins the page to the current transaction (saving its before-image the first time)
// and returns it for modification. Always call write() *before* changing bytes.
Page& write() { return cache_->begin_write(frame_); }
private:
friend class PageCache;
PageRef(PageCache* cache, std::size_t frame) noexcept : cache_(cache), frame_(frame) {}
void release() noexcept {
if (cache_ != nullptr) {
cache_->unpin(frame_);
cache_ = nullptr;
}
}
PageCache* cache_ = nullptr;
std::size_t frame_ = 0;
};
PageRef fetch(PageId id); // pins; reads (and verifies) from disk on a miss
PageRef create(PageId id); // pins a zeroed frame for a newly allocated page id
// Transaction support. A transaction is implicit: it starts with the first write() and
// ends with commit_txn() or abort_txn().
void for_each_txn_page(const std::function<void(PageId, Page&)>& fn);
void commit_txn() noexcept;
void abort_txn() noexcept;
bool in_txn() const noexcept { return !txn_frames_.empty(); }
// Writes every dirty, committed page. The caller then calls Pager::sync().
void flush_all();
struct Stats {
std::uint64_t hits = 0;
std::uint64_t misses = 0;
std::uint64_t evictions = 0;
std::uint64_t writebacks = 0;
};
const Stats& stats() const noexcept { return stats_; }
std::size_t capacity() const noexcept { return frames_.size(); }
private:
struct Frame {
PageId id = 0;
Page page;
int pins = 0;
bool used = false;
bool dirty = false;
bool in_txn = false;
bool created_in_txn = false;
bool in_lru = false;
std::list<std::size_t>::iterator lru_pos;
};
struct BeforeImage {
Page page;
bool was_dirty = false;
};
std::size_t obtain_frame(); // a free frame, or an evicted one; throws if none
void write_back(Frame& frame);
void pin(std::size_t frame);
void unpin(std::size_t frame) noexcept;
Page& begin_write(std::size_t frame);
void drop_frame(std::size_t frame) noexcept;
Pager& pager_;
WalFlush wal_flush_;
std::vector<Frame> frames_; // never resized: references stay valid
std::unordered_map<PageId, std::size_t> table_; // page id -> frame index
std::list<std::size_t> lru_; // unpinned frames, least recent at front
std::vector<std::size_t> free_; // frames holding no page
std::vector<std::size_t> txn_frames_; // frames written by the current transaction
std::unordered_map<PageId, BeforeImage> before_images_;
Stats stats_;
};
} // namespace minidb
#include "minidb/page_cache.hpp"
#include <string>
#include <utility>
namespace minidb {
PageCache::PageCache(Pager& pager, std::size_t capacity, WalFlush wal_flush)
: pager_(pager), wal_flush_(std::move(wal_flush)), frames_(capacity) {
if (capacity < 16) {
// A put pins one page per tree level and a split creates up to one new page per level
// plus a new root; 16 frames covers trees far deeper than 4 KiB pages ever produce.
throw DbError("page cache needs at least 16 frames");
}
free_.reserve(capacity);
for (std::size_t i = capacity; i > 0; --i) {
free_.push_back(i - 1);
}
}
PageCache::PageRef PageCache::fetch(PageId id) {
const auto it = table_.find(id);
if (it != table_.end()) {
++stats_.hits;
pin(it->second);
return PageRef(this, it->second);
}
++stats_.misses;
const std::size_t index = obtain_frame();
Frame& frame = frames_[index];
try {
pager_.read(id, frame.page);
} catch (...) {
free_.push_back(index); // frame was not registered yet; just give it back
throw;
}
frame.id = id;
frame.used = true;
frame.dirty = false;
table_.emplace(id, index);
pin(index);
return PageRef(this, index);
}
PageCache::PageRef PageCache::create(PageId id) {
if (table_.count(id) != 0) {
throw DbError("page " + std::to_string(id) + " already cached; allocated twice?");
}
const std::size_t index = obtain_frame();
Frame& frame = frames_[index];
frame.page = Page{};
frame.id = id;
frame.used = true;
frame.dirty = true;
frame.in_txn = true;
frame.created_in_txn = true; // abort removes it; there is no before-image to restore
txn_frames_.push_back(index);
table_.emplace(id, index);
pin(index);
return PageRef(this, index);
}
Page& PageCache::begin_write(std::size_t index) {
Frame& frame = frames_[index];
if (!frame.in_txn) {
before_images_.emplace(frame.id, BeforeImage{frame.page, frame.dirty});
frame.in_txn = true;
txn_frames_.push_back(index);
}
frame.dirty = true;
return frame.page;
}
void PageCache::for_each_txn_page(const std::function<void(PageId, Page&)>& fn) {
for (const std::size_t index : txn_frames_) {
fn(frames_[index].id, frames_[index].page);
}
}
void PageCache::commit_txn() noexcept {
for (const std::size_t index : txn_frames_) {
frames_[index].in_txn = false;
frames_[index].created_in_txn = false; // now an ordinary dirty, committed page
}
txn_frames_.clear();
before_images_.clear();
}
void PageCache::abort_txn() noexcept {
for (const std::size_t index : txn_frames_) {
Frame& frame = frames_[index];
if (frame.created_in_txn) {
drop_frame(index); // the allocation is undone by the meta page's before-image
continue;
}
const auto it = before_images_.find(frame.id);
if (it != before_images_.end()) {
frame.page = it->second.page;
frame.dirty = it->second.was_dirty;
}
frame.in_txn = false;
}
txn_frames_.clear();
before_images_.clear();
}
void PageCache::flush_all() {
for (Frame& frame : frames_) {
if (frame.used && frame.dirty && !frame.in_txn) {
write_back(frame);
}
}
}
std::size_t PageCache::obtain_frame() {
if (!free_.empty()) {
const std::size_t index = free_.back();
free_.pop_back();
return index;
}
for (auto it = lru_.begin(); it != lru_.end(); ++it) {
Frame& frame = frames_[*it];
if (frame.in_txn) {
continue; // no-steal: uncommitted bytes never reach the data file
}
if (frame.dirty) {
write_back(frame); // may throw; the frame stays cached and consistent
}
const std::size_t index = *it;
lru_.erase(it);
frame.in_lru = false;
table_.erase(frame.id);
frame.used = false;
++stats_.evictions;
return index;
}
throw DbError("page cache exhausted: every frame is pinned or part of the open transaction");
}
void PageCache::write_back(Frame& frame) {
wal_flush_(frame.page.lsn()); // the WAL rule: log record durable before the page
pager_.write(frame.id, frame.page);
frame.dirty = false;
++stats_.writebacks;
}
void PageCache::pin(std::size_t index) {
Frame& frame = frames_[index];
if (frame.in_lru) {
lru_.erase(frame.lru_pos);
frame.in_lru = false;
}
++frame.pins;
}
void PageCache::unpin(std::size_t index) noexcept {
Frame& frame = frames_[index];
if (--frame.pins == 0 && frame.used) {
frame.lru_pos = lru_.insert(lru_.end(), index); // most recently used at the back
frame.in_lru = true;
}
}
void PageCache::drop_frame(std::size_t index) noexcept {
Frame& frame = frames_[index];
if (frame.in_lru) {
lru_.erase(frame.lru_pos);
frame.in_lru = false;
}
table_.erase(frame.id);
frame.used = false;
frame.dirty = false;
frame.in_txn = false;
frame.created_in_txn = false;
if (frame.pins == 0) {
free_.push_back(index);
}
// A dropped frame that is still pinned would be a bug in the caller (abort while a
// PageRef is alive); it then leaks one frame rather than corrupting another page.
}
} // namespace minidb
Two details to be able to explain:
- In
fetch(), if the disk read throws, the frame goes back to the free list before it was entered in the page table, so a failed read leaves the cache exactly as it was. - In
obtain_frame(), a dirty victim is written back before it leaves the table. If the write throws, the page is still cached and still dirty: nothing is lost.
Build and run¶
cmake --build --preset debug && ctest --preset debug -R PageCache
cmake --build --preset asan && ctest --preset asan -R PageCache
Tests¶
TEST(PageCache, EvictsLeastRecentlyUsedAndWritesBackDirtyPages) {
TempDir dir;
const std::string path = dir.file("db");
std::vector<Page> pages(20);
for (std::size_t i = 0; i < pages.size(); ++i) {
pages[i] = page_with_byte(static_cast<char>('a' + i));
}
Pager::create(path, pages);
Pager pager(path);
std::vector<Lsn> flushed;
PageCache cache(pager, 16, [&](Lsn lsn) { flushed.push_back(lsn); });
{
PageCache::PageRef ref = cache.fetch(3);
ref.write().payload()[0] = 'Z'; // dirty, inside a "transaction"
}
cache.commit_txn(); // now an ordinary dirty page: evictable
for (PageId id = 4; id < 20; ++id) {
cache.fetch(id); // 16 more pages through a 16-frame cache: page 3 must be evicted
}
EXPECT_GE(cache.stats().evictions, 1u);
EXPECT_EQ(flushed.size(), 1u); // the WAL rule hook ran before the write-back
Page on_disk;
pager.read(3, on_disk);
EXPECT_EQ(on_disk.payload()[0], 'Z');
}
TEST(PageCache, NoStealAndAbortRestoresBeforeImage) {
TempDir dir;
const std::string path = dir.file("db");
std::vector<Page> pages(20);
for (std::size_t i = 0; i < pages.size(); ++i) {
pages[i] = page_with_byte('a');
}
Pager::create(path, pages);
Pager pager(path);
PageCache cache(pager, 16, [](Lsn) {});
{
PageCache::PageRef ref = cache.fetch(2);
ref.write().payload()[0] = 'U'; // uncommitted
}
for (PageId id = 3; id < 20; ++id) {
cache.fetch(id); // pressure: 17 other pages through the 15 remaining frames
}
Page on_disk;
pager.read(2, on_disk);
EXPECT_EQ(on_disk.payload()[0], 'a'); // no-steal: the uncommitted byte never hit the file
cache.abort_txn();
EXPECT_EQ(cache.fetch(2).read().payload()[0], 'a'); // before-image restored
}
TEST(PageCache, ThrowsWhenEveryFrameIsPinned) {
TempDir dir;
const std::string path = dir.file("db");
std::vector<Page> pages(20, page_with_byte('a'));
Pager::create(path, pages);
Pager pager(path);
PageCache cache(pager, 16, [](Lsn) {});
std::vector<PageCache::PageRef> pins;
for (PageId id = 0; id < 16; ++id) {
pins.push_back(cache.fetch(id));
}
EXPECT_THROW(cache.fetch(16), DbError);
pins.pop_back(); // unpin one
EXPECT_NO_THROW(cache.fetch(16));
}
Commit¶
git add -A
git commit -m "Add PageCache: pinned frames, LRU replacement, WAL-rule hook, no-steal
- PageRef: move-only RAII pin; write() saves the before-image and joins the transaction
- LRU over unpinned frames (list + stored iterators); dirty victims written back first
- write-back calls the WAL hook with the page LSN before touching the data file
- frames in the open transaction are never evicted; abort_txn() restores before-images"
git push
What to say in an interview¶
"The buffer pool pins pages through a move-only RAII handle, so an exception cannot leak a pin.
Before a dirty page is written back, the cache calls a hook that makes the log durable up to
that page's LSN: the write-ahead rule, enforced in exactly one place. Pages modified by the open
transaction are never evicted, which is the no-steal policy; the data file therefore never
holds uncommitted bytes, so recovery needs redo but never undo. I did not use mmap, because
the engine has to control when a page reaches the disk to keep the WAL rule, and with mmap
the kernel writes dirty pages back whenever it likes. The CIDR 2022 paper 'Are You Sure You
Want to Use MMAP in Your Database Management System?' makes that argument in detail."
Step 4. BPlusTree I: node format, search, insert, splits, invariant checker¶
Time: ~8 hours Week: 7
Read first: 07_Databases_Internals/03_btree_indexes_in_depth.md, all of it.
03_Data_Structures_Algorithms: binary search, and exactly what std::lower_bound and
std::upper_bound return. 02_Modern_CPP: 07_stl_algorithms_lambdas_iterators.md.
Goal¶
A B+tree stored in pages: get, insert, overwrite, leaf splits, internal splits, root splits, every leaf at the same depth, and a checker that proves it after any sequence of operations.
In plain words: a library card catalogue. The top drawer says "A to F: drawer 2, G to M: drawer 3". Drawer 3 says "G to H: card box 17, ...". The card box holds the cards in order. When a card box overflows you split it into two and add one line to the drawer above it. When a drawer overflows you split it and add a line to the one above that. When the top drawer overflows you build a new top drawer above it: the only way the catalogue gets taller, which is why every card box is always the same number of drawers from the top.
Design¶
- Decode, modify, encode. Each operation decodes a page into a small struct of vectors, changes it, and encodes it back. Real engines edit slotted pages in place; this is slower but far easier to get right, and the on-disk format is identical.
- Separators. In an internal node,
key_iis the smallest key that may appear underchild_i. The child for a search key is the number of separators<=the key, which isstd::upper_bound. - Split by bytes, not by count. Cells range from 5 to 1,012 bytes. Splitting at the middle cell can leave one half overfull; splitting at the middle byte cannot. Because a cell is at most a quarter of a page, the left half is at most about 5/8 of a page and the right at most about 7/8. Redo that arithmetic on a whiteboard: total after the insert is at most one page plus one cell; the left half stops at or before half of that; the right half is the rest, at most half plus one cell.
- A leaf split copies the separator up (the first key of the right leaf stays in the leaf); an internal split moves it up (the middle separator leaves the node). That difference is the B+tree in one sentence.
- Leaves are chained through
next_leaffor range scans (step 5). - Allocation is "increment
page_countin the meta page", done through the cache, so it is part of the transaction: an aborted insert also un-allocates. - The checker (
check()) walks the whole tree and verifies every invariant in00_spec.mdsection 4.4. Write it in this step, not later: it is the test oracle for every split you are about to debug. - Fan-out, for interviews. With 16-byte keys an internal entry is 22 bytes, so an internal page holds about 185 children. With 100-byte values a leaf cell is 120 bytes, so a leaf holds about 34. One million keys need roughly 30,000 leaves (more, since split leaves are partly empty) and two internal levels above them: height 3. Three page reads per lookup, and the top two levels stay in the cache.
Files¶
include/minidb/btree.hpp; src/btree.cpp (everything except erase and scan, which are
step 5: leave them declared and unimplemented until then, and do not call them);
tests/btree_test.cpp (the fixture and one test, below).
Code¶
#pragma once
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include "minidb/page_cache.hpp"
namespace minidb {
// B+tree over pages from the PageCache. Keys and values are byte strings; keys are compared
// with std::string_view::compare (bytewise, like memcmp).
//
// internal page: child0 | key1 child1 | key2 child2 | ... key_i = smallest key in child_i
// leaf page: next_leaf | key1 value1 | key2 value2 | ... sorted, unique keys
//
// Every leaf is at the same depth. Leaves are chained left to right through next_leaf, so a
// range scan descends once and then walks sideways.
//
// Simplification (documented, deliberate): erase() never merges or rebalances. A leaf may
// become empty; it stays in the chain and is skipped by scans. Space is reclaimed only by
// rebuilding the file. Many production B-trees also delete lazily for the same reason:
// merges are complex and rarely pay for themselves.
//
// Mutating calls must run inside a PageCache transaction (the Db does this). Not thread-safe.
class BPlusTree {
public:
explicit BPlusTree(PageCache& cache) : cache_(cache) {}
// Pages for a brand-new database: page 0 = meta (root = 1, page_count = 2), page 1 = empty leaf.
static std::vector<Page> initial_pages();
std::optional<std::string> get(std::string_view key);
void put(std::string_view key, std::string_view value);
bool erase(std::string_view key);
// Keys in [start, end), ascending, at most `limit`. An empty `end` means "no upper bound".
std::vector<std::pair<std::string, std::string>> scan(std::string_view start, std::string_view end,
std::size_t limit);
// Walks the whole tree and checks every structural invariant (key order, separator
// bounds, equal leaf depth, leaf chain matches in-order leaves). Throws CorruptionError on
// the first violation; returns the number of keys. Used by tests and after recovery.
std::size_t check();
private:
struct Split {
std::string separator; // smallest key of the new right sibling
PageId right = kNoPage;
};
std::optional<Split> insert(PageId id, std::string_view key, std::string_view value, int depth);
PageId find_leaf(std::string_view key);
PageId allocate_page();
PageId root();
void set_root(PageId root);
PageCache& cache_;
};
} // namespace minidb
src/btree.cpp, part 1: node codecs, search, insert and splits.
#include "minidb/btree.hpp"
#include <algorithm>
#include <iterator>
#include <string>
namespace minidb {
namespace {
constexpr int kMaxDepth = 32; // a 4 KiB-page tree of any realistic size is under 6 levels
// Decoded node forms. We decode a page, change the vectors, and encode it back. Real engines
// edit slotted pages in place; decode/encode costs some CPU but is far easier to get right,
// and the on-disk format is identical either way.
struct LeafNode {
PageId next = kNoPage;
std::vector<std::pair<std::string, std::string>> cells; // sorted by key, unique
};
struct InternalNode {
std::vector<std::string> keys; // separators, strictly increasing
std::vector<PageId> children; // keys.size() + 1 entries
};
// Leaf payload: u16 count | u32 next | count x (u16 klen | u16 vlen | key | value)
// Internal payload: u16 count | u32 child0 | count x (u16 klen | key | u32 child)
constexpr std::size_t kNodeHeader = 2 + 4;
std::size_t leaf_cell_size(const std::pair<std::string, std::string>& cell) noexcept {
return 4 + cell.first.size() + cell.second.size();
}
std::size_t internal_entry_size(const std::string& key) noexcept { return 2 + key.size() + 4; }
std::size_t leaf_size(const LeafNode& node) noexcept {
std::size_t size = kNodeHeader;
for (const auto& cell : node.cells) {
size += leaf_cell_size(cell);
}
return size;
}
std::size_t internal_size(const InternalNode& node) noexcept {
std::size_t size = kNodeHeader;
for (const auto& key : node.keys) {
size += internal_entry_size(key);
}
return size;
}
// Bounds-checked decoding: a corrupt length can never read past the page.
class Reader {
public:
explicit Reader(const char* p) noexcept : p_(p), left_(kPagePayloadSize) {}
template <class T>
T get() {
need(sizeof(T));
const T value = read_as<T>(p_);
p_ += sizeof(T);
left_ -= sizeof(T);
return value;
}
std::string bytes(std::size_t n) {
need(n);
std::string out(p_, n);
p_ += n;
left_ -= n;
return out;
}
private:
void need(std::size_t n) const {
if (n > left_) {
throw CorruptionError("b+tree node overruns its page");
}
}
const char* p_;
std::size_t left_;
};
class Writer {
public:
explicit Writer(char* p) noexcept : p_(p) {}
template <class T>
void put(T value) noexcept {
write_as(p_, value);
p_ += sizeof(T);
}
void bytes(const std::string& s) noexcept {
std::memcpy(p_, s.data(), s.size());
p_ += s.size();
}
private:
char* p_;
};
LeafNode decode_leaf(const Page& page) {
if (page.type() != PageType::Leaf) {
throw CorruptionError("expected a leaf page");
}
Reader r(page.payload());
LeafNode node;
const auto count = r.get<std::uint16_t>();
node.next = r.get<PageId>();
node.cells.reserve(count);
for (std::uint16_t i = 0; i < count; ++i) {
const auto klen = r.get<std::uint16_t>();
const auto vlen = r.get<std::uint16_t>();
std::string key = r.bytes(klen);
std::string value = r.bytes(vlen);
node.cells.emplace_back(std::move(key), std::move(value));
}
return node;
}
// Precondition: leaf_size(node) <= kPagePayloadSize (callers check before encoding).
void encode_leaf(const LeafNode& node, Page& page) {
page.set_type(PageType::Leaf);
page.clear_payload();
Writer w(page.payload());
w.put(static_cast<std::uint16_t>(node.cells.size()));
w.put(node.next);
for (const auto& [key, value] : node.cells) {
w.put(static_cast<std::uint16_t>(key.size()));
w.put(static_cast<std::uint16_t>(value.size()));
w.bytes(key);
w.bytes(value);
}
}
InternalNode decode_internal(const Page& page) {
if (page.type() != PageType::Internal) {
throw CorruptionError("expected an internal page");
}
Reader r(page.payload());
InternalNode node;
const auto count = r.get<std::uint16_t>();
node.keys.reserve(count);
node.children.reserve(count + 1u);
node.children.push_back(r.get<PageId>());
for (std::uint16_t i = 0; i < count; ++i) {
const auto klen = r.get<std::uint16_t>();
node.keys.push_back(r.bytes(klen));
node.children.push_back(r.get<PageId>());
}
return node;
}
void encode_internal(const InternalNode& node, Page& page) {
page.set_type(PageType::Internal);
page.clear_payload();
Writer w(page.payload());
w.put(static_cast<std::uint16_t>(node.keys.size()));
w.put(node.children.front());
for (std::size_t i = 0; i < node.keys.size(); ++i) {
w.put(static_cast<std::uint16_t>(node.keys[i].size()));
w.bytes(node.keys[i]);
w.put(node.children[i + 1]);
}
}
// Index of the child whose subtree may contain `key`: the number of separators <= key.
std::size_t child_index(const InternalNode& node, std::string_view key) {
const auto it = std::upper_bound(node.keys.begin(), node.keys.end(), key,
[](std::string_view k, const std::string& sep) { return k.compare(sep) < 0; });
return static_cast<std::size_t>(it - node.keys.begin());
}
auto leaf_lower_bound(LeafNode& node, std::string_view key) {
return std::lower_bound(node.cells.begin(), node.cells.end(), key,
[](const auto& cell, std::string_view k) { return std::string_view(cell.first).compare(k) < 0; });
}
} // namespace
std::vector<Page> BPlusTree::initial_pages() {
std::vector<Page> pages(2);
Meta meta;
meta.root = 1;
meta.page_count = 2;
meta.encode(pages[0]);
encode_leaf(LeafNode{}, pages[1]);
return pages;
}
PageId BPlusTree::root() {
PageCache::PageRef meta_ref = cache_.fetch(kMetaPageId);
return Meta::decode(meta_ref.read()).root;
}
void BPlusTree::set_root(PageId new_root) {
PageCache::PageRef meta_ref = cache_.fetch(kMetaPageId);
Meta meta = Meta::decode(meta_ref.read());
meta.root = new_root;
meta.encode(meta_ref.write());
}
PageId BPlusTree::allocate_page() {
PageCache::PageRef meta_ref = cache_.fetch(kMetaPageId);
Meta meta = Meta::decode(meta_ref.read());
if (meta.page_count == UINT32_MAX) {
throw DbError("database full: page id space exhausted");
}
const PageId id = meta.page_count++;
meta.encode(meta_ref.write()); // logged with the transaction; undone by abort
return id;
}
PageId BPlusTree::find_leaf(std::string_view key) {
PageId id = root();
for (int depth = 0; depth <= kMaxDepth; ++depth) {
PageCache::PageRef ref = cache_.fetch(id);
const Page& page = ref.read();
if (page.type() == PageType::Leaf) {
return id;
}
const InternalNode node = decode_internal(page);
id = node.children[child_index(node, key)];
}
throw CorruptionError("b+tree deeper than possible (cycle?)");
}
std::optional<std::string> BPlusTree::get(std::string_view key) {
PageCache::PageRef ref = cache_.fetch(find_leaf(key));
LeafNode leaf = decode_leaf(ref.read());
const auto it = leaf_lower_bound(leaf, key);
if (it == leaf.cells.end() || it->first != key) {
return std::nullopt;
}
return std::move(it->second);
}
void BPlusTree::put(std::string_view key, std::string_view value) {
const PageId old_root = root();
std::optional<Split> split = insert(old_root, key, value, 0);
if (!split) {
return;
}
// The root split: the tree grows by one level, at the top. This is the only way a
// B+tree gets taller, which is why all leaves stay at the same depth.
InternalNode node;
node.keys.push_back(std::move(split->separator));
node.children = {old_root, split->right};
const PageId new_root = allocate_page();
PageCache::PageRef ref = cache_.create(new_root);
encode_internal(node, ref.write());
set_root(new_root);
}
std::optional<BPlusTree::Split> BPlusTree::insert(PageId id, std::string_view key, std::string_view value,
int depth) {
if (depth > kMaxDepth) {
throw CorruptionError("b+tree deeper than possible (cycle?)");
}
PageCache::PageRef ref = cache_.fetch(id);
if (ref.read().type() == PageType::Leaf) {
LeafNode leaf = decode_leaf(ref.read());
const auto it = leaf_lower_bound(leaf, key);
if (it != leaf.cells.end() && it->first == key) {
it->second.assign(value.data(), value.size());
} else {
leaf.cells.emplace(it, std::string(key), std::string(value));
}
if (leaf_size(leaf) <= kPagePayloadSize) {
encode_leaf(leaf, ref.write());
return std::nullopt;
}
// Leaf split, balanced by bytes rather than by count, so that a few large values
// cannot leave one half overfull. Because one cell is at most a quarter of a page
// (kMaxKeySize, kMaxValueSize), both halves are guaranteed to fit.
std::size_t total = 0;
for (const auto& cell : leaf.cells) {
total += leaf_cell_size(cell);
}
std::size_t mid = 0;
std::size_t left_bytes = 0;
while (mid + 1 < leaf.cells.size() && left_bytes + leaf_cell_size(leaf.cells[mid]) <= total / 2) {
left_bytes += leaf_cell_size(leaf.cells[mid]);
++mid;
}
mid = std::max<std::size_t>(mid, 1);
LeafNode right;
right.cells.assign(std::make_move_iterator(leaf.cells.begin() + static_cast<std::ptrdiff_t>(mid)),
std::make_move_iterator(leaf.cells.end()));
leaf.cells.resize(mid);
const PageId right_id = allocate_page();
right.next = leaf.next; // splice the new leaf into the sibling chain
leaf.next = right_id;
PageCache::PageRef right_ref = cache_.create(right_id);
encode_leaf(right, right_ref.write());
encode_leaf(leaf, ref.write());
return Split{right.cells.front().first, right_id};
}
InternalNode node = decode_internal(ref.read());
const std::size_t index = child_index(node, key);
std::optional<Split> child_split = insert(node.children[index], key, value, depth + 1);
if (!child_split) {
return std::nullopt;
}
node.keys.insert(node.keys.begin() + static_cast<std::ptrdiff_t>(index), std::move(child_split->separator));
node.children.insert(node.children.begin() + static_cast<std::ptrdiff_t>(index) + 1, child_split->right);
if (internal_size(node) <= kPagePayloadSize) {
encode_internal(node, ref.write());
return std::nullopt;
}
// Internal split: keys[mid] moves *up* to the parent (unlike a leaf split, which copies
// the first right key up and keeps it in the leaf).
std::size_t total = 0;
for (const auto& k : node.keys) {
total += internal_entry_size(k);
}
std::size_t mid = 0;
std::size_t left_bytes = 0;
while (mid + 2 < node.keys.size() && left_bytes + internal_entry_size(node.keys[mid]) <= total / 2) {
left_bytes += internal_entry_size(node.keys[mid]);
++mid;
}
mid = std::max<std::size_t>(mid, 1);
InternalNode right;
right.keys.assign(std::make_move_iterator(node.keys.begin() + static_cast<std::ptrdiff_t>(mid) + 1),
std::make_move_iterator(node.keys.end()));
right.children.assign(node.children.begin() + static_cast<std::ptrdiff_t>(mid) + 1, node.children.end());
std::string promoted = std::move(node.keys[mid]);
node.keys.resize(mid);
node.children.resize(mid + 1);
const PageId right_id = allocate_page();
PageCache::PageRef right_ref = cache_.create(right_id);
encode_internal(right, right_ref.write());
encode_internal(node, ref.write());
return Split{std::move(promoted), right_id};
}
src/btree.cpp, part 2 (end of file): the invariant checker.
std::size_t BPlusTree::check() {
struct Checker {
explicit Checker(PageCache& c) : cache(c) {}
PageCache& cache;
std::uint32_t page_count = 0;
int leaf_depth = -1;
std::size_t keys = 0;
std::vector<PageId> leaves; // in key order
void visit(PageId id, const std::string* lo, const std::string* hi, int depth) {
if (depth > kMaxDepth || id == kNoPage || id >= page_count) {
throw CorruptionError("check: bad child pointer or depth");
}
PageCache::PageRef ref = cache.fetch(id);
if (ref.read().type() == PageType::Leaf) {
const LeafNode leaf = decode_leaf(ref.read());
if (leaf_depth == -1) {
leaf_depth = depth;
} else if (leaf_depth != depth) {
throw CorruptionError("check: leaves at different depths");
}
for (std::size_t i = 0; i < leaf.cells.size(); ++i) {
const std::string& key = leaf.cells[i].first;
if (i > 0 && !(leaf.cells[i - 1].first < key)) {
throw CorruptionError("check: leaf keys not strictly increasing");
}
if ((lo != nullptr && key < *lo) || (hi != nullptr && !(key < *hi))) {
throw CorruptionError("check: leaf key outside its separator bounds");
}
}
keys += leaf.cells.size();
leaves.push_back(id);
return;
}
const InternalNode node = decode_internal(ref.read());
ref = PageCache::PageRef(); // unpin before descending: pins stay O(1), not O(depth)
if (node.children.size() != node.keys.size() + 1) {
throw CorruptionError("check: internal node child count");
}
for (std::size_t i = 0; i < node.keys.size(); ++i) {
if ((i > 0 && !(node.keys[i - 1] < node.keys[i])) || (lo != nullptr && node.keys[i] < *lo) ||
(hi != nullptr && !(node.keys[i] < *hi))) {
throw CorruptionError("check: separators out of order or out of bounds");
}
}
for (std::size_t i = 0; i < node.children.size(); ++i) {
const std::string* child_lo = i == 0 ? lo : &node.keys[i - 1];
const std::string* child_hi = i == node.keys.size() ? hi : &node.keys[i];
visit(node.children[i], child_lo, child_hi, depth + 1);
}
}
};
Checker checker(cache_);
{
PageCache::PageRef meta_ref = cache_.fetch(kMetaPageId);
checker.page_count = Meta::decode(meta_ref.read()).page_count;
}
checker.visit(root(), nullptr, nullptr, 0);
// The sibling chain must visit exactly the leaves the tree walk found, in the same order.
for (std::size_t i = 0; i < checker.leaves.size(); ++i) {
PageCache::PageRef ref = cache_.fetch(checker.leaves[i]);
const PageId expected_next = i + 1 < checker.leaves.size() ? checker.leaves[i + 1] : kNoPage;
if (decode_leaf(ref.read()).next != expected_next) {
throw CorruptionError("check: leaf chain does not match tree order");
}
}
return checker.keys;
}
} // namespace minidb
Points that come up in review:
insertkeeps the parent pinned while it recurses into the child, so pins grow with the tree height (3 or 4), far below the 16-frame minimum.checkunpins a node before visiting its children, so it holds O(1) pins however large the tree.find_leafandinsertstop after 32 levels and throwCorruptionError: a corrupted child pointer that forms a cycle must not hang the process.Readerbounds-checks every length read from a page, so a corruptcountorklencan never read past the 4 KiB buffer. This is exactly the class of bug ASan finds in parsers.
Build and run¶
cmake --build --preset debug && ctest --preset debug -R TreeFixture
cmake --build --preset asan && ctest --preset asan -R TreeFixture
Tests¶
tests/btree_test.cpp: the tree on a Pager and a PageCache with a no-op WAL hook, committing
each operation in the cache directly, so tree bugs show up here and not in recovery. In step 4,
leave out the erase helper of the fixture.
// BPlusTree on a Pager + PageCache with no log: each operation is committed in the cache
// straight away. Isolates the tree from the WAL so tree bugs show up here, not in recovery.
#include <cstdio>
#include <map>
#include <random>
#include <string>
#include <gtest/gtest.h>
#include "minidb/btree.hpp"
#include "test_util.hpp"
using namespace minidb;
namespace {
std::string key_of(int i) {
char buf[16];
std::snprintf(buf, sizeof buf, "k%08d", i);
return buf;
}
std::string create_database(const TempDir& dir) {
const std::string path = dir.file("db");
Pager::create(path, BPlusTree::initial_pages());
return path;
}
class TreeFixture : public ::testing::Test {
protected:
TreeFixture()
: path_(create_database(dir_)),
pager_(path_),
cache_(pager_, 32, [](Lsn) {}), // no WAL in this test: nothing to make durable
tree_(cache_) {}
void put(const std::string& k, const std::string& v) {
tree_.put(k, v);
cache_.commit_txn(); // what Db::commit does after logging
}
bool erase(const std::string& k) {
const bool removed = tree_.erase(k);
cache_.commit_txn();
return removed;
}
TempDir dir_;
std::string path_;
Pager pager_;
PageCache cache_;
BPlusTree tree_;
};
} // namespace
TEST_F(TreeFixture, MaximumSizeCellsAlwaysSplitIntoFittingHalves) {
// Worst case for the split rule: every cell at the maximum size, inserted in reverse.
for (int i = 300; i > 0; --i) {
std::string key = key_of(i);
key.resize(kMaxKeySize, 'k');
put(key, std::string(kMaxValueSize, 'v'));
}
EXPECT_EQ(tree_.check(), 300u);
}
This is the adversarial case for the split rule: every cell at the maximum size, inserted in
descending order so every insert goes to the leftmost leaf. If the byte-balanced split or the
size limits were wrong, encode_leaf would be asked to write more than a page, which the
checker or ASan would catch.
Commit¶
git add -A
git commit -m "Add BPlusTree: node codecs, search, insert with leaf/internal/root splits, checker
- leaf and internal page formats; bounds-checked decoding (CorruptionError on overrun)
- byte-balanced splits; leaf split copies the separator up, internal split moves it up
- root split is the only way the tree grows, so all leaves stay at one depth
- page allocation through the meta page inside the transaction
- check(): key order, separator bounds, equal leaf depth, leaf chain = in-order leaves"
git push
What to say in an interview¶
"It is a B+tree: values only in leaves, leaves chained for range scans, internal nodes holding
separators. Splits are balanced by bytes rather than by count, because cells vary in size, and
the key and value limits guarantee that a cell is at most a quarter of a page, so both halves
of a split always fit. A leaf split copies the first key of the new leaf up; an internal split
moves its middle key up. The tree only grows by splitting the root, which is why every leaf is
at the same depth. With 4 KiB pages and 16-byte keys the fan-out is about 185, so a million
keys is three levels. And there is a check() that verifies every invariant; every test calls
it."
Step 5. BPlusTree II: erase, range scan, model-based test¶
Time: ~5 hours Week: 8
Read first: 07_Databases_Internals/03_btree_indexes_in_depth.md (deletion, merges, range
scans). 03_Data_Structures_Algorithms: std::map as an ordered reference model.
Goal¶
Delete keys, scan ranges through the leaf chain, and prove the whole tree against std::map
under tens of thousands of random operations.
Design¶
- Erase without merge. Remove the cell from its leaf; never merge or redistribute. Leaves may become empty; they stay in the chain and scans skip them. Separators in internal nodes may then name keys that no longer exist, which is harmless: they are still valid bounds. Merging is where most B-tree bugs live, and many production engines also delete lazily and reclaim space by rebuilding. Say it as a decision, with the cost: the file never shrinks.
- Range scan. Descend once to the leaf that would contain
start, then follownext_leaf, stopping atendorlimit. One descent plus sequential leaf reads, which is why B+trees (not plain B-trees) are the standard index structure. - Model-based testing. Run the same random operations against the tree and against a
std::map, compare results as you go, compare full contents at the end, and callcheck()periodically. It finds the bugs you did not think to write a test for.
Files¶
The rest of src/btree.cpp (below); the remaining tests in tests/btree_test.cpp, and the
fixture's erase helper.
Code¶
src/btree.cpp, the two functions between insert and check:
bool BPlusTree::erase(std::string_view key) {
PageCache::PageRef ref = cache_.fetch(find_leaf(key));
LeafNode leaf = decode_leaf(ref.read());
const auto it = leaf_lower_bound(leaf, key);
if (it == leaf.cells.end() || it->first != key) {
return false;
}
leaf.cells.erase(it);
encode_leaf(leaf, ref.write()); // no merge: see the class comment
return true;
}
std::vector<std::pair<std::string, std::string>> BPlusTree::scan(std::string_view start, std::string_view end,
std::size_t limit) {
std::vector<std::pair<std::string, std::string>> out;
if (limit == 0) {
return out;
}
std::uint32_t page_count = 0;
{
PageCache::PageRef meta_ref = cache_.fetch(kMetaPageId);
page_count = Meta::decode(meta_ref.read()).page_count;
}
PageId id = find_leaf(start);
for (std::uint32_t hops = 0; id != kNoPage; ++hops) {
if (hops > page_count) {
throw CorruptionError("leaf chain longer than the file (cycle?)");
}
PageCache::PageRef ref = cache_.fetch(id);
LeafNode leaf = decode_leaf(ref.read());
for (auto& [key, value] : leaf.cells) {
if (std::string_view(key).compare(start) < 0) {
continue;
}
if (!end.empty() && std::string_view(key).compare(end) >= 0) {
return out;
}
out.emplace_back(std::move(key), std::move(value));
if (out.size() >= limit) {
return out;
}
}
id = leaf.next;
}
return out;
}
scan refuses to follow more leaf links than there are pages in the file: a corrupted
next_leaf that points backwards would otherwise loop forever.
Build and run¶
cmake --build --preset debug && ctest --preset debug -R TreeFixture
cmake --build --preset asan && ctest --preset asan -R TreeFixture
Tests¶
The remaining three tests of tests/btree_test.cpp:
TEST_F(TreeFixture, EmptyTree) {
EXPECT_FALSE(tree_.get("x").has_value());
EXPECT_TRUE(tree_.scan("", "", 10).empty());
EXPECT_EQ(tree_.check(), 0u);
}
TEST_F(TreeFixture, SequentialInsertsSplitLeavesAndInternals) {
for (int i = 0; i < 5000; ++i) {
put(key_of(i), std::string(400, 'v')); // ~9 cells per leaf: hundreds of leaves
}
EXPECT_EQ(tree_.check(), 5000u);
EXPECT_EQ(tree_.get(key_of(4321)).value_or("").size(), 400u);
const auto range = tree_.scan(key_of(10), key_of(20), 100);
ASSERT_EQ(range.size(), 10u);
EXPECT_EQ(range.front().first, key_of(10));
}
TEST_F(TreeFixture, MatchesStdMapUnderRandomOperations) {
std::map<std::string, std::string> model;
std::mt19937 rng(7);
for (int step = 0; step < 40000; ++step) {
const std::string key = key_of(static_cast<int>(rng() % 4000));
if (rng() % 3 != 0) {
const std::string value(rng() % (kMaxValueSize + 1), static_cast<char>('a' + rng() % 26));
put(key, value);
model[key] = value;
} else {
EXPECT_EQ(erase(key), model.erase(key) == 1);
}
if (step % 5000 == 0) {
ASSERT_EQ(tree_.check(), model.size());
}
}
ASSERT_EQ(tree_.check(), model.size());
const auto all = tree_.scan("", "", SIZE_MAX);
ASSERT_EQ(all.size(), model.size());
auto it = model.begin();
for (const auto& [k, v] : all) {
ASSERT_EQ(k, it->first);
ASSERT_EQ(v, it->second);
++it;
}
}
Forty thousand operations over 4,000 keys with values up to the maximum size: hundreds of
splits, thousands of deletes, empty leaves, and a check every 5,000 steps. The fixed seed (7)
makes a failure reproducible; when one fails, print step and shrink the loop until the
smallest failing sequence remains.
Commit¶
git add -A
git commit -m "Add BPlusTree erase and range scan; model-based test against std::map
- erase removes the cell without merging (documented: lazy deletion, file never shrinks)
- scan: one descent, then the leaf chain; [start, end) with a limit; cycle guard
- 40k random puts/erases checked against std::map with periodic full invariant checks"
git push
What to say in an interview¶
"Deletes are lazy: the key is removed from its leaf, nothing is merged. That keeps the
delete path trivially correct, at the cost that space is only reclaimed by rebuilding the file,
which is a trade-off production engines also make. Range scans descend once and then walk the
leaf chain. The tree is tested model-based: forty thousand random operations against
std::map, comparing every result, with the full invariant checker running every few
thousand steps."
Step 6. Wal: records, LSNs, sync, replay, truncate, reset¶
Time: ~6 hours Week: 8
Read first: 07_Databases_Internals/04_wal_and_crash_recovery.md (all of it).
06_Linux_Toolchain_DevOps/07_debugging_and_profiling_tools_on_linux.md (strace -e
trace=fdatasync,pwrite64 to see the commit protocol happen).
Goal¶
The write-ahead log: append page-image and commit records, make them durable with one
fdatasync, read them back after a crash stopping cleanly at a torn tail, cut the log at a
chosen point, and replace it with an empty one at a checkpoint without ever losing the LSN
sequence.
In plain words: the receipt pad from 00_spec.md section 1. Every receipt is numbered by its
position on an endless roll of paper (the LSN), sealed (CRC), and the roll is only considered
"in the safe" after fdatasync returns.
Design¶
- LSN = byte position in a logical log that never restarts:
start_lsn + (offset - 24). The record stores its own LSN, so replay can tell a record written here from leftover bytes of an earlier, longer log. - Append buffers,
sync()writes and syncs. Onepwriteand onefdatasyncper commit, however many records the transaction has.ensure_durable(lsn)is what the cache's WAL hook calls: it syncs only if that LSN is not durable yet. - Replay stops at the first invalid record: incomplete, wrong length for its type, LSN not equal to its position, or CRC mismatch. All of those mean "the crash happened here".
truncate_atis separate fromreplay. Recovery decides where the log ends: just after the last commit, not just after the last valid record. The first draft of this code truncated after the last valid record;Recovery.UncommittedTailIsDiscardedAndNotReplayedLater(step 7) failed, because the page images of a crashed transaction stayed in the log, in front of the next transaction's records, and the next recovery applied them as part of that transaction's commit. Keep that story: it is a real ordering bug, and a test you wrote found it.reset()never rewrites the header in place. It builds<path>.tmpwith a header whosestart_lsnis the current end, syncs it, renames it over the log, syncs the directory. A crash leaves the old log (replaying it is harmless after a checkpoint) or the new empty one.- LSN 0 is reserved, a new log starts at
kFirstLsn = 1. Why is in step 7.
Files¶
include/minidb/wal.hpp, src/wal.cpp; add src/wal.cpp to the library.
Code¶
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include "minidb/file.hpp"
#include "minidb/page.hpp"
namespace minidb {
enum class RecordType : std::uint8_t { PageImage = 1, Commit = 2 };
struct WalRecord {
Lsn lsn = 0;
RecordType type = RecordType::Commit;
PageId page_id = 0; // PageImage
std::uint64_t txn_id = 0; // Commit
Page image; // PageImage: the full page after the change, sealed
};
// Write-ahead log: an append-only file next to the data file ("<db>-wal").
//
// File layout:
// header (24 bytes): u64 magic | u64 start_lsn | u32 crc32(first 16 bytes) | u32 zero
// records, back to back:
// u32 crc32(bytes [4, 24) + payload) | u32 payload_len | u64 lsn | u8 type | 3 x u8 zero |
// u32 page_id | payload (4096-byte page image, or u64 txn id for Commit)
//
// LSN = start_lsn + (file offset - header size). LSNs only grow: reset() starts the next log
// at the current end LSN, so a page LSN can be compared with any record LSN.
//
// The header is never rewritten in place. A new log (creation or reset) is built as
// "<path>.tmp", fsynced, and renamed over the old one, so the file always has a valid header
// and either the old records or none.
//
// Durability: append_*() only buffer in memory; sync() writes the buffer and fdatasyncs.
// A record is durable once durable_lsn() is greater than its LSN.
class Wal {
public:
// LSN 0 is reserved: it is the LSN of pages that were never logged (written by
// Pager::create). If the first record also had LSN 0, recovery would see a page with
// LSN 0 >= record LSN 0 and wrongly skip the redo.
static constexpr Lsn kFirstLsn = 1;
// Opens the log at `path`, creating an empty one (start LSN kFirstLsn) if it does not exist.
// Throws CorruptionError if an existing file has an invalid header.
explicit Wal(const std::string& path);
Wal(const Wal&) = delete;
Wal& operator=(const Wal&) = delete;
// Recovery: calls `fn` for every valid record in order, stopping at the first record that
// is incomplete, has a bad checksum or an unexpected LSN: the torn tail of a crash.
// Returns the number of valid records. Does not modify the file.
std::size_t replay(const std::function<void(const WalRecord&)>& fn);
// Recovery: discard everything from `end` onwards (the records of a transaction that never
// committed, and any torn tail). Must be called before the first append after replay(),
// or those orphaned page images would be mistaken for part of the next transaction.
void truncate_at(Lsn end);
// Stamps the page with the record's LSN, seals it, and buffers the record.
Lsn append_page(PageId id, Page& page);
Lsn append_commit(std::uint64_t txn_id);
void sync(); // write buffered records, then fdatasync
void ensure_durable(Lsn lsn); // sync() unless the record at `lsn` is already durable
void discard_buffer() noexcept; // drop unsynced records (a transaction that failed)
// Checkpoint: the data file has been synced and contains every logged change. Replace
// the log with an empty one that starts at end_lsn().
void reset();
Lsn start_lsn() const noexcept { return start_lsn_; }
Lsn end_lsn() const noexcept { return end_lsn_; } // LSN the next record gets
Lsn durable_lsn() const noexcept { return durable_lsn_; } // everything below is on disk
std::uint64_t size_bytes() const noexcept { return kHeaderSize + (end_lsn_ - start_lsn_); }
static constexpr std::size_t kHeaderSize = 24;
static constexpr std::size_t kRecordHeaderSize = 24;
private:
static void create_empty(const std::string& path, Lsn start_lsn);
std::uint64_t offset_of(Lsn lsn) const noexcept { return kHeaderSize + (lsn - start_lsn_); }
std::string path_;
File file_;
Lsn start_lsn_ = 0;
Lsn end_lsn_ = 0;
Lsn durable_lsn_ = 0;
std::string buffer_; // encoded records in [durable_lsn_, end_lsn_)
};
} // namespace minidb
#include "minidb/wal.hpp"
#include <array>
#include "minidb/crc32.hpp"
namespace minidb {
namespace {
constexpr std::uint64_t kWalMagic = 0x314C41574244494DULL; // arbitrary, fixed forever
std::uint32_t payload_size(RecordType type) noexcept {
return type == RecordType::PageImage ? static_cast<std::uint32_t>(kPageSize) : 8u;
}
void encode_record(std::string& out, Lsn lsn, RecordType type, PageId page_id, const char* payload,
std::uint32_t payload_len) {
std::array<char, Wal::kRecordHeaderSize> header{};
write_as(header.data() + 4, payload_len);
write_as(header.data() + 8, lsn);
write_as(header.data() + 16, static_cast<std::uint8_t>(type));
write_as(header.data() + 20, page_id);
std::uint32_t crc = crc32(header.data() + 4, header.size() - 4);
crc = crc32(payload, payload_len, crc);
write_as(header.data(), crc);
out.append(header.data(), header.size());
out.append(payload, payload_len);
}
} // namespace
void Wal::create_empty(const std::string& path, Lsn start_lsn) {
std::array<char, kHeaderSize> header{};
write_as(header.data(), kWalMagic);
write_as(header.data() + 8, start_lsn);
write_as(header.data() + 16, crc32(header.data(), 16));
const std::string tmp = path + ".tmp";
{
File file = File::open(tmp, /*create=*/true);
file.truncate(0);
file.write_at(0, header.data(), header.size());
file.sync();
}
rename_file(tmp, path); // atomic: readers see the old log or the new one
sync_parent_directory(path); // and the rename survives a power cut
}
Wal::Wal(const std::string& path) : path_(path) {
if (!file_exists(path)) {
create_empty(path, kFirstLsn);
}
file_ = File::open(path, /*create=*/false);
std::array<char, kHeaderSize> header{};
const std::size_t n = file_.read_at(0, header.data(), header.size());
if (n != kHeaderSize || read_as<std::uint64_t>(header.data()) != kWalMagic ||
read_as<std::uint32_t>(header.data() + 16) != crc32(header.data(), 16)) {
throw CorruptionError("WAL " + path + ": invalid header");
}
start_lsn_ = read_as<Lsn>(header.data() + 8);
end_lsn_ = start_lsn_;
durable_lsn_ = start_lsn_;
}
std::size_t Wal::replay(const std::function<void(const WalRecord&)>& fn) {
std::size_t count = 0;
Lsn lsn = start_lsn_;
WalRecord record;
std::array<char, kRecordHeaderSize> header{};
std::array<char, 8> commit_payload{};
for (;;) {
const std::uint64_t offset = offset_of(lsn);
if (file_.read_at(offset, header.data(), header.size()) != header.size()) {
break; // clean end of log, or a torn record header
}
const auto len = read_as<std::uint32_t>(header.data() + 4);
const auto type = static_cast<RecordType>(read_as<std::uint8_t>(header.data() + 16));
if ((type != RecordType::PageImage && type != RecordType::Commit) || len != payload_size(type) ||
read_as<Lsn>(header.data() + 8) != lsn) {
break; // garbage: not a record we wrote at this position
}
char* payload = type == RecordType::PageImage ? record.image.bytes.data() : commit_payload.data();
if (file_.read_at(offset + kRecordHeaderSize, payload, len) != len) {
break; // torn payload
}
std::uint32_t crc = crc32(header.data() + 4, header.size() - 4);
crc = crc32(payload, len, crc);
if (crc != read_as<std::uint32_t>(header.data())) {
break; // torn or corrupted record
}
record.lsn = lsn;
record.type = type;
record.page_id = read_as<PageId>(header.data() + 20);
record.txn_id = type == RecordType::Commit ? read_as<std::uint64_t>(commit_payload.data()) : 0;
fn(record);
++count;
lsn += kRecordHeaderSize + len;
}
return count;
}
void Wal::truncate_at(Lsn end) {
if (end < start_lsn_) {
throw DbError("WAL truncate_at: LSN before start of log");
}
// Cut the uncommitted or torn tail so new records follow the last commit directly.
// Otherwise a later replay would see the orphaned images, or stop at the old garbage
// before reaching newer committed records.
file_.truncate(offset_of(end));
file_.sync();
buffer_.clear();
end_lsn_ = end;
durable_lsn_ = end;
}
Lsn Wal::append_page(PageId id, Page& page) {
const Lsn lsn = end_lsn_;
page.set_lsn(lsn);
page.seal();
encode_record(buffer_, lsn, RecordType::PageImage, id, page.bytes.data(),
static_cast<std::uint32_t>(kPageSize));
end_lsn_ += kRecordHeaderSize + kPageSize;
return lsn;
}
Lsn Wal::append_commit(std::uint64_t txn_id) {
const Lsn lsn = end_lsn_;
std::array<char, 8> payload{};
write_as(payload.data(), txn_id);
encode_record(buffer_, lsn, RecordType::Commit, 0, payload.data(), 8);
end_lsn_ += kRecordHeaderSize + 8;
return lsn;
}
void Wal::sync() {
if (!buffer_.empty()) {
file_.write_at(offset_of(durable_lsn_), buffer_.data(), buffer_.size());
}
file_.sync();
durable_lsn_ = end_lsn_;
buffer_.clear();
}
void Wal::ensure_durable(Lsn lsn) {
if (lsn >= durable_lsn_) {
sync();
}
}
void Wal::discard_buffer() noexcept {
buffer_.clear();
end_lsn_ = durable_lsn_;
}
void Wal::reset() {
if (!buffer_.empty()) {
sync();
}
file_ = File(); // close before replacing the file
create_empty(path_, end_lsn_);
file_ = File::open(path_, /*create=*/false);
start_lsn_ = end_lsn_;
durable_lsn_ = end_lsn_;
}
} // namespace minidb
Notes:
- The CRC covers the record header after the CRC field plus the payload, chained with the
previousparameter ofcrc32: one checksum over two buffers without concatenating them. discard_buffer()is for a transaction that failed before its records reached the file. The nextsync()writes at the durable offset, overwriting whatever a failed attempt may have left there.size_bytes()is what theDbcompares withcheckpoint_wal_bytes.
Build and run¶
cmake --build --preset debug && ctest --preset debug -R Wal
strace -f -e trace=pwrite64,fdatasync,rename ./build/debug/storage_test --gtest_filter='Wal.*' 2>&1 | head -40
The strace output shows the protocol: one pwrite64 of the buffered records, one
fdatasync, and for reset() a write to wal.tmp, fdatasync, rename, and an fsync of
the directory.
Tests¶
Add to tests/storage_test.cpp:
TEST(Wal, AppendSyncReplay) {
TempDir dir;
const std::string path = dir.file("wal");
Lsn first = 0;
{
Wal wal(path);
wal.truncate_at(wal.start_lsn());
Page page = page_with_byte('q');
first = wal.append_page(7, page);
EXPECT_EQ(page.lsn(), first); // the page carries its record's LSN
wal.append_commit(1);
wal.sync();
EXPECT_EQ(wal.durable_lsn(), wal.end_lsn());
}
Wal wal(path);
std::vector<WalRecord> seen;
EXPECT_EQ(wal.replay([&](const WalRecord& r) { seen.push_back(r); }), 2u);
ASSERT_EQ(seen.size(), 2u);
EXPECT_EQ(seen[0].type, RecordType::PageImage);
EXPECT_EQ(seen[0].page_id, 7u);
EXPECT_EQ(seen[0].lsn, first);
EXPECT_TRUE(seen[0].image.verify());
EXPECT_EQ(seen[1].type, RecordType::Commit);
EXPECT_EQ(seen[1].txn_id, 1u);
}
TEST(Wal, TornTailIsIgnored) {
TempDir dir;
const std::string path = dir.file("wal");
{
Wal wal(path);
Page page = page_with_byte('q');
wal.append_page(1, page);
wal.append_commit(1);
wal.append_page(2, page);
wal.append_commit(2);
wal.sync();
}
{
// Simulate a crash in the middle of writing the second transaction's page image.
File f = File::open(path, false);
f.truncate(Wal::kHeaderSize + (Wal::kRecordHeaderSize + kPageSize) + (Wal::kRecordHeaderSize + 8) + 1000);
}
Wal wal(path);
std::size_t commits = 0;
EXPECT_EQ(wal.replay([&](const WalRecord& r) { commits += r.type == RecordType::Commit ? 1 : 0; }), 2u);
EXPECT_EQ(commits, 1u);
}
TEST(Wal, ResetKeepsLsnsMonotonic) {
TempDir dir;
const std::string path = dir.file("wal");
Wal wal(path);
Page page = page_with_byte('q');
wal.append_page(1, page);
wal.append_commit(1);
wal.sync();
const Lsn end = wal.end_lsn();
wal.reset();
EXPECT_EQ(wal.start_lsn(), end);
EXPECT_EQ(wal.size_bytes(), Wal::kHeaderSize);
EXPECT_EQ(wal.append_commit(2), end); // the next record continues the sequence
}
Commit¶
git add -A
git commit -m "Add write-ahead log: page-image and commit records, LSNs, replay, reset
- record: CRC over header and payload, length, LSN equal to its position, type, page id
- append buffers; sync() is one pwrite + one fdatasync; ensure_durable() for the WAL rule
- replay stops at the first invalid record (torn tail); truncate_at() cuts the log where
recovery decides (after the last commit)
- reset() via tmp file + rename + directory fsync; LSNs continue across resets"
git push
What to say in an interview¶
"The log is append-only and each record carries a CRC and its own LSN, which is its byte
position in a logical log that never restarts, so replay can tell the torn tail of a crash
from valid records. A commit is one pwrite and one fdatasync of everything the transaction
appended. Two details I got wrong first and my tests caught: after a crash the log must be cut
after the last commit, not the last valid record, or a crashed transaction's page images get
replayed as part of the next commit; and LSN 0 must be reserved for pages that were never
logged."
Step 7. Recovery and Db: the commit protocol and checkpoints¶
Time: ~8 hours Week: 9
Read first: 07_Databases_Internals/04_wal_and_crash_recovery.md again (ARIES: analysis,
redo, undo; steal/no-steal, force/no-force), 05_transactions_acid_mvcc.md (atomicity and
durability). 04_Concurrency/03_mutexes_lock_guard_unique_lock.md. 02_Modern_CPP:
10_error_handling_exceptions_optional_expected.md (exception safety: the strong guarantee).
Goal¶
Wire everything into the public Db class: transactions around every put and del, the
commit protocol, checkpoints, and redo-only crash recovery on open. After this step the
durability contract in 00_spec.md section 2 holds.
Design¶
The commit protocol, in the order that makes it crash-safe (Db::commit):
1. for every page in the transaction: Wal::append_page (stamps the page LSN, seals it)
2. Wal::append_commit
3. Wal::sync() <-- durable from here; the call may now return
4. PageCache::commit_txn() pages become ordinary dirty pages, evictable
The data file is not touched at commit (no-force). It catches up at eviction (after the WAL hook) or at a checkpoint.
- Strong exception guarantee for
put/del.write_txnruns the tree operation and the commit; if anything throws,abort_txn()restores every before-image anddiscard_buffer()forgets the buffered records. The database is exactly as it was before the call. - A failed
fdatasyncpoisons theDb. After a failedfsyncthe kernel may have dropped the dirty pages and cleared the error, so retrying can falsely succeed (PostgreSQL's 2018 "fsyncgate"). The only safe response is to refuse further operations and let recovery decide on the next open. - Checkpoint =
flush_all(),Pager::sync(),Wal::reset(). Run when the log passescheckpoint_wal_bytes, on open after recovery, and in the destructor. - Recovery (
00_spec.mdsection 7): replay, collect page images per transaction, apply them at the commit record, skip pages whose LSN shows they are already current, discard the uncommitted tail, sync the data file, cut the log after the last commit. - The LSN-0 bug. In the first draft the log started at LSN 0. A freshly created database
has page 1 (the root leaf) with page LSN 0. The first
putlogged page 1 at LSN 0. After a crash, recovery compared the data file's page LSN (0) with the record's (0), concluded "already current", and skipped the redo: the first committed write of every database was lost if the process died before a checkpoint. The SIGKILL crash test did not catch it (by the time it kills, page 1 has been logged many times at higher LSNs); the deterministic test below did. The fix is one constant: logs start at LSN 1, and LSN 0 means "never logged". - Member order is construction order.
Pager, thenWal, thenRecoveryStats(whose initialiser runs recovery), then the cache and the tree. Recovery must finish before the cache reads its first page. The constructor initialiser list makes that ordering explicit and impossible to get wrong later.
Files¶
include/minidb/recovery.hpp, src/recovery.cpp, include/minidb/db.hpp, src/db.cpp,
tests/db_test.cpp.
Code¶
#pragma once
#include <cstddef>
#include "minidb/pager.hpp"
#include "minidb/wal.hpp"
namespace minidb {
struct RecoveryStats {
std::size_t records = 0; // valid records found in the log
std::size_t committed_txns = 0; // transactions with a commit record
std::size_t pages_redone = 0; // page images written to the data file
std::size_t pages_skipped = 0; // data file already had this image or a newer one
std::size_t discarded_records = 0; // records of a transaction that never committed
Lsn end_lsn = 0; // log end after recovery (just past the last commit)
};
// Redo-only crash recovery.
//
// Why redo-only works: the PageCache never writes a page modified by an uncommitted
// transaction to the data file (no-steal), so the data file never needs undoing. It may,
// however, be missing committed changes (no-force: commit only syncs the log) and may hold a
// torn page (crash during a page write). Every committed change is in the log as a full page
// image, so replaying committed images in log order repairs both.
//
// Idempotent: running it twice, or crashing during it and running it again, gives the same
// result, because applying a full page image is idempotent and replay is in LSN order.
class Recovery {
public:
static RecoveryStats run(Pager& pager, Wal& wal);
};
} // namespace minidb
#include "minidb/recovery.hpp"
#include <utility>
#include <vector>
namespace minidb {
RecoveryStats Recovery::run(Pager& pager, Wal& wal) {
RecoveryStats stats;
stats.end_lsn = wal.start_lsn();
// Page images of the transaction being read; applied only when its commit record shows
// up. Images of a transaction whose commit never reached the disk are never applied.
std::vector<std::pair<PageId, Page>> pending;
Page current;
stats.records = wal.replay([&](const WalRecord& record) {
if (record.type == RecordType::PageImage) {
pending.emplace_back(record.page_id, record.image);
return;
}
// Commit: redo every image of this transaction, in log order.
for (auto& [page_id, image] : pending) {
// The page LSN check is an optimisation, not a correctness requirement: if the
// data file already holds this image or a later one, rewriting is unnecessary.
// A torn or missing page fails read_if_valid() and is always rewritten.
if (pager.read_if_valid(page_id, current) && current.lsn() >= image.lsn()) {
++stats.pages_skipped;
continue;
}
pager.write(page_id, image);
++stats.pages_redone;
}
pending.clear();
++stats.committed_txns;
stats.end_lsn = record.lsn + Wal::kRecordHeaderSize + 8; // just past the commit record
});
stats.discarded_records = pending.size();
pager.sync(); // redone pages durable before the log can be reset
wal.truncate_at(stats.end_lsn); // drop the uncommitted tail before anything is appended
return stats;
}
} // namespace minidb
#pragma once
#include <cstddef>
#include <cstdint>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include "minidb/btree.hpp"
#include "minidb/page_cache.hpp"
#include "minidb/pager.hpp"
#include "minidb/recovery.hpp"
#include "minidb/wal.hpp"
namespace minidb {
struct Options {
std::size_t cache_pages = 256; // 1 MiB of page cache
std::uint64_t checkpoint_wal_bytes = 8u << 20; // checkpoint when the log passes 8 MiB
};
// The public API. Every put/del is its own transaction and is durable when the call returns:
// its WAL records have been fdatasynced. One mutex serialises all calls.
class Db {
public:
// Opens `path` (and "<path>-wal"), creating both if the data file does not exist,
// and runs crash recovery.
explicit Db(const std::string& path, Options options = {});
~Db(); // checkpoints, so a clean shutdown leaves an empty log
Db(const Db&) = delete;
Db& operator=(const Db&) = delete;
void put(std::string_view key, std::string_view value);
std::optional<std::string> get(std::string_view key);
bool del(std::string_view key);
std::vector<std::pair<std::string, std::string>> scan(std::string_view start, std::string_view end = {},
std::size_t limit = SIZE_MAX);
void checkpoint();
std::size_t check(); // full structural check; returns key count
const RecoveryStats& recovery_stats() const noexcept { return recovery_; }
std::uint64_t wal_bytes() const noexcept { return wal_.size_bytes(); }
const PageCache::Stats& cache_stats() const noexcept { return cache_.stats(); }
private:
template <class F>
auto write_txn(F&& body);
void commit();
void checkpoint_locked();
void throw_if_failed() const;
std::mutex mutex_;
Options options_;
Pager pager_; // declaration order is construction order:
Wal wal_; // the log must exist before recovery,
RecoveryStats recovery_; // recovery must finish before the cache reads any page
PageCache cache_;
BPlusTree tree_;
std::uint64_t next_txn_id_ = 1;
bool failed_ = false; // set when a commit fails part-way; see commit()
};
} // namespace minidb
#include "minidb/db.hpp"
#include <stdexcept>
namespace minidb {
namespace {
// Runs before the Pager member is constructed: makes sure a data file exists.
const std::string& ensure_database(const std::string& path) {
if (!file_exists(path)) {
// A log left over from an older database with the same name must not be replayed
// into the new one: replace it with an empty log first, then create the data file.
if (file_exists(path + "-wal")) {
rename_file(path + "-wal", path + "-wal.orphaned");
}
Pager::create(path, BPlusTree::initial_pages());
}
return path;
}
void validate(std::string_view key, std::string_view value) {
if (key.empty() || key.size() > kMaxKeySize) {
throw std::invalid_argument("minidb: key must be 1.." + std::to_string(kMaxKeySize) + " bytes");
}
if (value.size() > kMaxValueSize) {
throw std::invalid_argument("minidb: value must be at most " + std::to_string(kMaxValueSize) + " bytes");
}
}
} // namespace
Db::Db(const std::string& path, Options options)
: options_(options),
pager_(ensure_database(path)),
wal_(path + "-wal"),
recovery_(Recovery::run(pager_, wal_)),
cache_(pager_, options.cache_pages, [this](Lsn lsn) { wal_.ensure_durable(lsn); }),
tree_(cache_) {
{
PageCache::PageRef meta = cache_.fetch(kMetaPageId);
(void)Meta::decode(meta.read()); // throws CorruptionError for a non-minidb file
}
std::lock_guard<std::mutex> lock(mutex_);
checkpoint_locked(); // fold the recovered log into the data file; start with an empty log
}
Db::~Db() {
try {
std::lock_guard<std::mutex> lock(mutex_);
if (!failed_) {
checkpoint_locked();
}
} catch (...) {
// Destructors must not throw. Nothing is lost: the log still holds every commit.
}
}
void Db::throw_if_failed() const {
if (failed_) {
throw DbError("minidb: a previous commit failed; reopen the database to recover");
}
}
template <class F>
auto Db::write_txn(F&& body) {
throw_if_failed();
try {
auto result = body();
commit();
return result;
} catch (...) {
cache_.abort_txn(); // restore before-images: memory matches the last commit again
wal_.discard_buffer(); // nothing of this transaction reaches the log
throw;
}
}
// The commit protocol, in the order that makes it crash-safe:
// 1. append a full image of every page this transaction changed (stamps page LSNs)
// 2. append a commit record
// 3. fdatasync the log <- the transaction is durable from here
// 4. release the pages to the cache as ordinary dirty pages (they may now be evicted)
// The data file is not touched. It catches up at eviction or checkpoint.
void Db::commit() {
if (!cache_.in_txn()) {
return; // e.g. del() of a missing key changed nothing
}
cache_.for_each_txn_page([this](PageId id, Page& page) { wal_.append_page(id, page); });
wal_.append_commit(next_txn_id_++);
try {
wal_.sync();
} catch (...) {
// fsync failed. The kernel may have dropped the dirty log pages, so we cannot know
// what is on disk ("fsyncgate", PostgreSQL 2018). The only safe answer is to stop
// accepting writes and let recovery decide on the next open.
failed_ = true;
throw;
}
cache_.commit_txn();
}
void Db::put(std::string_view key, std::string_view value) {
validate(key, value);
std::lock_guard<std::mutex> lock(mutex_);
write_txn([&] {
tree_.put(key, value);
return true;
});
if (wal_.size_bytes() >= options_.checkpoint_wal_bytes) {
checkpoint_locked();
}
}
bool Db::del(std::string_view key) {
if (key.empty() || key.size() > kMaxKeySize) {
return false;
}
std::lock_guard<std::mutex> lock(mutex_);
const bool removed = write_txn([&] { return tree_.erase(key); });
if (wal_.size_bytes() >= options_.checkpoint_wal_bytes) {
checkpoint_locked();
}
return removed;
}
std::optional<std::string> Db::get(std::string_view key) {
std::lock_guard<std::mutex> lock(mutex_);
throw_if_failed();
return tree_.get(key);
}
std::vector<std::pair<std::string, std::string>> Db::scan(std::string_view start, std::string_view end,
std::size_t limit) {
std::lock_guard<std::mutex> lock(mutex_);
throw_if_failed();
return tree_.scan(start, end, limit);
}
std::size_t Db::check() {
std::lock_guard<std::mutex> lock(mutex_);
return tree_.check();
}
void Db::checkpoint() {
std::lock_guard<std::mutex> lock(mutex_);
throw_if_failed();
checkpoint_locked();
}
// Checkpoint: make the data file contain everything the log contains, then empty the log.
// 1. write every dirty page (each one's WAL record is already durable: the WAL rule)
// 2. fdatasync the data file
// 3. replace the log with an empty one starting at the current end LSN
// A crash before step 3 completes leaves the old log in place; replaying it is harmless.
void Db::checkpoint_locked() {
cache_.flush_all();
pager_.sync();
wal_.reset();
}
} // namespace minidb
ensure_database renames a log left behind by an older database of the same name instead of
replaying it into the new one: without that, deleting the data file but not the log and then
reopening would apply a stranger's page images to a fresh database. (A stronger version stores
a random database id in both the meta page and the log header and refuses to replay on a
mismatch; a good exercise.)
Build and run¶
cmake --build --preset debug && ctest --preset debug
cmake --build --preset asan && ctest --preset asan
db_test is slow by design: every put waits for fdatasync. On the WSL2 machine used to
check this code, its roughly 15,000 synced writes took about 40 seconds. Run
ctest --preset debug -R 'Db|Recovery' --output-on-failure while iterating.
Tests¶
tests/db_test.cpp in full. The three Recovery tests simulate a crash by copying the data
file and the log while the Db is still open (so its destructor's checkpoint has not run),
then damaging the copy in a specific way:
// BPlusTree through the Db API, recovery, and a model-based property test against std::map.
#include <cstdio>
#include <filesystem>
#include <map>
#include <random>
#include <string>
#include <vector>
#include <gtest/gtest.h>
#include "minidb/db.hpp"
#include "test_util.hpp"
using namespace minidb;
namespace {
std::string key_of(int i) {
char buf[16];
std::snprintf(buf, sizeof buf, "k%08d", i);
return buf;
}
} // namespace
TEST(Db, PutGetDelOnFreshDatabase) {
TempDir dir;
Db db(dir.file("db"));
EXPECT_FALSE(db.get("a").has_value());
db.put("a", "1");
db.put("b", "2");
db.put("a", "3"); // overwrite
EXPECT_EQ(db.get("a").value_or(""), "3");
EXPECT_TRUE(db.del("a"));
EXPECT_FALSE(db.del("a"));
EXPECT_FALSE(db.get("a").has_value());
EXPECT_EQ(db.check(), 1u);
}
TEST(Db, RejectsOversizedKeysAndValues) {
TempDir dir;
Db db(dir.file("db"));
EXPECT_THROW(db.put("", "v"), std::invalid_argument);
EXPECT_THROW(db.put(std::string(kMaxKeySize + 1, 'k'), "v"), std::invalid_argument);
EXPECT_THROW(db.put("k", std::string(kMaxValueSize + 1, 'v')), std::invalid_argument);
EXPECT_NO_THROW(db.put(std::string(kMaxKeySize, 'k'), std::string(kMaxValueSize, 'v')));
}
TEST(Db, ManySplitsKeepTheTreeValidAndScansOrdered) {
TempDir dir;
Db db(dir.file("db"), Options{32, 1u << 20}); // small cache: forces eviction and write-back
constexpr int kCount = 6000; // with 300..699-byte values: ~1,000 leaves, so internal nodes split too
for (int i = 0; i < kCount; ++i) {
const int k = (i * 7919) % kCount; // a permutation: inserts arrive out of order
db.put(key_of(k), std::string(static_cast<std::size_t>(300 + k % 400), 'v'));
}
EXPECT_EQ(db.check(), static_cast<std::size_t>(kCount));
const auto all = db.scan("", "");
ASSERT_EQ(all.size(), static_cast<std::size_t>(kCount));
for (int i = 0; i < kCount; ++i) {
EXPECT_EQ(all[static_cast<std::size_t>(i)].first, key_of(i));
}
const auto range = db.scan(key_of(100), key_of(110));
ASSERT_EQ(range.size(), 10u);
EXPECT_EQ(range.front().first, key_of(100));
EXPECT_EQ(range.back().first, key_of(109));
EXPECT_EQ(db.scan(key_of(5), "", 3).size(), 3u);
}
TEST(Db, MatchesStdMapUnderRandomOperations) {
TempDir dir;
const std::string path = dir.file("db");
std::map<std::string, std::string> model;
std::mt19937 rng(12345);
{
Db db(path, Options{24, 256u << 10});
for (int step = 0; step < 10000; ++step) {
const std::string key = key_of(static_cast<int>(rng() % 1500));
const unsigned op = rng() % 10;
if (op < 6) {
const std::string value(rng() % (kMaxValueSize + 1), static_cast<char>('a' + rng() % 26));
db.put(key, value);
model[key] = value;
} else if (op < 9) {
EXPECT_EQ(db.del(key), model.erase(key) == 1);
} else {
const auto got = db.get(key);
const auto it = model.find(key);
ASSERT_EQ(got.has_value(), it != model.end());
if (got) {
EXPECT_EQ(*got, it->second);
}
}
}
EXPECT_EQ(db.check(), model.size());
}
Db reopened(path); // clean close and reopen: same contents
const auto all = reopened.scan("", "");
ASSERT_EQ(all.size(), model.size());
auto it = model.begin();
for (const auto& [k, v] : all) {
EXPECT_EQ(k, it->first);
EXPECT_EQ(v, it->second);
++it;
}
}
TEST(Recovery, CommittedWritesSurviveWithoutCheckpoint) {
TempDir dir;
const std::string path = dir.file("db");
{
// A huge checkpoint threshold and no clean close: everything after open lives only
// in the log and in the cache. Simulate the crash by copying the files before the
// destructor runs its checkpoint.
Db db(path, Options{64, 1u << 30});
for (int i = 0; i < 500; ++i) {
db.put(key_of(i), "value" + std::to_string(i));
}
std::filesystem::copy_file(path, path + ".crash");
std::filesystem::copy_file(path + "-wal", path + ".crash-wal");
}
Db db(path + ".crash");
EXPECT_GT(db.recovery_stats().committed_txns, 0u);
EXPECT_EQ(db.check(), 500u);
for (int i = 0; i < 500; ++i) {
EXPECT_EQ(db.get(key_of(i)).value_or(""), "value" + std::to_string(i));
}
}
namespace {
// Ends of the committed records in a log file, and the page ids they carry.
struct LogSummary {
Lsn end_of_last_commit = 0;
std::vector<PageId> pages;
};
LogSummary summarize_log(const std::string& wal_path) {
LogSummary summary;
Wal wal(wal_path);
summary.end_of_last_commit = wal.start_lsn();
wal.replay([&](const WalRecord& r) {
if (r.type == RecordType::PageImage) {
summary.pages.push_back(r.page_id);
} else {
summary.end_of_last_commit = r.lsn + Wal::kRecordHeaderSize + 8;
}
});
return summary;
}
} // namespace
TEST(Recovery, TornDataPageIsRepairedFromTheLog) {
TempDir dir;
const std::string path = dir.file("db");
{
Db db(path, Options{64, 1u << 30});
for (int i = 0; i < 300; ++i) {
db.put(key_of(i), "v");
}
db.checkpoint(); // data file current, log empty
db.put(key_of(0), "after-checkpoint"); // logged; the leaf is dirty only in the cache
std::filesystem::copy_file(path, path + ".crash");
std::filesystem::copy_file(path + "-wal", path + ".crash-wal");
}
const LogSummary log = summarize_log(path + ".crash-wal");
ASSERT_FALSE(log.pages.empty());
{
// Tear the logged page in the copy: second half overwritten with garbage, as if the
// power failed in the middle of the 4 KiB write.
File f = File::open(path + ".crash", false);
const std::string junk(kPageSize / 2, '\x7f');
f.write_at(static_cast<std::uint64_t>(log.pages.front()) * kPageSize + kPageSize / 2, junk.data(),
junk.size());
}
Db db(path + ".crash");
EXPECT_GE(db.recovery_stats().pages_redone, 1u);
EXPECT_EQ(db.get(key_of(0)).value_or(""), "after-checkpoint");
EXPECT_EQ(db.check(), 300u);
}
TEST(Recovery, UncommittedTailIsDiscardedAndNotReplayedLater) {
TempDir dir;
const std::string path = dir.file("db");
{
Db db(path, Options{64, 1u << 30});
db.put("a", "1");
std::filesystem::copy_file(path, path + ".crash");
std::filesystem::copy_file(path + "-wal", path + ".crash-wal");
}
{
// Append one page image with no commit record: a crash between append and commit.
// The image is an empty leaf for the root page; applying it would wipe "a".
const LogSummary log = summarize_log(path + ".crash-wal");
Wal wal(path + ".crash-wal");
wal.truncate_at(log.end_of_last_commit);
Page empty_leaf = BPlusTree::initial_pages()[1];
wal.append_page(1, empty_leaf);
wal.sync();
}
{
Db db(path + ".crash");
EXPECT_EQ(db.recovery_stats().discarded_records, 1u);
EXPECT_EQ(db.get("a").value_or(""), "1");
db.put("b", "2"); // appended after the discarded tail was cut off
}
Db db(path + ".crash");
EXPECT_EQ(db.get("a").value_or(""), "1");
EXPECT_EQ(db.get("b").value_or(""), "2");
}
Commit¶
git add -A
git commit -m "Add Db, commit protocol, checkpoints and redo-only recovery
- Db: one mutex; every put/del is a transaction with the strong exception guarantee
- commit: append page images + commit record, fdatasync, then release pages (no-force)
- a failed fdatasync poisons the Db until reopen (fsyncgate)
- checkpoint: flush dirty pages, sync data file, reset the log; on open and on close
- Recovery: redo committed images in LSN order, skip current pages, discard the
uncommitted tail and cut the log after the last commit
- LSN 0 reserved for never-logged pages (first record at LSN 1 was skipped by redo)
- tests: model test through the API, recovery without checkpoint, torn page repair,
uncommitted tail discarded and not replayed later"
git push
What to say in an interview¶
"Every put is a transaction. Its changed pages are logged as full images plus a commit
record, the log is fdatasynced once, and only then does the call return. The data file is
written later, at eviction or checkpoint, always after the log record it depends on. Because
uncommitted pages never reach the data file, recovery is redo-only: replay the log, apply
committed page images, drop the uncommitted tail. Full images make redo idempotent and repair
torn pages for free. Compared with ARIES I gave up steal, so a transaction's pages must fit in
the cache, and in exchange I have no undo pass and no compensation records. And I can tell
you about the bug where the first write of every database was lost because the first log
record had the same LSN as a never-logged page."
Step 8. The crash test, CLI, benchmark, CI, README, tag v0.1.0¶
Time: ~6 hours Week: 9
Read first: 06_Linux_Toolchain_DevOps/09_ci_cd_github_actions_and_azure_devops.md.
04_Concurrency/01_processes_threads_and_the_os.md (fork, signals, waitpid).
tcpkv step 10 (the release checklist; apply the same one here).
Goal¶
Prove the durability contract with a test that kills the process mid-write, add a CLI and a benchmark, make CI run everything under the sanitizers, and tag v0.1.0.
Design¶
The crash test is specified in 00_spec.md section 8. The design points to defend:
fork()plusSIGKILL, not an exception orexit():SIGKILLcannot be caught, so no destructor, checkpoint or flush runs. That is a process crash, faithfully.- The pipe is the acknowledgement channel. The child writes
i + 1after operationireturns. The parent drains the pipe after the kill: everything the child wrote before dying is still there, andreadreturns 0 once the only write end has died. - Deterministic operations. Operation
iis a pure function ofi, so the parent rebuilds the expected state for any prefix with astd::map, without trusting the child. ackedoracked + 1. The operation in flight may have committed just before the kill, without its acknowledgement reaching the pipe. Anything else is a bug.- A small cache and a small checkpoint threshold in the child, so kills land during evictions and checkpoints, not only during commits.
- The test must be able to fail. It also asserts that something was acknowledged, and it
was checked against a mutant: with redo disabled in
Recovery, it fails on the first kill (state matches neither 28 nor 29 operations). Do the same mutation on your version once. - What it does not test: power loss. After
SIGKILLthe kernel still writes its page cache to disk, so an engine that never calledfdatasyncwould pass. Say so before you are asked, and name the tools that do test it (dm-log-writes,dm-flakey, LazyFS, VM snapshots).
Files¶
tests/crash_test.cpp, apps/minidb_cli.cpp, tools/bench.cpp, README.md, CI updates.
Code¶
// The crash test: a child process writes as fast as it can and reports every acknowledged
// operation through a pipe; the parent SIGKILLs it at a random moment, reopens the database
// and checks that the state equals the model after exactly the acknowledged operations
// (or after one more: the operation in flight may have committed just before the kill).
//
// SIGKILL cannot be caught, so no destructor, no checkpoint and no flush runs in the child.
// That is the same as a process crash. It is not the same as a power failure (the kernel
// still writes back the page cache); see the spec, section 8, for what that would take.
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <map>
#include <random>
#include <string>
#include <thread>
#include <gtest/gtest.h>
#include "minidb/db.hpp"
#include "test_util.hpp"
using namespace minidb;
namespace {
constexpr int kKeySpace = 400;
std::string key_of(int i) {
char buf[16];
std::snprintf(buf, sizeof buf, "k%05d", i % kKeySpace);
return buf;
}
// Operation i is a pure function of i, so the parent can recompute the expected state for
// any prefix of the operation sequence without talking to the child.
struct Op {
bool is_delete = false;
std::string key;
std::string value;
};
Op op_at(std::uint32_t i) {
Op op;
op.key = key_of(static_cast<int>((i * 7u) % kKeySpace));
op.is_delete = i % 11 == 10;
if (!op.is_delete) {
op.value = "v" + std::to_string(i) + ":" + std::string(i % 600, 'x'); // 0..~610 bytes
}
return op;
}
std::map<std::string, std::string> model_after(std::uint32_t count) {
std::map<std::string, std::string> model;
for (std::uint32_t i = 0; i < count; ++i) {
const Op op = op_at(i);
if (op.is_delete) {
model.erase(op.key);
} else {
model[op.key] = op.value;
}
}
return model;
}
[[noreturn]] void child_main(const std::string& path, int ack_fd) {
try {
// Small cache and a small checkpoint threshold, so kills land during evictions and
// during checkpoints, not only during commits.
Db db(path, Options{16, 128u << 10});
for (std::uint32_t i = 0;; ++i) {
const Op op = op_at(i);
if (op.is_delete) {
db.del(op.key);
} else {
db.put(op.key, op.value);
}
// The acknowledgement: operation i returned, so it must survive any crash.
const std::uint32_t done = i + 1;
if (::write(ack_fd, &done, sizeof done) != static_cast<ssize_t>(sizeof done)) {
::_exit(3);
}
}
} catch (...) {
::_exit(2);
}
}
std::map<std::string, std::string> contents(Db& db) {
std::map<std::string, std::string> out;
for (auto& [k, v] : db.scan("", "")) {
out.emplace(std::move(k), std::move(v));
}
return out;
}
} // namespace
TEST(Crash, SigkillMidWriteLosesNoAcknowledgedOperation) {
TempDir dir;
const std::string path = dir.file("db");
std::mt19937 rng(std::random_device{}());
std::uint32_t total_acked = 0;
for (int round = 0; round < 25; ++round) {
// Every round starts from an empty database, so the expected state is simply the
// model after the first `acked` operations.
std::filesystem::remove(path);
std::filesystem::remove(path + "-wal");
int fds[2];
ASSERT_EQ(::pipe(fds), 0);
const pid_t pid = ::fork();
ASSERT_GE(pid, 0);
if (pid == 0) {
::close(fds[0]);
child_main(path, fds[1]); // never returns
}
::close(fds[1]);
// Let the child run for a random 20..200 ms, then kill it wherever it is.
std::this_thread::sleep_for(std::chrono::milliseconds(20 + rng() % 180));
ASSERT_EQ(::kill(pid, SIGKILL), 0);
int status = 0;
ASSERT_EQ(::waitpid(pid, &status, 0), pid);
ASSERT_TRUE(WIFSIGNALED(status)) << "child exited early with status " << WEXITSTATUS(status);
// Acknowledgements written before the kill are still in the pipe. read() returns 0
// once they are drained, because the only write end died with the child.
std::uint32_t acked = 0;
std::uint32_t value = 0;
while (::read(fds[0], &value, sizeof value) == static_cast<ssize_t>(sizeof value)) {
acked = value;
}
::close(fds[0]);
Db db(path, Options{16, 128u << 10}); // runs recovery
db.check();
const auto actual = contents(db);
EXPECT_TRUE(actual == model_after(acked) || actual == model_after(acked + 1))
<< "round " << round << ": state matches neither " << acked << " nor " << acked + 1
<< " operations (recovered " << db.recovery_stats().committed_txns << " txns, discarded "
<< db.recovery_stats().discarded_records << " records)";
total_acked += acked;
if (HasFailure()) {
break;
}
}
EXPECT_GT(total_acked, 0u) << "the child never acknowledged anything; the test proved nothing";
std::printf("[ crash ] %u operations acknowledged across 25 kills\n", total_acked);
}
Notes on the code:
- The child calls
::_exit, neverexit, on its error paths:exitwould run GoogleTest's and the parent's static destructors in the child, a classicforkbug. forkis safe here because the test process is single-threaded at that point. Forking a multithreaded process copies only the calling thread, and any mutex another thread held stays locked forever in the child.- A run prints how much it proved, for example
[ crash ] 1011 operations acknowledged across 25 kills(from one run of the reference code on WSL2; yours will differ). If that number is near zero, the kills are landing before the database even opens: raise the sleep range.
apps/minidb_cli.cpp, outlined (about 80 lines; you wrote tcpkv_cli already):
minidb_cli <file> <command> [args]; commands as in00_spec.mdsection 3.2.- Open
minidb::Db db(file);printdb.recovery_stats()on stderr if it redid or discarded anything, so you can watch recovery happen after youkill -9a writer. puttakes the rest of the command line as the value;getprints the value or(nil);scanprintskey<TAB>valuelines;checkprints the key count or theCorruptionErrormessage and exits 1;statsprints page count (meta), log size, cache hits/misses.- Catch
std::invalid_argument(usage error, exit 2),minidb::CorruptionError(exit 3),std::exception(exit 1).
A manual crash demo for the README:
./build/release/minidb_cli /tmp/demo.db put greeting hello
( while true; do ./build/release/minidb_cli /tmp/demo.db put k$RANDOM v; done ) &
sleep 2; kill -9 %1
./build/release/minidb_cli /tmp/demo.db check # recovery runs, then the checker
(kill -9 here hits the shell loop or one CLI process mid-write; either way the next open
recovers.)
tools/bench.cpp, outlined: minidb_bench <file> <count> <value_size> times count puts with
sequential keys, count puts with random keys, count gets, and one full scan, and prints
operations per second for each. Expect puts to be bound by fdatasync latency, so the number
mostly measures your disk (and WSL2's virtual disk), not the tree: say exactly that in the
README, next to the numbers and the machine. The experiment worth adding: a second run with a
WriteBatch of 100 puts per transaction (one sync per batch) shows how much group commit
would buy.
CI: add crash to the test list (already in the CMakeLists.txt above) and keep the
debug, release, asan, tsan, ubsan matrix from tcpkv. On the TSan job, apply the same
vm.mmap_rnd_bits=28 step as tcpkv.
README: follow 00_spec.md section 10. The first screen must state the durability contract
in one sentence and link to the crash test.
Build and run¶
cmake --preset release && cmake --build --preset release && ctest --preset release
cmake --preset asan && cmake --build --preset asan && ctest --preset asan
setarch $(uname -m) -R cmake --preset tsan && setarch $(uname -m) -R cmake --build --preset tsan && setarch $(uname -m) -R ctest --preset tsan
./build/release/minidb_bench /tmp/bench.db 10000 100
Tests¶
Crash.SigkillMidWriteLosesNoAcknowledgedOperation passes in every preset, and fails against
the redo-disabled mutant. The full suite is then 21 tests: 9 storage, 4 tree, 7 database and
recovery, 1 crash.
Commit and tag¶
git add -A
git commit -m "Add SIGKILL crash test, CLI and benchmark; release 0.1.0
- crash test: forked writer acknowledges each op through a pipe, SIGKILLed at a random
moment 25 times; reopened state must equal the model after acked or acked+1 ops
- verified to fail when recovery skips redo
- minidb_cli (put/get/del/scan/check/stats), minidb_bench (fsync-bound put rate, get rate)
- README: durability contract, format, commit protocol, recovery, limitations"
git push
git tag -a v0.1.0 -m "minidb 0.1.0: B+tree storage engine with WAL and crash recovery"
git push origin v0.1.0
Then make the repository public and pin it next to tcpkv, exactly as in tcpkv step 10.
What to say in an interview¶
"The durability claim is tested, not asserted. A forked child writes as fast as it can and
reports each acknowledged operation through a pipe; the parent kills it with SIGKILL at a
random moment, reopens the database, which runs recovery, and checks that the contents equal a
model after exactly the acknowledged operations, or one more for the operation in flight. It
does that 25 times per run, under ASan and TSan too, and I checked that the test fails if I
disable redo. What it does not cover is power loss, because after a process crash the kernel
still flushes its page cache; for that I would use dm-log-writes or LazyFS to replay every
prefix of the write stream."
After v0.1.0: the resume line, and what next¶
Only once the tag exists, CI is green and the repository is public:
minidb — B+tree storage engine with write-ahead logging · C++17, Linux, CMake, GoogleTest · github.com/khanshaki-r/minidb - Page-based key-value store: 4 KiB checksummed pages, LRU buffer pool with no-steal/no-force policies, B+tree with byte-balanced splits and range scans, full-page-image WAL with LSNs, checkpoints, and redo-only crash recovery. - Crash test kills the writer with SIGKILL mid-operation 25 times per run and verifies that every acknowledged write survives and no unacknowledged one appears; runs under ASan, TSan and UBSan in CI.
Do not add a throughput number unless you measured it with the procedure from tcpkv step 10, and if you do, say that it is fsync-bound.
What next, in the order an interviewer would find most interesting (each is in 00_spec.md
section 11): group commit; delta log records with a full image on first touch after a
checkpoint; latch crabbing for concurrent readers; merges and a free list; power-loss testing.