yams/tests/unit/cli/doctor/db_integrity_catch2_test.cpp
trvon 8da4df3568
fix(daemon): delete corrupt DB copies only after confirmed salvage
Corrupt-DB handling could destroy the only copy of documents:

- removeCorruptDbFiles() deleted every readable yams.db.corrupt-* after a
  startup or `repair --orphans --remove-corrupt` salvage, including DBs whose
  salvageFromCorruptDb() had failed or only partly copied, and DBs that were
  never salvaged because their document count was <= the live DB's.
- A corrupt DB whose count was <= the live DB's was otherwise kept forever
  with only a "skipping" log, with no path that ever decided it was safe to
  drop.
- quarantineAndRecreate() renamed onto yams.db.corrupt-<second>, so two
  quarantines in the same second overwrote the first copy.

Deletion is now decided by evidence, not by which code path ran:
corruptDbSalvageConfirmed() opens the corrupt DB read-only, attaches the
live DB and checks that every document hash in it is present in the live
DB (salvage copies documents keyed by hash). removeSalvagedCorruptDbs()
removes an artifact only when that check succeeds and the artifact is older
than a retention window (quarantine timestamp from its name, else mtime);
unreadable, unconfirmed or young artifacts are kept and reported with the
reason.

Startup salvage no longer deletes anything. RepairService owns cleanup:
runRecoveryArtifactMaintenance() runs from the background loop when
maintenanceAllowed(), every recoveryArtifactSweepInterval (6h), with
corruptDbRetention (7 days) in RepairService::Config. An explicit
`repair --orphans --remove-corrupt` waives the retention window but still
requires confirmation. yams doctor explains the retention policy next to
the artifacts it lists.

The "yams.db" / ".corrupt-" naming is centralized in db_recovery.h
(kMetadataDbFileName, kCorruptDbMarker, corruptDbPrefix(),
isCorruptDbFileName(), listCorruptDbs()) and used by salvage, recovery,
RepairService and the doctor checks instead of four hand-rolled scans.

Signed-off-by: trvon <me@trevon.dev>
2026-09-27 13:57:29 -06:00

170 lines
5.1 KiB
C++

// Unit tests for extracted doctor check modules
// Catch2 tests for DbIntegrityCheck
#include <catch2/catch_test_macros.hpp>
#include <yams/cli/doctor/checks/db_integrity.h>
#include <yams/cli/doctor/doctor_context.h>
#include <yams/cli/yams_cli.h>
#include <filesystem>
#include <fstream>
#include <memory>
#include <sstream>
#include "../../../common/test_helpers_catch2.h"
namespace fs = std::filesystem;
using namespace yams::cli::doctor;
// Catch2's expression decomposition intentionally overloads comparison operators.
// NOLINTBEGIN(bugprone-chained-comparison)
namespace {
struct DoctorTestEnv {
fs::path dataDir;
yams::test::ScopedEnvVar dataEnv;
yams::test::ScopedEnvVar homeEnv;
explicit DoctorTestEnv()
: dataDir(yams::test::make_temp_dir("yams_doctor_test_")),
dataEnv("YAMS_DATA_DIR", dataDir.string()), homeEnv("HOME", dataDir.string()) {
fs::create_directories(dataDir);
}
std::unique_ptr<yams::cli::YamsCLI> makeCli() { return std::make_unique<yams::cli::YamsCLI>(); }
};
} // namespace
TEST_CASE("DbIntegrityCheck - empty data dir has no issues", "[doctor][db_integrity]") {
DoctorTestEnv env;
auto cli = env.makeCli();
DoctorContext ctx(cli.get());
DbIntegrityCheck check;
auto result = check.execute(ctx);
CHECK(result.metadataWalBytes == 0);
CHECK(result.vectorWalBytes == 0);
CHECK(result.corruptArtifacts.empty());
CHECK(result.ok == true);
}
TEST_CASE("DbIntegrityCheck - detects WAL file presence", "[doctor][db_integrity]") {
DoctorTestEnv env;
// Create a small WAL file (under the 128 MB warn threshold)
fs::path walPath = env.dataDir / "yams.db-wal";
{
std::ofstream w(walPath, std::ios::binary);
for (int i = 0; i < 1024; ++i)
w.put(static_cast<char>(i % 256));
w.close();
}
auto cli = env.makeCli();
DoctorContext ctx(cli.get());
DbIntegrityCheck check;
auto result = check.execute(ctx);
CHECK(result.metadataWalBytes == 1024);
CHECK(result.ok == true);
}
TEST_CASE("DbIntegrityCheck - warns on large WAL", "[doctor][db_integrity]") {
DoctorTestEnv env;
// Create a sparse WAL that reports > 128 MB. Use seek + put for sparse file.
fs::path walPath = env.dataDir / "yams.db-wal";
{
std::ofstream w(walPath, std::ios::binary);
w.seekp(200ULL * 1024ULL * 1024ULL - 1);
w.put('\0');
w.close();
}
auto cli = env.makeCli();
DoctorContext ctx(cli.get());
DbIntegrityCheck check;
auto result = check.execute(ctx);
CHECK(result.metadataWalBytes >= 128ULL * 1024ULL * 1024ULL);
CHECK(result.ok == false);
}
TEST_CASE("DbIntegrityCheck - readonly FTS validation row is not corruption",
"[doctor][db_integrity][readonly][fts5]") {
const std::string readonlyFtsRow =
"unable to validate the inverted index for FTS5 table main.kg_aliases_fts: attempt to "
"write a readonly database";
CHECK(testing_isReadOnlyFtsValidationLine(readonlyFtsRow));
CHECK_FALSE(testing_isReadOnlyFtsValidationLine(
"unable to validate the inverted index for FTS5 table main.kg_aliases_fts: database is "
"malformed"));
CHECK_FALSE(testing_isReadOnlyFtsValidationLine("row 3 missing from index idx_documents_path"));
}
TEST_CASE("DbIntegrityCheck - detects corrupt artifacts", "[doctor][db_integrity]") {
DoctorTestEnv env;
// Create a corrupt artifact file
fs::path corruptPath = env.dataDir / "yams.db.corrupt-20260501T120000Z";
{
std::ofstream c(corruptPath, std::ios::binary);
c << "corrupt data";
c.close();
}
auto cli = env.makeCli();
DoctorContext ctx(cli.get());
DbIntegrityCheck check;
auto result = check.execute(ctx);
CHECK_FALSE(result.corruptArtifacts.empty());
CHECK(result.ok == false);
std::ostringstream os;
DbIntegrityCheck::render(os, result);
CHECK(os.str().find("retention window") != std::string::npos);
}
TEST_CASE("DbIntegrityCheck - reports quarantined WAL sidecars", "[doctor][db_integrity]") {
DoctorTestEnv env;
fs::path quarantinedWal = env.dataDir / "yams.db-wal.quarantine-20260501T120000Z";
{
std::ofstream w(quarantinedWal, std::ios::binary);
w << "uncheckpointed frames";
}
auto cli = env.makeCli();
DoctorContext ctx(cli.get());
DbIntegrityCheck check;
auto result = check.execute(ctx);
REQUIRE(result.corruptArtifacts.size() == 1);
CHECK(result.corruptArtifacts.front().find("yams.db-wal.quarantine-") != std::string::npos);
CHECK(result.ok == false);
}
TEST_CASE("DbIntegrityCheck - render produces output", "[doctor][db_integrity]") {
DoctorTestEnv env;
auto cli = env.makeCli();
DoctorContext ctx(cli.get());
DbIntegrityCheck check;
auto result = check.execute(ctx);
std::ostringstream os;
DbIntegrityCheck::render(os, result);
auto output = os.str();
CHECK_FALSE(output.empty());
CHECK(output.find("yams.db-wal") != std::string::npos);
CHECK(output.find("vectors.db-wal") != std::string::npos);
}
// NOLINTEND(bugprone-chained-comparison)