// 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 #include 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 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& 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(offset)); file.write(reinterpret_cast(&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 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 table(path); CheckEqual(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 table(path, { .durability = DB::Durability::EveryAppend }); table.Append({ "durable", 101 }); Die(); }); { DB::Table table(path); CheckEqual(table.Size(), 102, "EveryAppend row survived a crash"); } // Uncommitted growth is truncated on open. std::uint64_t committedLength = 0; InChildWithoutCleanup([&] { DB::Table 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 table(path); CheckEqual(fs::file_size(path), committedLength, "writable open truncates uncommitted growth"); CheckEqual(table.Size(), 102, "rows unchanged"); } // Tombstones written before an unclean exit are recounted. InChildWithoutCleanup([&] { DB::Table table(path); table.Erase(0); table.Erase(1); Die(); }); { DB::Table table(path); CheckEqual(table.Size(), 100, "live count recounted after unclean exit"); std::uint64_t seen = 0; for ([[maybe_unused]] DB::Row row : table) seen++; CheckEqual(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 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 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 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(info.commit.commitSeq, before.commitSeq, "child wrote no commit record"); CheckEqual(table.Size(), before.liveCount - 1, "parent still sees its tombstone"); table.Flush(); const DB::Inspection after = DB::TableFile::Inspect(path); CheckEqual(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 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 table(path); CheckEqual(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; }