Crafter.DB/tests/ShouldRecoverCommits/main.cpp

213 lines
8.6 KiB
C++
Raw Normal View History

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Crash safety of the commit protocol. A child process appends without
// flushing and dies: the parent must see exactly the committed rows. A
// damaged newer commit slot falls back to the older one. Uncommitted growth
// is truncated on the next writable open. Tombstones written before an
// unclean exit are recounted.
#include <sys/wait.h>
#include <unistd.h>
import Crafter.DB;
import std;
using namespace Crafter;
namespace fs = std::filesystem;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
template<typename T>
void CheckEqual(const T& actual, const T& expected, std::string_view what) {
if (!(actual == expected)) {
std::println("FAIL: {} — expected '{}', got '{}'", what, expected, actual);
++failures;
}
}
struct Entry {
std::string text;
std::uint64_t n;
static constexpr std::uint32_t version = 1;
};
// Runs `work` in a child. `work` must end with Die() while its table is
// still in scope, so no destructor (and no Flush) ever runs.
[[noreturn]] void Die() { _exit(0); }
void InChildWithoutCleanup(const std::function<void()>& work) {
const pid_t pid = fork();
if (pid == 0) {
try {
work();
} catch (const std::exception& error) {
std::println("child failed: {}", error.what());
_exit(2);
}
_exit(5); // work() returned instead of dying: the test is wrong
}
int status = 0;
waitpid(pid, &status, 0);
Check(WIFEXITED(status) && WEXITSTATUS(status) == 0, "child ran to its exit point");
}
void Patch(const fs::path& path, std::uint64_t offset, std::byte value) {
std::fstream file(path, std::ios::in | std::ios::out | std::ios::binary);
file.seekp(static_cast<std::streamoff>(offset));
file.write(reinterpret_cast<const char*>(&value), 1);
}
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-recover";
fs::remove_all(dir);
const fs::path path = dir / "log.cdb";
// Committed rows survive, unflushed rows vanish, the table stays valid.
InChildWithoutCleanup([&] {
DB::Table<Entry> table(path);
for (std::uint64_t i = 0; i < 100; i++) table.Append({ std::format("committed {}", i), i });
table.Flush();
for (std::uint64_t i = 100; i < 150; i++) table.Append({ std::format("lost {}", i), i });
Die(); // no Flush, no destructor
});
{
DB::Table<Entry> table(path);
CheckEqual<std::uint64_t>(table.Size(), 100, "only flushed rows are visible after a crash");
Check(table.Back()[&Entry::text] == "committed 99", "last committed row intact");
table.Append({ "after crash", 100 });
Check(table[100][&Entry::text] == "after crash", "slot after the committed rows is reusable");
table.Flush();
}
// EveryAppend durability: nothing is lost.
InChildWithoutCleanup([&] {
DB::Table<Entry> table(path, { .durability = DB::Durability::EveryAppend });
table.Append({ "durable", 101 });
Die();
});
{
DB::Table<Entry> table(path);
CheckEqual<std::uint64_t>(table.Size(), 102, "EveryAppend row survived a crash");
}
// Uncommitted growth is truncated on open.
std::uint64_t committedLength = 0;
InChildWithoutCleanup([&] {
DB::Table<Entry> table(path);
table.Flush();
committedLength = fs::file_size(path);
for (std::uint64_t i = 0; i < 2000; i++) table.Append({ std::string(100, 'g'), i }); // forces new extents
Die();
});
{
const DB::Inspection info = DB::TableFile::Inspect(path);
committedLength = info.commit.fileLength;
Check(fs::file_size(path) > committedLength, "uncommitted extents extend the file before recovery");
DB::Table<Entry> table(path);
CheckEqual<std::uint64_t>(fs::file_size(path), committedLength, "writable open truncates uncommitted growth");
CheckEqual<std::uint64_t>(table.Size(), 102, "rows unchanged");
}
// Tombstones written before an unclean exit are recounted.
InChildWithoutCleanup([&] {
DB::Table<Entry> table(path);
table.Erase(0);
table.Erase(1);
Die();
});
{
DB::Table<Entry> table(path);
CheckEqual<std::uint64_t>(table.Size(), 100, "live count recounted after unclean exit");
std::uint64_t seen = 0;
for ([[maybe_unused]] DB::Row<Entry> row : table) seen++;
CheckEqual<std::uint64_t>(seen, 100, "iteration honours recovered tombstones");
table.Compact();
table.Flush();
}
// An Update whose heap bytes were written but whose process died before
// any Flush: the row must read as either the old or the new value, never
// point at uncommitted heap bytes.
InChildWithoutCleanup([&] {
DB::Table<Entry> table(path);
table.Flush();
table.Update(5, { std::string(300, 'u'), 5 }); // long value -> heap
table.Set<&Entry::text>(6, std::string(200, 's'));
Die();
});
{
DB::Table<Entry> table(path);
const std::string five(table[5][&Entry::text]);
Check(five == std::string(300, 'u') || five == "committed 5", std::format("updated row is consistent after a crash (got {} bytes)", five.size()));
const std::string six(table[6][&Entry::text]);
Check(six == std::string(200, 's') || six == "committed 6", "Set row is consistent after a crash");
Check(table.Verify().Ok(), "no dangling heap references after a crashed Update");
table.Append({ "after update crash", 7 });
Check(table[5][&Entry::text] == five, "later appends do not change the updated row's text");
table.Flush();
}
// A forked child that destroys its inherited Table must not write a
// commit record: its counters are the parent's at fork time.
{
DB::Table<Entry> table(path);
table.Append({ "parent row", 8 });
table.Flush();
const DB::TableStats before = table.Stats();
table.Erase(2); // tombstone after the flush
const pid_t pid = fork();
if (pid == 0) {
// normal destruction path in the child
bool threw = false;
try {
table.Append({ "child", 9 });
} catch (const DB::Error&) {
threw = true;
}
if (!threw) _exit(6);
const std::string_view text = table[0][&Entry::text]; // reads work on the shared mapping
if (text.size() == 0) _exit(7);
table.~Table();
_exit(0);
}
int status = 0;
waitpid(pid, &status, 0);
Check(WIFEXITED(status) && WEXITSTATUS(status) == 0, std::format("child refused to write and destroyed its table (status {})", WIFEXITED(status) ? WEXITSTATUS(status) : -1));
const DB::Inspection info = DB::TableFile::Inspect(path);
CheckEqual<std::uint64_t>(info.commit.commitSeq, before.commitSeq, "child wrote no commit record");
CheckEqual<std::uint64_t>(table.Size(), before.liveCount - 1, "parent still sees its tombstone");
table.Flush();
const DB::Inspection after = DB::TableFile::Inspect(path);
CheckEqual<std::uint64_t>(after.commit.liveCount, before.liveCount - 1, "parent's commit carries the tombstone");
}
// A damaged newer commit slot falls back to the older one.
{
{
DB::Table<Entry> table(path);
table.Append({ "slot A", 1 });
table.Flush(); // commit N: rows r+1
table.Append({ "slot B", 2 }); // the destructor's clean close writes commit N+1: rows r+2
}
const DB::Inspection before = DB::TableFile::Inspect(path);
const std::size_t newer = before.header.commits[0].commitSeq > before.header.commits[1].commitSeq ? 0 : 1;
Patch(path, 3072 + newer * 512 + 8, std::byte{ 0xEE }); // corrupt rowCount of the newest slot -> CRC fails
DB::Table<Entry> table(path);
CheckEqual<std::uint64_t>(table.SlotCount(), before.commit.rowCount - 1, "fell back to the previous commit");
Check(table.Back()[&Entry::text] == "slot A", "the row of the lost commit is gone");
}
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}