yams/tests/unit/daemon/db_recovery_test.cpp
Trevon b603738f44
Stabilize sanitizer lanes and daemon concurrency (#40)
* fix(repair): keep RPC progress alive

* fix(daemon): propagate DB open failure to FSM and add shutdown cancellation

* fix(db): classify FTS5 inverted-index errors as repairable, not transient locks

* fix(daemon): clean up stale corrupt files after salvage and suppress optional readiness keys

* test(db_recovery): update FTS5 classification test for new transient-lock exclusion

* test(gc): add garbage collector unit tests for collection, dry-run, scheduling

* test(repair): expand repair manager tests with hash-verified recovery and source ordering

* fix(gc): dry-run mode no longer increments blocksDeleted/bytesReclaimed

* fix(mock,mcp): add atomic bool for lock consistency, move field assignments

* style: fix unnecessary-copy, dead-store, and const-refable findings from Infer

* fix(daemon): increase default shutdown timeout from 15s to 120s

* fix(daemon): demote non-critical warnings to info (FTS5, VACUUM, WorkCoordinator timeout)

* fix(db): re-enable WAL auto-checkpoint to prevent unbounded WAL growth

* test(pool): add WAL auto-checkpoint and concurrency coverage for connection pool

* test(pool): add exhaustion, stats, lifecycle, and read-only connection pool tests

* test(pool): add tagging, WAL journal, reuse, and busy timeout tests

* test(metrics): relax session snippet assertion to tolerate stub behavior

* fix(test): stabilize sanitizer triage failures

* fix(build): wire version and zyp deps

* fix(daemon): close TSAN race clusters

* fix(test): guard optional zyp dep

* fix(test): address PR review findings
2026-06-01 12:14:43 -04:00

153 lines
5.2 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <yams/daemon/components/db_recovery.h>
#include <yams/metadata/database.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <string>
namespace fs = std::filesystem;
namespace {
fs::path makeScratchDir(const std::string& prefix) {
auto base = fs::temp_directory_path() /
(prefix + "_" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
fs::create_directories(base);
return base;
}
void writeFile(const fs::path& p, const std::string& content) {
std::ofstream out(p, std::ios::binary);
out.write(content.data(), static_cast<std::streamsize>(content.size()));
}
void corruptHeader(const fs::path& p) {
std::fstream f(p, std::ios::in | std::ios::out | std::ios::binary);
REQUIRE(f.is_open());
f.seekp(20, std::ios::beg);
const char garbage[] = "\xff\xff\xff\xff\xff\xff\xff\xff";
f.write(garbage, 8);
}
} // namespace
TEST_CASE("Database::checkIntegrity passes on a fresh DB", "[unit][daemon][db_recovery]") {
auto dir = makeScratchDir("yams_db_recovery_ok");
auto dbPath = dir / "yams.db";
yams::metadata::Database db;
auto open = db.open(dbPath.string(), yams::metadata::ConnectionMode::Create);
REQUIRE(open);
auto check = db.checkIntegrity();
REQUIRE(check);
db.close();
std::error_code ec;
fs::remove_all(dir, ec);
}
TEST_CASE("Database::checkIntegrity detects header corruption", "[unit][daemon][db_recovery]") {
auto dir = makeScratchDir("yams_db_recovery_corrupt");
auto dbPath = dir / "yams.db";
{
yams::metadata::Database db;
REQUIRE(db.open(dbPath.string(), yams::metadata::ConnectionMode::Create));
REQUIRE(db.execute("CREATE TABLE t(x INTEGER)"));
REQUIRE(db.execute("INSERT INTO t VALUES(1)"));
db.close();
}
corruptHeader(dbPath);
yams::metadata::Database db;
auto open = db.open(dbPath.string(), yams::metadata::ConnectionMode::ReadWrite);
if (!open) {
SUCCEED("open failed; corruption detected at open time");
} else {
auto check = db.checkIntegrity();
REQUIRE_FALSE(check);
}
db.close();
std::error_code ec;
fs::remove_all(dir, ec);
}
TEST_CASE("Database::checkIntegrity classifies FTS5 corruption distinctly",
"[unit][daemon][db_recovery]") {
using yams::metadata::testing_isTransientIntegrityCheckMessage;
// FTS5 inverted-index + "locked" is FTS5 corruption, not transient lock.
CHECK_FALSE(testing_isTransientIntegrityCheckMessage(
"unable to validate the inverted index for FTS5 table main.documents_fts: database is "
"locked"));
// Genuine transient lock messages should still be classified as transient.
CHECK(testing_isTransientIntegrityCheckMessage("database table is locked"));
CHECK(testing_isTransientIntegrityCheckMessage("database is busy"));
// Hard corruption should still be non-transient.
CHECK_FALSE(testing_isTransientIntegrityCheckMessage("database disk image is malformed"));
}
TEST_CASE("quarantineAndRecreate renames db + sentinel + sibling files",
"[unit][daemon][db_recovery]") {
auto dir = makeScratchDir("yams_db_recovery_quarantine");
auto dbPath = dir / "yams.db";
writeFile(dbPath, std::string("SQLite format 3\0fakecontent", 27));
writeFile(fs::path(dbPath.string() + "-wal"), "wal");
writeFile(fs::path(dbPath.string() + "-shm"), "shm");
auto res = yams::daemon::quarantineAndRecreate(dbPath);
REQUIRE(res);
REQUIRE_FALSE(fs::exists(dbPath));
REQUIRE(fs::exists(res.value().quarantinedPath));
REQUIRE(fs::exists(res.value().sentinelPath));
REQUIRE(fs::exists(fs::path(res.value().quarantinedPath.string() + "-wal")));
REQUIRE(fs::exists(fs::path(res.value().quarantinedPath.string() + "-shm")));
auto sentinel = yams::daemon::readLatestRecoverySentinel(dbPath);
REQUIRE(sentinel.has_value());
REQUIRE(sentinel->quarantinedPath == res.value().quarantinedPath);
REQUIRE(sentinel->timestamp == res.value().timestamp);
std::error_code ec;
fs::remove_all(dir, ec);
}
TEST_CASE("quarantineAndRecreate fails cleanly when source is missing",
"[unit][daemon][db_recovery]") {
auto dir = makeScratchDir("yams_db_recovery_missing");
auto dbPath = dir / "yams.db";
auto res = yams::daemon::quarantineAndRecreate(dbPath);
REQUIRE_FALSE(res);
auto sentinel = yams::daemon::readLatestRecoverySentinel(dbPath);
REQUIRE_FALSE(sentinel.has_value());
std::error_code ec;
fs::remove_all(dir, ec);
}
TEST_CASE("Recovery sentinel survives across calls and is idempotent to read",
"[unit][daemon][db_recovery]") {
auto dir = makeScratchDir("yams_db_recovery_sentinel");
auto dbPath = dir / "yams.db";
writeFile(dbPath, "stub");
auto first = yams::daemon::quarantineAndRecreate(dbPath);
REQUIRE(first);
auto sentinel1 = yams::daemon::readLatestRecoverySentinel(dbPath);
auto sentinel2 = yams::daemon::readLatestRecoverySentinel(dbPath);
REQUIRE(sentinel1.has_value());
REQUIRE(sentinel2.has_value());
REQUIRE(sentinel1->quarantinedPath == sentinel2->quarantinedPath);
std::error_code ec;
fs::remove_all(dir, ec);
}