213 lines
8.6 KiB
C++
213 lines
8.6 KiB
C++
|
|
// 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;
|
||
|
|
}
|